AI-generated — show prompt
A dark moody digital illustration of a single embedded circuit board chip emitting two divergent translucent BLE radio waves, each terminating in a different MAC address visualized as a glowing 6-byte hex string floating in space. The two radio waves arc outward in opposite directions like a slingshot, with subtle dashed beam outlines suggesting the signal switch between identities. A small chest-mounted heart rate sensor silhouette in the foreground. Color palette: deep navy (#101218), electric blue (#468CDC) accent for the primary radio wave, soft magenta (#C040A0) accent for the secondary radio wave (showing the two distinct identities). Technical illustration style, slightly stylized, 16:9 landscape, dark background suitable for text overlay.
I’m writing the firmware for a chest-strap running sensor, and it has to do something Garmin actively prevents: be a plain heart-rate monitor and a custom sensor at the same time, from one BLE chip. Pair a BLE sensor natively to a Garmin watch, then try to reach it from a Connect IQ datafield, and the connection flip-flops until both are unusable.
The watch has to pair the strap natively as an HRM, so the standard run app records HR, HRV, and the whole training stack with no custom code. And a Connect IQ datafield has to read the running-form metrics the watch can’t measure itself, over a custom GATT service. Drop the first and the training features break; drop the second and the metrics that justify the product are gone. Garmin’s BLE stack refuses to let one peripheral do both.
The fix I shipped: make the single nRF52832 present as two devices at two different MAC addresses, same chip, same firmware, never on the air at the same time. It boots as the HR strap, lets the watch pair, then switches its BLE identity mid-flight so the datafield connects to what looks like a separate device.
If you’re on a legacy Nordic chip with no BLE 5 multi-advertising and you’ve hit Garmin’s “one device can’t be two of my subsystems” wall, this is the way through. The published guidance on the key SoftDevice call is vague bordering on discouraging, so this is the writeup I wish I’d had. The BLE-side trick is below; the full firmware flow is in a gist.
The setup
The hardware is a Movesense HR2: nRF52832, ECG electrodes for heart rate, a six-axis IMU for biomechanics, BLE-only as Movesense ships it. (The nRF52832 silicon can run the s332 BLE+ANT SoftDevice, but Movesense builds the HR2 on s132 and doesn’t expose ANT+. That matters later.) On top of the usual chest-strap dynamics (cadence, vertical oscillation, ground contact time) it computes a handful of running-form metrics: torso lean, braking variability, left-right impact symmetry, and a couple I’m not writing about yet. I’m doing the firmware end to end, and the product only exists if the native HR pairing and the custom-metric datafield both work at once, on this one sensor.
Why this is hard
A single BLE peripheral has one GAP identity address, and centrals (the watch, the phone) use that MAC as the stable identifier for “this device.” Advertise both the standard Heart Rate Service (0x180D) and a custom 128-bit service in one packet and the watch sees one device offering both. That should work. On Garmin watches it doesn’t.
I spent a week trying before accepting it. Pair the sensor as an HRM from Garmin’s native menu, then connect from the datafield, and one or the other connection breaks within seconds; pairing the other way around, same result. Per the Connect IQ forums and Nordic devzone, it’s a known constraint: a sensor the watch has paired natively isn’t available to a Connect IQ app, and CIQ only gets a small, device-wide pool of BLE connections to begin with. One peripheral can’t be both the firmware’s HR strap and a CIQ app’s device at once. Stryd dodges it with ANT+: its datafield talks to the pod over a private ANT+ channel, and Stryd tells you to pair over ANT+, not BLE. On BLE-only hardware I don’t have that escape hatch.
The alternatives I ruled out:
- Two physical devices. Doubles BOM, doubles charging, awful UX. Non-starter for a wearable.
- ANT+. The HR2 ships BLE-only, so it’s off the table even though the silicon could do it.
- Multi-set advertising (BLE 5.0). The clean modern answer: one chip, multiple advertising sets at independent MACs. The SoftDevice doesn’t do it. s132 v6.1.1 hard-caps
BLE_GAP_ADV_SET_COUNT_MAX = 1, and so does the s140 on the nRF52840 (Nordic staff confirmed it as late as s140 v7.0.1). Real multi-set means leaving the SoftDevice for Zephyr / the nRF Connect SDK, which isn’t where this product lives. - Garmin’s own Running Dynamics protocol. Garmin’s straps push HR, pace, cadence and the standard RD set into the native FIT recording over an undocumented BLE/GFDI profile. I reverse-engineered that one in a separate project (the full writeup is here), but it only carries Garmin’s fixed metric set, and some of the metrics this sensor exists for aren’t in it.
What’s left: pretend to be two devices. Same chip, same firmware, two MACs, two GATT trees that look different to discovery scans. Garmin’s stack doesn’t mind two connections to two different MACs even when both point at the same physical hardware, because it doesn’t know they’re the same hardware.
The trick: switching GAP identity mid-connection
The SoftDevice exposes sd_ble_gap_addr_set to change the device’s GAP address. Nordic’s docs hint (without saying it outright) that you should call it only when no connection is active, and devzone threads warn about NRF_ERROR_INVALID_STATE if you try while advertising.
Reading between the lines: addr_set affects future advertising and future connection initiation. The link handle for an active connection is per-connection, not per-MAC. So changing the address shouldn’t tear down the existing connection.
I bench-tested it on a Movesense HR2 with a Bleak client connected to MAC_A’s HRS, streaming HR, while I ran sd_ble_gap_addr_set to switch to MAC_B. The call returned NRF_SUCCESS. The Bleak client stayed is_connected = True for 12+ seconds afterwards, HR notifications flowing the whole time, while a separate scanner picked up MAC_B advertising at -45 dBm. That’s the load-bearing primitive. Everything else is plumbing.
The full sequence:
sequenceDiagram
participant W as Garmin watch
participant S as Sensor nRF52832
participant D as Connect IQ datafield
Note over S: Boot, advertise as MAC_A (HRS 0x180D only)
W->>S: HRM scanner pairs, slot 1 (conn A)
S-->>W: HR notifications start
Note over S: Wait 5s (MTU + HRS CCCD settled)
Note over S: sd_ble_gap_addr_set(MAC_B)<br/>MAC_B = MAC_A with byte 0 XOR 0xAA
Note over W,S: Slot-1 link survives the flip,<br/>same handle, CCCD, MTU, HR still flowing
Note over S: Advertise as MAC_B (F0F0 + 128-bit UUID, no HRS)
D->>S: Scans F0F0, connects, slot 2 (conn B)
Note over D,S: different MAC, so no two-to-one-peripheral refusal
S-->>W: HRS notifications (MAC_A handle)
S-->>D: custom GATT metrics (MAC_B handle)
Note over W,D: both links live on one chip
The 5-second delay between CONNECTED and the switch is the most important parameter. Under 3 seconds and slower centrals (some Android phones, Suunto’s own scanner) haven’t finished CCCD setup; over 8 seconds and the F0F0-scanning datafield occasionally times out waiting for MAC_B. 5 seconds is comfortable on every central I tested.
The SVC trampoline gotcha
MovesenseCoreLib is a closed-source binary that doesn’t export Nordic’s generated SoftDevice stubs. Linking against it gives you the high-level Whiteboard BLE API but not direct SVC entry points. To call sd_ble_gap_addr_set directly, I emit the SVC instruction via a naked function:
struct __attribute__((packed)) gap_addr_t {
uint8_t flags; // bit 0 = addr_id_peer, bits 1-7 = addr_type
uint8_t addr[6];
};
__attribute__((naked, noinline))
static uint32_t sd_gap_addr_set(gap_addr_t const* /*p_addr*/)
{
__asm volatile("svc 0x6C\n" "bx lr");
}
SVC number 0x6C is SD_BLE_GAP_ADDR_SET on s132 v6.1.1, derived from BLE_GAP_SVC_BASE = 0x6C in ble_ranges.h and offset 0 in the BLE_GAP_SVCS enum. AAPCS puts the function argument in r0, which is exactly where the SoftDevice’s SVC handler expects p_addr. The bx lr returns whatever the SoftDevice put in r0 (the result code). Same trick works for addr_get (0x6D) and adv_stop (0x74).
gap_addr_t is memory-layout-compatible with Nordic’s ble_gap_addr_t: 1 byte of bitfields (addr_id_peer in bit 0, addr_type in bits 1-7), then 6 bytes of MAC. Don’t use Nordic’s actual header struct here; MovesenseCoreLib has its own conflicting definitions that won’t link. (Same naked-function pattern also got me around MovesenseCoreLib’s lack of --wrap support for the BLE config symbols.)
The S132 v6.1.1 quirks worth knowing
A handful of things the documentation either doesn’t mention or actively misleads on.
Multi-set advertising isn’t real on s132. BLE_GAP_ADV_SET_COUNT_MAX = 1 is hardcoded across every s132 release. Calling sd_ble_cfg_set(BLE_GAP_CFG_ROLE_COUNT, adv_set_count=2, ...) returns NRF_ERROR_RESOURCES (0x13) no matter how much RAM you give the SoftDevice. The error is misleading: it’s not a memory issue, it’s the preflight rejection of adv_set_count > 1 masked behind RESOURCES. The Nordic forums are full of people trying to fix it by bumping RAM, which is wasted work.
Address changes are RAM-only. sd_ble_gap_addr_set persists nothing to flash. A reboot reverts the GAP address to the FICR-derived factory MAC. Fine for this architecture (I always switch on first CONNECTED post-boot), but MAC_B is a runtime-only identity you re-create every power cycle.
Stopping non-running advertising is harmless. Step 4 in the sequence calls sd_gap_adv_stop, but the SoftDevice already stopped advertising when slot 1 filled on CONNECTED. The call returns NRF_ERROR_INVALID_STATE (0x8), which looks alarming but is the expected path. Don’t bail on it.
The link handle survives the address change. This is the empirical finding that makes the whole thing work. The existing MAC_A connection keeps its conn_handle, CCCD state, MTU, and GATT subscriptions through the addr_set call, and HRS notifications keep flowing. The watch never notices the GAP identity changed, because from its side it doesn’t care: it has a connection handle, the connection is alive, the subscription is delivering.
Those four together (no multi-set, RAM-only address, harmless adv_stop error, link survives) are what make sequential identity switching the only viable path on this hardware.
The revert problem and the brute-force fix
The happy path works cleanly the first time: boot, advertise MAC_A, watch pairs, 5 seconds, switch to MAC_B, datafield connects, two live connections, sensor feeds both. Beautiful.
Then the run ends, both peers disconnect, and the sensor should go back to advertising MAC_A so the next workout’s HR pair finds it. The obvious revert (stop adv, sd_ble_gap_addr_set(MAC_A), PUT a new HRS adv packet, restart advertising) works once. The watch re-pairs HR and the cycle restarts.
It wedges on the second switch. The Movesense adv-PUT pipeline desyncs after a manual address change mid-flight, and subsequent adv_start calls don’t actually re-advertise: the sensor thinks it’s advertising but nothing’s on the air. Field-confirmed, every fresh-boot HR pair succeeded, every post-revert one failed with the status LED still blinking. I spent two days trying to figure out which internal state it was wedged in (probably a flag tracking “the SoftDevice already started advertising for me” that doesn’t clear when the address changes underneath it), but without MovesenseCoreLib source I couldn’t confirm.
The fix that ships: don’t do a clean revert, just reboot. When the last peer disconnects after a switch to MAC_B, start a 5-second recovery grace timer (so fast reconnects shortcut the revert); if it expires with no peers, fire NVIC_SystemReset. The sensor reboots in ~1.5 seconds, comes back up advertising MAC_A from the FICR default, and the watch’s bonded HR auto-reconnects. To the user it’s indistinguishable from a clean recovery, just with a 1.5-second gap.
// Inline NVIC_SystemReset() to avoid needing CMSIS headers, which aren't
// exposed in the Movesense app include path. SCB_AIRCR is at 0xE000ED0C;
// writing VECTKEY (0x5FA in high half) + SYSRESETREQ (bit 2) triggers a
// chip reset. __DSB() ensures pending memory writes flush before reset.
__asm__ volatile ("dsb 0xF" ::: "memory");
*(volatile uint32_t*)0xE000ED0Cu = 0x05FA0004u;
__asm__ volatile ("dsb 0xF" ::: "memory");
while (true) { __asm__ volatile ("nop"); }
Not elegant. Rebooting to reset state is the firmware equivalent of “have you tried turning it off and on again.” But shipping a wedge that needs the user to physically power-cycle is worse, and chasing the MovesenseCoreLib bug further without source wasn’t justifiable. The reboot trades 1.5 seconds for a state machine that always works. Anything in RAM at revert is lost, but EEPROM (the raw motion log, the per-second metric log, the pace calibration) survives, and by the time this path runs peers=0 means nothing’s streaming anyway.
Connection-handle-aware revert: where it gets fiddly
The all-peers-gone reboot is the easy case. The interesting one is asymmetric disconnect, where one peer drops and the other stays. Two sub-cases, handled differently.
Datafield drops, HR stays. The CIQ app crashed or got backgrounded, dropping the F0F0 connection while HR keeps streaming. I want to stay on MAC_B (the HR peer is happily connected on the slot-1 handle, switching identities now would break it) but restart MAC_B’s advertising so the datafield can reconnect. The SoftDevice auto-stopped MAC_B’s advertising when the datafield connected and won’t resume on its own, so without an explicit re-advertise the datafield can never find the sensor again.
The fix: on disconnect, if one peer remains AND the state is POST_SWITCH (on MAC_B) AND the dropping peer isn’t the original HRS peer, re-advertise MAC_B in place (no address change, just restart adv with the F0F0 packet). Identifying “isn’t the HRS peer” is the trick. Movesense’s COMM_BLE_PEERS event carries a wb::Optional<uint32_t> connection handle; on the first CONNECTED in PRE_SWITCH state (where MAC_A advertises only HRS, so the only possible peer is HRS), I capture that handle and match it against the disconnecting peer’s.
const bool dropper_is_hrs = (handleAvailable
&& mFirstPeerHandleValid
&& peerHandle == mFirstPeerHandle);
if (!dropper_is_hrs
&& mPeersConnected == 1
&& mState == State::POST_SWITCH)
{
DEBUGLOG("datafield dropped, HR remains -- re-advertising MAC_B");
restartMacBAdv();
}
HR drops, datafield stays. The chest strap loosens mid-run and the electrodes lose contact, dropping the HR connection while the datafield keeps streaming biomechanics. I originally implemented an immediate revert here (free MAC_A so the watch can re-pair), and removed it the same day, six hours later. The field evidence: a brief HR RF blip during the datafield’s ~100 ms GATT setup would trigger sd_gap_addr_set mid-connection, the sensor’s MAC would vanish from the datafield’s view, and the datafield would hang on CONN forever while the watch’s HR pair also lost its peer. The opposite of the goal.
Shipping behavior: if HR drops while the datafield is connected, stay on MAC_B until the datafield also drops. HR re-pair then needs a sensor reboot (cycle the chest strap), which is the right trade for a working datafield connection, and it’s documented in the user-facing pairing guide.
There are more branches (abort the switch if HR drops during the 5-second pre-switch delay; a runtime EEPROM flag that disables the whole switcheroo for partner integrations that want simpler single-MAC semantics; a SWD-readable telemetry block at a fixed RAM address for debugging without UART). The full file is ~590 lines including the SVC trampolines. The shape is consistent: every BLE event is a state transition that either succeeds, falls into a known recoverable state, or escalates to NVIC reset.
Empirical validation
Bench setup: a Movesense HR2, a J-Link probe, a Bleak client on macOS holding MAC_A’s HRS, a separate Mac scanner enumerating peripherals, and telemetry read over J-Link SWD (nrfutil device x-read) against the SoftDevice memory map. On the switch sequence:
sd_ble_gap_addr_setreturnedNRF_SUCCESSmid-connection (telemetry wordaddr_set_err = 0x00000000).- HRS notifications streamed continuously through the switch; Bleak got 31 HR samples from 153 down to 73 bpm as I calmed down after climbing the stairs.
client.is_connectedstayedTruefor the full 12 seconds post-switch.- The scanner picked up MAC_B at -45 dBm while Bleak still held MAC_A: both MACs in the air at once.
- Per-identity advertising filtering confirmed: MAC_A’s packet carries 180D only, MAC_B’s carries F0F0 plus the 128-bit UUID. The watch’s HRM scanner finds MAC_A and ignores MAC_B; the datafield’s F0F0 scanner finds MAC_B and ignores MAC_A. No cross-talk.
Robustness testing came later: 10 connect-disconnect cycles in a row (verifying the reboot-on-revert recovery), supervision-timeout edge cases (RF dropout simulated by Faraday-cage-bagging the sensor for 5 seconds), and the full pause-resume flow on a Fenix 8 during outdoor runs. The state machine survived everything, with the caveat that “survive” includes “reboot when revert is required.”
Why I’m sharing this
I checked whether any of this was novel enough to protect. Not really. One radio presenting as several devices at several addresses is old ground (patents going back a decade), and BLE has rotated peripheral addresses at runtime for years via Resolvable Private Addresses. The only piece I couldn’t find written down anywhere is this exact combination: flipping the GAP identity mid-connection on a legacy s132 SoftDevice with the existing link surviving intact. So no patent route; I spent two weeks on bench work one blog post could have saved me, so here’s the blog post. If you’re hitting the same Garmin pairing conflict on legacy Nordic hardware, take it.
The production firmware lives in a private client repo, but the whole flow fits in one file. I put a stripped-down, vendor-neutral C++ reference up as a gist: the full boot → switch → dual-link → revert flow. That plus the snippets above should be enough to rebuild it.
Built with Claude Code, and the honest split: I owned the problem, the constraints, and every minute on the bench (Claude can’t touch hardware). Claude floated “present as two BLE identities” when I asked it to deep-research the pairing conflict, and made the key call early, that sd_ble_gap_addr_set mid-connection wouldn’t drop the link because the SoftDevice keys a connection by its handle, not its MAC. That stayed a hypothesis until I proved it on the strap. From there it wrote most of the firmware (the SVC trampolines, the state machine, the reboot-on-wedge recovery) while I fed back the failure modes from real watches that the happy path missed and corrected it when it wandered off. Implementer and SoftDevice rubber-duck; I was the only one who could tell whether it actually worked.