Blog
Feb 18, 2026 - 10 MIN READ
Building a Garmin Connect IQ App in Monkey C: Lessons from a BLE Remote Control

Building a Garmin Connect IQ App in Monkey C: Lessons from a BLE Remote Control

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

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.


What We're Building

The app connects a Garmin watch to a BeatBuddy swim metronome over BLE. The watch acts as a remote control:

  • SELECT button → start/stop the metronome
  • UP/DOWN buttons → adjust tempo (±0.05 seconds)
  • Auto-reconnect → if connection drops underwater, it reconnects automatically

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

Lesson 1: Monkey C Is Not What You Expect

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:

No String Methods

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.

Nullable Types Require Explicit Casts

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.

Array Operations Are Limited

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.


Lesson 2: BLE on Garmin Is Event-Driven, Not Async

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.

The Problem

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.

The Solution: Command Queue with Busy Flag

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
}

Lesson 3: ASCII Protocol > Binary Protocol

The BLE protocol between the watch and metronome uses plain ASCII strings — not packed binary structs:

CommandBytesAction
"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:

  1. Debuggability — you can read the commands in BLE logs without a decoder
  2. Firmware simplicity — the nRF52832 firmware parses with basic string functions
  3. Monkey C compatibility — no struct.pack() in Monkey C anyway

The 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()));
}

Lesson 4: The 1-Second Delay Hack

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.


Lesson 5: Connection State Machine

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:

  • Queue flush on disconnect — stale commands would fail anyway
  • State reset_running = false prevents the UI from showing "PLAYING" when disconnected
  • Immediate re-scan — no user action required to reconnect

This 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.


Lesson 6: Drawing the UI by Hand

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);
}

Lesson 7: Supporting 27 Devices with One Codebase

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.


Lesson 8: Build a Simulator (Seriously)

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).


The Numbers

MetricValue
Source files4
Lines of code~650
Supported devices27 Garmin watches
BLE commands4 (start, stop, tempo, program)
GATT characteristics1 (bidirectional)
Binary size (.iq)~15 KB
Development time~3 days

What I'd Do Differently

  1. Start with the Python simulator. I built it after the watch app. Building it first would have saved a day of debugging BLE issues in the Connect IQ simulator.
  2. Use 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.
  3. Add program selection to the UI. The current version shows only tempo. Adding a program selector would require a menu or a second page — Connect IQ supports both, but the round screen makes menus tricky.

Key Takeaways

  • Monkey C is low-level. No regex, no string parsing, limited array operations. Accept it and write byte-level code.
  • BLE needs a command queue. One GATT operation at a time. No exceptions.
  • ASCII protocols win. Human-readable commands save hours of debugging.
  • Auto-reconnect is essential. Especially for sports devices where connections drop constantly.
  • The simulator lies. Test on real hardware as early as possible. The 1-second delay hack only exists because of a simulator bug.
  • 27 devices, 0 #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.