What I learned building a Garmin watch app that controls a swim metronome over BLE — from GATT command queues to manual ASCII parsing in a language with no regex.
Mariusz Smenzyk
AI Developer ✨ MusicTech ✨ SportTech
When I needed to control a swim metronome from a Garmin watch, I expected the BLE (Bluetooth Low Energy) part to be the hard part. Instead, the real challenge was Monkey C — Garmin's proprietary language for Connect IQ apps. No regex. No string parsing methods. No stack traces. And a simulator that crashes if you initialize BLE too early.
This is what I learned building a real-world BLE remote control app for 27 Garmin watch models.
The app connects a Garmin watch to a BeatBuddy swim metronome over BLE. The watch acts as a remote control:
The entire app is 4 source files, ~650 lines of Monkey C. No frameworks, no dependencies — just the Connect IQ SDK.
source/
├── BeatBuddyApp.mc # App lifecycle
├── BeatBuddyComm.mc # BLE: scanning, connection, command queue
├── BeatBuddyDelegate.mc # Button input handling
└── BeatBuddyView.mc # UI rendering
Coming from Rust and Python, Monkey C feels like Java from 2005 — but running on a device with 64 KB of memory. Here's what caught me off guard:
Need to find a comma in a byte array? Write your own loop:
// Find comma (ASCII 44) in a byte array
var commaIdx = -1;
for (var i = 0; i < value.size(); i++) {
if (value[i] == 44) {
commaIdx = i;
break;
}
}
Need to parse an integer from ASCII digits? Manual arithmetic:
var tempo = 0;
for (var i = commaIdx + 1; i < value.size(); i++) {
var digit = value[i] - 48; // '0' = ASCII 48
if (digit >= 0 && digit <= 9) {
tempo = tempo * 10 + digit;
} else {
break;
}
}
No parseInt(). No String.find(). No regex. You work at the byte level — which is fine once you accept it, but surprising when you first open the API docs.
Monkey C has null safety, but the syntax is verbose:
private var _bleTimer as Timer.Timer?; // nullable
function onStop(state as Dictionary?) as Void {
if (_bleTimer != null) {
(_bleTimer as Timer.Timer).stop(); // must cast after null check
}
}
After a null check, you still need to cast with as Timer.Timer. The compiler doesn't narrow the type automatically.
No destructuring, no spread operator. Removing the first element from an array:
_cmdQueue = _cmdQueue.slice(1, null); // null = to the end
No [1:] like Python. No .shift() like JavaScript. slice(start, null) is the idiom.
If you've used BLE libraries like bleak (Python) or flutter_blue_plus (Flutter), you're used to async/await patterns. Garmin's BLE is entirely callback-based — and there's a critical constraint: only one GATT operation can be in-flight at a time.
If you send two BLE writes simultaneously, the second one silently fails or crashes the app. There's no error, no queue — it just doesn't work.
private var _cmdQueue as Array<ByteArray> = [];
private var _cmdBusy as Boolean = false;
function _enqueueCommand(data as ByteArray) as Void {
_cmdQueue.add(data);
if (!_cmdBusy) {
_processQueue();
}
}
function _processQueue() as Void {
if (_cmdQueue.size() == 0 || _cmdBusy) { return; }
if (_device == null || _connState != STATE_CONNECTED) {
_cmdQueue = [];
return;
}
var service = (_device as Ble.Device)
.getService(Ble.stringToUuid(COMMAND_SERVICE_UUID));
var controlChar = service
.getCharacteristic(Ble.stringToUuid(COMMAND_CONTROL_UUID));
var cmd = _cmdQueue[0];
_cmdQueue = _cmdQueue.slice(1, null);
_cmdBusy = true;
controlChar.requestWrite(cmd, {:writeType => Ble.WRITE_TYPE_WITH_RESPONSE});
}
function onCharacteristicWrite(
characteristic as Ble.Characteristic,
status as Ble.Status
) as Void {
_cmdBusy = false;
_processQueue(); // process next command
}
The pattern is simple: enqueue → process if idle → on write callback, process next. It's essentially a mutex implemented with a boolean flag. This ensures only one GATT write is in-flight at any time.
The same queue handles CCCD (notification subscription) writes. When you first connect, enabling notifications is the first "command" in the queue:
private function _enableStateNotifications() as Void {
var cccd = controlChar.getDescriptor(Ble.cccdUuid());
_cmdBusy = true;
cccd.requestWrite([0x01, 0x00]b); // enable notifications
}
The BLE protocol between the watch and metronome uses plain ASCII strings — not packed binary structs:
| Command | Bytes | Action |
|---|---|---|
"1" | [0x31] | Start metronome |
"0" | [0x30] | Stop metronome |
"T130" | [0x54, 0x31, 0x33, 0x30] | Set tempo to 1.30s |
"P2" | [0x50, 0x32] | Switch to program P3 |
State notifications come back as "1,130" — running, tempo 130 centiseconds.
Why ASCII instead of a binary struct? Three reasons:
struct.pack() in Monkey C anywayThe conversion from string to byte array is manual:
function _stringToBytes(str as String) as ByteArray {
var chars = str.toUtf8Array();
var bytes = new [chars.size()]b;
for (var i = 0; i < chars.size(); i++) {
bytes[i] = chars[i];
}
return bytes;
}
function sendTempo(centis as Number) as Void {
if (centis < 10) { centis = 10; }
if (centis > 200) { centis = 200; }
_enqueueCommand(_stringToBytes("T" + centis.toString()));
}
The Garmin simulator crashes if you call Ble.setDelegate() before the view renders. I discovered this after hours of debugging — there's no error message, just a silent crash.
The fix is embarrassingly simple: delay BLE initialization by 1 second.
function onStart(state as Dictionary?) as Void {
_bleTimer = new Timer.Timer();
(_bleTimer as Timer.Timer).start(method(:onBleInit), 1000, false);
}
function onBleInit() as Void {
_bleTimer = null;
try {
Ble.setDelegate(_comm);
_comm.start();
} catch (e) {
System.println("BLE init error: " + e.getErrorMessage());
}
}
This lets the watch display "Searching..." while BLE initializes in the background. On real hardware, it's barely noticeable. In the simulator, it's the difference between working and crashing.
BLE connections on a watch are unreliable — especially for a swim device that goes underwater. The app implements a simple state machine with automatic recovery:
IDLE → SCANNING → CONNECTING → CONNECTED
↓
(disconnect)
↓
SCANNING (auto)
static const STATE_IDLE = 0;
static const STATE_SCANNING = 1;
static const STATE_CONNECTING = 2;
static const STATE_CONNECTED = 3;
function onConnectedStateChanged(
device as Ble.Device,
state as Ble.ConnectionState
) as Void {
if (state == Ble.CONNECTION_STATE_CONNECTED) {
_device = device;
_connState = STATE_CONNECTED;
_enableStateNotifications();
} else {
// Disconnected → clean up and restart scanning
_device = null;
_connState = STATE_SCANNING;
_cmdQueue = [];
_cmdBusy = false;
_running = false;
Ble.setScanState(Ble.SCAN_STATE_SCANNING);
}
WatchUi.requestUpdate();
}
Key details:
_running = false prevents the UI from showing "PLAYING" when disconnectedThis is critical for a swim use case. The watch might lose BLE contact when the user's arm enters the water, then reconnect on the next stroke. The state machine handles this transparently.
There's no UIKit, no Flutter widgets, no HTML. You get a Dc (drawing context) and pixel coordinates:
function onUpdate(dc as Dc) as Void {
dc.setColor(0x000000, 0x000000); // black background
dc.clear();
// Title — yellow
dc.setColor(0xFFFF00, Graphics.COLOR_TRANSPARENT);
dc.drawText(_centerX, 20, Graphics.FONT_SMALL, "BeatBuddy",
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
// Tempo — large number
var tempoStr = secs.toString() + "." + frac.format("%02d");
dc.drawText(_centerX, _centerY + 5, Graphics.FONT_NUMBER_HOT, tempoStr,
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
// Connection status — colored dot + text
dc.setColor(statusColor, Graphics.COLOR_TRANSPARENT);
dc.fillCircle(_centerX - 45, 60, 4);
dc.drawText(_centerX - 37, 60, Graphics.FONT_XTINY, statusText,
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
}
Everything is drawText(), fillCircle(), and setColor(). The layout is hardcoded to pixel positions. It works — but it takes trial and error to get things aligned on a round watch face.
The UI refreshes on a 2-second timer plus immediate updates on button presses and BLE notifications:
function onShow() as Void {
_refreshTimer = new Timer.Timer();
(_refreshTimer as Timer.Timer).start(method(:onRefresh), 2000, true);
}
One of the best things about Connect IQ: device compatibility is declared in manifest.xml, not in code:
<iq:products>
<iq:product id="fenix6" />
<iq:product id="fenix7" />
<iq:product id="epix2pro47mm" />
<iq:product id="fr265" />
<iq:product id="venu3" />
<!-- ... 22 more devices ... -->
</iq:products>
The SDK compiles a .iq package that runs on all listed devices. No conditional code, no device-specific branches. The Graphics.FONT_NUMBER_HOT font scales to each screen size automatically. The only thing you need to watch for is screen dimensions — I use dc.getWidth() and dc.getHeight() instead of hardcoded values.
The Connect IQ simulator is slow and limited. I built a Python BLE simulator using Bleak that acts like the Garmin watch — connecting to a real BeatBuddy device and sending the same commands:
COMMAND_SERVICE_UUID = "87660000-9140-0ace-dead-beefba5eba11"
COMMAND_CONTROL_UUID = "87660001-9140-0ace-dead-beefba5eba11"
async def send_command(client, cmd: str):
await client.write_gatt_char(
COMMAND_CONTROL_UUID,
cmd.encode("ascii"),
response=True
)
Interactive terminal commands mirror the watch buttons:
> s # SELECT → toggle start/stop
> + # UP → slower (+5 centis)
> - # DOWN → faster (-5 centis)
> t 130 # set tempo to 1.30s
> p 2 # switch to program P3
This lets me test the BLE protocol without flashing a real watch. The feedback loop went from "change code → build → deploy to watch → test" (5 minutes) to "change code → run simulator → test" (10 seconds).
| Metric | Value |
|---|---|
| Source files | 4 |
| Lines of code | ~650 |
| Supported devices | 27 Garmin watches |
| BLE commands | 4 (start, stop, tempo, program) |
| GATT characteristics | 1 (bidirectional) |
| Binary size (.iq) | ~15 KB |
| Development time | ~3 days |
WRITE_TYPE_DEFAULT first. I started with WRITE_TYPE_WITH_RESPONSE, which is more reliable but slower. For a metronome remote, the latency of acknowledged writes is fine. For higher-throughput apps, test both.#ifdef. Connect IQ's manifest-based device support is genuinely well-designed.If you're considering building a Connect IQ app — especially one with BLE — budget 70% of your time for BLE edge cases and 30% for everything else. The language is quirky but workable. The BLE stack is powerful but unforgiving. And the feeling of controlling hardware from your wrist is absolutely worth it.
Built with Connect IQ SDK 8.x, Monkey C, and a healthy dose of System.println() debugging.
Automating Google Docs with Claude Code: Build a CLI That Pulls, Edits, and Pushes
How I built a Python CLI tool that lets Claude Code read and write Google Docs directly — editing DOCX files programmatically, fixing typos, styling tables, and reading comments, all from the terminal.
Building a YouTube Video Research Pipeline with Whisper and Claude
How I built a CLI tool that downloads YouTube videos, transcribes them with Whisper, and generates structured market intelligence using Claude — all with one command.