Summary
**The identical app works correctly when side-loaded onto a physical
fēnix 8 47mm, and fails every time in the Simulator against an
nRF52840 Dongle.** This isolates the fault specifically to the
Simulator + nRF52840 Dongle BLE bridge -- not the app, not the peripheral,
and not fēnix 8 firmware in general.
`Ble.pairDevice(scanResult)` returns a non-null `Device` in the Connect IQ
Simulator when using an nRF52840 Dongle as the BLE bridge, but
`BleDelegate.onConnectedStateChanged()` and `onEncryptionStatus()` are
never called afterward -- not even a disconnect callback, ever, for the
full 60+ second window tested. Meanwhile the peripheral itself (an
independently logged BlueZ/Linux GATT server, not a Garmin device)
confirms the link-layer connection succeeds and full GATT service
discovery completes normally. On real hardware, the same app connects and
disconnects cleanly against the same peripheral in under 6 seconds.
This reproduces with a minimal, ~130-line watch-app that does nothing but
scan, pair, wait, and unpair -- no service registration, no GATT reads or
writes, default connection strategy. It is not specific to any particular
app's complexity, service count, or `setConnectionStrategy()` choice.
## Environment
- **Connect IQ SDK**: 9.2.0 (2026-06-09)
- **Simulator BLE bridge**: MDBT50Q-CX Nordic nRF52840 Dongle, firmware
`connectivity_1.0.0_usb_with_s140_6.1.1` -- confirmed byte-for-byte
identical to the current SDK 9.2.0 dongle firmware linked from the SDK's
own "Getting Started with Connect IQ BLE Development" guide (not the
"(old)" pre-9.2.0 firmware).
- **Target device**: fēnix 8 47mm (`fenix847mm`)
- **Peripheral**: a BlueZ-based Linux BLE peripheral (GATT server), not a
Garmin product -- exposes a standard Fitness Machine Service (FTMS)
treadmill profile plus a vendor GATT service. Bondable, IO capability
`DisplayYesNo`, confirmed via `bluetoothd`'s own management-socket log.
## Steps to reproduce
1. Flash an nRF52840 Dongle with the current SDK 9.2.0 firmware per the
official guide; bind it in the simulator's `Settings > BLE Settings` to
its COM port.
2. Run the attached minimal `testconn` watch-app (source attached in full
below -- `source/testconn.mc`, plus its `manifest.xml` and
`monkey.jungle`) in the simulator against a real, advertising BLE
peripheral with a known device name.
3. Observe the simulator's console/debug output.
## Expected behavior
After `Ble.pairDevice()` returns a non-null `Device`,
`BleDelegate.onConnectedStateChanged()` should eventually be called with
`CONNECTION_STATE_CONNECTED` once the link is established (per the SDK
doc's own description: "Once the device is found and connected,
onConnectedStateChanged() will be called").
## Actual behavior
`onConnectedStateChanged()` is never called at all -- not with
`CONNECTED`, not with any other state, ever observed in this session.
`onEncryptionStatus()` is likewise never called. No exception is thrown by
`pairDevice()`. The app has no way to detect that anything happened.
Independently, on the peripheral side, a live `btmon -i hci1` HCI capture
during the same stall shows:
- `LE Enhanced Connection Complete` for the dongle's Bluetooth address,
confirming the radio-level connection genuinely succeeded;
- immediately followed by a full, successful GATT service-discovery
exchange (`ATT: Exchange MTU Request/Response`, dozens of
`ATT: Find Information Request/Response` pairs covering every
characteristic and descriptor) and an `LE L2CAP: Connection Parameter
Update` renegotiation -- all completing normally;
- **zero SMP (Security Manager Protocol) frames anywhere in the capture**
-- no `Pairing Request`, no `LE Start Encryption`, no `LE Long Term Key
Request`.
So the underlying BLE link is fully healthy and even completes GATT
discovery, but the Connect IQ application layer receives no signal that
any of this happened, and no bonding/security procedure is ever initiated
by the central.
Separately: once `Ble.pairDevice()`'s internal 60-second-class timeout is
reached and the app calls `Ble.unpairDevice()` on the pending `Device`,
that call also produces no terminal callback -- the peripheral-side log
shows the connection remaining open indefinitely, surviving even a full
kill of the simulator process. The peripheral only sees the link close
when it is force-disconnected from its own side.
## Same code on real hardware: works correctly
The identical `testconn.mc` app, side-loaded and run on a physical fēnix 8
47mm against the same peripheral, connects and disconnects cleanly:
`Connected` and `Disconnected` events observed on the peripheral's log
roughly 5.3 seconds apart, matching the app's designed 5-tick
(~5 second) sit-and-disconnect behavior exactly. This isolates the fault
to the simulator + nRF52840 dongle bridge specifically, not to the app,
not to the peripheral, and not to fēnix 8 firmware behavior in general.
## Notes on connection strategy
The same total-callback-silence result was also confirmed with the full application this minimal repro was extracted from, under both
`CONNECTION_STRATEGY_SECURE_PAIR_BOND` and `CONNECTION_STRATEGY_DEFAULT`
(tested by omitting the `setConnectionStrategy()` call entirely, matching
the SDK's own `NordicThingy52` sample, which never calls it). Both
produced identical silence. This repro (`testconn.mc`) additionally never
calls `setConnectionStrategy()` at all, so it is running under
`CONNECTION_STRATEGY_DEFAULT` throughout.
## Severity / impact
This makes the simulator + nRF52840 dongle setup unusable for developing
or testing any BLE central pairing flow against a real third-party
peripheral, for any app, regardless of complexity -- the documented
"Getting Started with Connect IQ BLE Development" workflow does not
function as described for this SDK/firmware combination.
## Attached source (complete, ~130 lines)
`source/testconn.mc` -- a self-contained watch-app. No dependencies beyond
`Toybox.BluetoothLowEnergy`, `Toybox.Timer`, `Toybox.WatchUi`. Hardcodes a
target advertised device name; scans, pairs on match, logs every BLE
delegate callback, sits connected for 5 one-second ticks, then calls
`unpairDevice()`.
```monkeyc
import Toybox.Application;
import Toybox.Lang;
import Toybox.WatchUi;
using Toybox.BluetoothLowEnergy as TbBle;
using Toybox.Graphics as TbGfx;
using Toybox.System as TbSys;
using Toybox.Timer as TbTimer;
const TESTCONN_TARGET_NAME = "your-peripheral-name-here";
const TESTCONN_SIT_TICKS = 5;
const TESTCONN_TICK_MS = 1000;
class TestConnDelegate extends TbBle.BleDelegate {
enum {
SCANNING = 0,
CONNECTING = 1,
CONNECTED = 2,
DISCONNECTED = 3
}
private var _m_phase as Number = SCANNING;
private var _m_pendingDevice as TbBle.Device or Null = null;
private var _m_sitTicksRemaining as Number = TESTCONN_SIT_TICKS;
function initialize() {
TbBle.BleDelegate.initialize();
}
function start() as Void {
TbSys.println("testconn: scanning for " + TESTCONN_TARGET_NAME);
TbBle.setScanState(TbBle.SCAN_STATE_SCANNING);
}
function onScanResults(scanResults as TbBle.Iterator) as Void {
if (_m_phase != SCANNING) {
return;
}
for (var result = scanResults.next(); result != null; result = scanResults.next()) {
if (!(result instanceof TbBle.ScanResult)) {
continue;
}
var scanResult = result as TbBle.ScanResult;
var deviceName = scanResult.getDeviceName();
if (deviceName != null && (deviceName as String).equals(TESTCONN_TARGET_NAME)) {
TbSys.println("testconn: match rssi=" + scanResult.getRssi() + "dBm, pairing");
TbBle.setScanState(TbBle.SCAN_STATE_OFF);
_m_phase = CONNECTING;
_m_pendingDevice = TbBle.pairDevice(scanResult);
if (_m_pendingDevice == null) {
TbSys.println("testconn: pairDevice returned null");
} else {
TbSys.println("testconn: pairDevice returned OK, waiting for callback");
}
return;
}
}
}
function onScanStateChange(scanState as TbBle.ScanState, status as TbBle.Status) as Void {
TbSys.println("testconn: scan state=" + scanState + " status=" + status);
}
function onConnectedStateChanged(device as TbBle.Device, state as TbBle.ConnectionState) as Void {
TbSys.println("testconn: onConnectedStateChanged state=" + state);
if (state == TbBle.CONNECTION_STATE_CONNECTED) {
_m_phase = CONNECTED;
_m_sitTicksRemaining = TESTCONN_SIT_TICKS;
} else {
_m_phase = DISCONNECTED;
}
}
function onEncryptionStatus(device as TbBle.Device, status as TbBle.Status) as Void {
TbSys.println("testconn: onEncryptionStatus status=" + status);
}
function onTick() as Void {
if (_m_phase == CONNECTED) {
_m_sitTicksRemaining -= 1;
TbSys.println("testconn: sitting, ticks remaining=" + _m_sitTicksRemaining);
if (_m_sitTicksRemaining <= 0) {
TbSys.println("testconn: disconnecting");
TbBle.unpairDevice(_m_pendingDevice as TbBle.Device);
_m_phase = DISCONNECTED;
}
}
}
}
class TestConnView extends WatchUi.View {
function initialize() {
View.initialize();
}
function onUpdate(dc as TbGfx.Dc) as Void {
dc.setColor(TbGfx.COLOR_WHITE, TbGfx.COLOR_BLACK);
dc.clear();
dc.drawText(
dc.getWidth() / 2, dc.getHeight() / 2,
TbGfx.FONT_SMALL, "testconn running\nsee console log",
TbGfx.TEXT_JUSTIFY_CENTER | TbGfx.TEXT_JUSTIFY_VCENTER);
}
}
class TestConnApp extends Application.AppBase {
private var _m_Delegate as TestConnDelegate;
private var _m_Timer as TbTimer.Timer;
function initialize() {
AppBase.initialize();
_m_Delegate = new TestConnDelegate();
_m_Timer = new TbTimer.Timer();
}
function onStart(state as Dictionary?) as Void {
TbBle.setDelegate(_m_Delegate);
_m_Delegate.start();
_m_Timer.start(method(:onTick), TESTCONN_TICK_MS, true);
}
function onStop(state as Dictionary?) as Void {
_m_Timer.stop();
}
function onTick() as Void {
_m_Delegate.onTick();
}
function getInitialView() as [WatchUi.Views] or [WatchUi.Views, WatchUi.InputDelegates] {
return [new TestConnView()];
}
}
function getApp() as TestConnApp {
return Application.getApp() as TestConnApp;
}