Cardboy Time Sync Companion
This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and updates its wall clock using the custom time sync service exposed by the firmware. The sources live inside the existing cardboy-companion/cardboy-companion.xcodeproj so you can open and run them directly in Xcode.
Requirements
- Xcode 15 or newer
- iOS 16 or newer deployment target (can be lowered to 15 with minor API tweaks)
- A Cardboy running firmware that includes the BLE time sync service
How it works
- The app scans for peripherals exposing service UUID
00000001-CA7B-4EFD-B5A6-10C3F4D3F230. - Once connected it discovers characteristic
00000002-CA7B-4EFD-B5A6-10C3F4D3F231. - Tapping Sync Now writes a 12‑byte payload containing:
- 8 bytes Unix epoch seconds (little endian)
- 2 bytes time zone offset in minutes from UTC (little endian)
- 1 byte DST flag (
1if daylight saving is active) - 1 reserved byte (
0)
- The firmware applies the timestamp with
settimeofday()and updates the TZ environment variable so the clock app renders local time.
Usage
- Open
cardboy-companion/cardboy-companion.xcodeprojin Xcode. - Ensure the
CoreBluetoothcapability is enabled for thecardboy-companiontarget and keep the Uses Bluetooth LE accessories background mode on (preconfigured in this project). - Build & run on a real device (BLE is not available in the simulator).
- Allow Bluetooth permissions when prompted. The app keeps scanning in the background, so the Cardboy can request a sync even while the companion is not foregrounded. Tap Sync Now any time you want to trigger a manual refresh.
- Switch to the Files tab to browse the LittleFS volume on the Cardboy: you can upload ROMs from the Files picker, create/remove folders, rename entries, delete files, and download items back to the phone for sharing.
BLE File Service Protocol
The ESP firmware exposes a custom GATT service (UUID 00000010-CA7B-4EFD-B5A6-10C3F4D3F230) with two characteristics:
| Characteristic | UUID | Properties | Direction | Description |
|---|---|---|---|---|
| File Command | 00000011-CA7B-4EFD-B5A6-10C3F4D3F231 |
Write / Write Without Response | iOS → ESP | Sends file management requests |
| File Response | 00000012-CA7B-4EFD-B5A6-10C3F4D3F232 |
Notify | ESP → iOS | Streams command results (responses or data) |
All payloads share the same framing. Commands written to the File Command characteristic use:
Offset | Size | Description
-------+------+------------
0 | 1 | Opcode (see table below)
1 | 1 | Reserved (set to 0)
2 | 2 | Little-endian payload length in bytes (N)
4 | N | Command payload
Notifications from the File Response characteristic use:
Offset | Size | Description
-------+------+------------
0 | 1 | Opcode (echoed from command)
1 | 1 | Status byte (bit 7 = completion flag; lower 7 bits = error code)
2 | 2 | Little-endian payload length (N)
4 | N | Response payload (command-specific)
Status byte semantics:
- Bit 7 (0x80) set → final packet for the current command (no further fragments).
- Lower 7 bits = error code (
0= success, otherwiseerrno-style code echoed back).- On error the response payload may contain a UTF-8 message.
Opcodes and Payloads
| Opcode | Name | Command Payload | Response Payload |
|---|---|---|---|
0x01 |
List Directory | uint16 path_len + UTF-8 path |
One or more fragments, each entry encoded as: - uint8 type (0=file, 1=dir)- uint8 reserved- uint16 name_len- uint32 size (0 for dirs)- name_len bytes UTF-8 nameFinal notification has completion bit set. |
0x02 |
Upload Begin | uint16 path_len + UTF-8 path + uint32 file_size |
Empty payload on success. Starts upload session (expects UploadChunk packets). |
0x03 |
Upload Chunk | Raw file bytes | Empty payload ack for each chunk. |
0x04 |
Upload End | No payload | Empty payload confirming completion. |
0x05 |
Download Request | uint16 path_len + UTF-8 path |
First notification: 4-byte little-endian total file size; subsequent notifications stream raw file data fragments. Completion bit marks the final chunk. |
0x06 |
Delete File | uint16 path_len + UTF-8 path |
Empty payload on success. |
0x07 |
Create Directory | uint16 path_len + UTF-8 path |
Empty payload on success. |
0x08 |
Delete Directory | uint16 path_len + UTF-8 path |
Empty payload on success. |
0x09 |
Rename Path | uint16 src_len + UTF-8 source path + uint16 dst_len + UTF-8 destination path |
Empty payload on success. |
Notes
- Paths are absolute within the LittleFS volume; the firmware normalizes them and rejects entries containing
... - Large responses (directory lists, downloads) may arrive in multiple notifications; the iOS client aggregates fragments until it sees the completion flag.
- Uploads are initiated with
Upload Begin(including total size), followed by one or moreUpload Chunkwrites, andUpload Endwhen done. - Errors from the firmware propagate via the status byte; when
status & 0x7F != 0, the notification payload typically includes a UTF-8 error message (e.g.,"stat failed").
This protocol mirrors the implementation in components/backend-esp/src/time_sync_service.cpp and the Swift client in TimeSyncManager.swift. Update both sides if new commands are added.
Optionally bundle this code into your existing app—TimeSyncManager is self‑contained and can be reused.