Firmware & code¶
PlatformIO, Arduino core, LVGL 9.5, TFT_eSPI, XPT2046_Touchscreen and NimBLE-Arduino 2.x.
pio run -e cyd2usb # USB-C / 2-USB board (ST7789) - default
pio run -e cyd # original micro-USB board (ILI9341), untested
tools/merge.sh cyd2usb v0.1.1 # one image for address 0x0 (blank NVS included)
merge.sh writes bootloader 0x1000, partitions 0x8000, a blank NVS at 0x9000, boot_app0 0xE000 and the app at 0x10000. The blank NVS wipes Bluetooth bonds on every full flash. Old bonds are the usual cause of an iPad that sees the board but won't connect. To update without losing pairings, flash only .pio/build/<env>/firmware.bin at 0x10000.
Source map¶
| File | What it is |
|---|---|
src/main.cpp |
Display, touch, all screens (LVGL), backlight, About, heartbeat |
src/ble_media.cpp/.h |
Bluetooth: HID consumer-control device, Apple Media Service client, bonds and address; chooses between iPad and Mac |
src/host_link.cpp/.h |
USB host link to the Mac helper: JSON lines in, commands out (protocol in the header) |
mac-helper/ |
The Mac helper app (Media Ctrl on a Mac) |
src/theme.h |
Colours, radii, fonts — retune the look here |
src/icons.h, src/assets/icons.c |
Icons, generated by tools/make_icons.js (SVG → alpha-only A8 images) |
src/assets/font_*.c |
Inter and JetBrains Mono subsets, generated by tools/make_fonts.sh |
sim/ |
Host simulator — renders every screen on Linux/macOS without a board |
Build flags¶
| Flag | Effect |
|---|---|
-DBLE_NAME=\"…\" |
Bluetooth name (default Media Ctrl) |
-DDIM_AFTER_MS=0 |
Never dim (default 120000) |
-DLED_QUIET |
No RGB LED (default: slow blue blink while unpaired) |
-DTOUCH_DEBUG |
Print raw touch coordinates to serial |
The Bluetooth layer¶
- Advertising: the HID service UUID, keyboard appearance (
0x03C1, so macOS lists the device), and a 128-bit service solicitation for AMS. The name goes in the scan response. - Address: a random static address generated on first boot and kept in NVS (namespace
mediactrl). Forget all devices wipes bonds and generates a new address, so hosts see a new device instead of failing on a stale bond. Four pairing refusals in a row (the host kills the link within 1.5 s with0x213,0x205,0x206or0x23D) trigger the same reset, unless another device is connected or one device was forgotten since boot. - Devices: three links at once (
SLOTS), each with its own track, AMS subscriptions and HID target; the controller allows three. Up to six bonds are kept (CONFIG_BT_NIMBLE_MAX_BONDS=6), with each device's name and kind saved in NVS (namespacemcnames) so the Devices screen can list it while it's away. Forgetting one device deletes only its bond (NimBLEDevice::deleteBond). The board shows the device that started playing most recently, and buttons go to that device only. - HID: one 16-bit Consumer Control usage per report, a press followed by a release 30 ms later. Usages: Play/Pause
0xCD, Next0xB5, Previous0xB6, Volume0xE9/0xEA, Mute0xE2. To a Mac (model string startingMac), next and previous are Fast Forward0xB3and Rewind0xB4, which macOS turns into track skips; it ignores0xB5and0xB6. - AMS client: once the link is encrypted, the board becomes a GATT client on the same connection (
NimBLEServer::getClient). It discovers service89D3502B-…-C502AD55F8DC, subscribes to Remote Command and Entity Update, and registers for: - Player: name, playback info (
state,rate,elapsed), volume - Track: artist, title, duration
- Queue: shuffle, repeat
Values flagged truncated are re-read in full through Entity Attribute. The Remote Command notification lists the commands the current app supports; the UI dims everything else. If the service isn't found, the board retries every 5 s, up to 12 times, and shows keys-only mode meanwhile.
- Threading: notifications arrive on the NimBLE task and are copied into one NowPlaying struct under a spinlock. The UI takes a snapshot every 100 ms. All GATT writes happen in the main loop.
USB host link (off by default)¶
Media over USB is switched off (-DUSB_MEDIA=0, the default): the board still answers {"t":"who"} with its hello line and logs over serial, but it ignores now-playing lines from a helper and never shows a helper as a source. Build with -DUSB_MEDIA=1 to bring the USB helper back. The helpers are moving to Bluetooth.
Serial runs at 115200 with a 2 KB receive buffer. Lines starting with { are JSON; everything else is the firmware's own log, which both sides ignore. The helper is considered present for 8 s after its last line, and it sends at least every 4 s. Media::snapshot() returns the Mac's state when the Mac is playing, or when the iPad isn't. Media::send() routes a button to whichever source is shown.
sim/test_host_link.cpp compiles the real parser on a desktop and feeds it the lines in mac-helper/tests/sample_lines.jsonl, which the helper's own test writes:
g++ -std=c++17 -Isim/stub -Isrc -I.pio/libdeps/cyd2usb/ArduinoJson/src sim/test_host_link.cpp src/host_link.cpp -o sim/test_host_link && (cd sim && ./test_host_link)
python3 mac-helper/tests/test_helper.py
Host simulator¶
sim/build.sh compiles LVGL and the real src/main.cpp natively, with small stubs standing in for Arduino, the display, touch and Bluetooth. It renders each state to sim/out/ (pairing, now playing, paused, More, nothing playing, keys only, About). Run it after any UI change, before flashing. The screenshots in these docs come from it.
Regenerating assets¶
cd tools && npm install
./make_fonts.sh # Inter 22/16/13 (+ medium 13, regular 12), JetBrains Mono 10
node make_icons.js # 13 icons