Acknowledged
CIQQA-4692

Simulator + nRF52840 Dongle: BLE central never receives connection callbacks after pairDevice(), even for a minimal app with no service registration

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;
}
  • Thanks, I can get no-encryption working on 9.1 if I disable YES/NO on my RPi/BlueZ/C stack (your early work inspired me btw).  Then I can return to 9.2 and it does indeed connect and pair without encryption. Sadly the reason I started trying the simulator this week was because encryption was supported. Everything else I run (RPi  centrals/peripherals and iOS) is working with YES/NO, including even the f8 hardware. Any idea if SDK team is listening and 9.2.1 is on the way?

  • If you drop back to the 9.1 SDK and flash the dongle with the old hex file, what happens?

    I've found that with 9.1 and the old hex file, in the sim I get a message about using an open connection, and if I ok that I can switch back to 9.2 and the new hex file and run fine.  I'm mainly connecting to raspberry Pi devices and ESP32.

     Would you like some Raspberry Pi with your Connect IQ? 

  • ## Update: peripheral-initiated pairing fails identically -- this is not specific to central-initiated bonding

    Per jim_m_58's cross ref (thanks!) I did more digging to help support the fix so I don't need to use his 9.1 workaround.
    Cross-referencing a related thread: [BLE not working in simulator after
    (InigoTolosa, jim_m_58, CYBERMAN54, gasteropod) -- independent reports of
    the same "scanning works, connect completes, no data/callbacks, sim
    requires a restart" symptom on SDK 9.2 with the nRF52840 dongle, including
    on an f8 device profile. Worth reading together with this report; between
    the two threads this looks like a solid, multi-reporter reproduction of
    one underlying defect.

    ### New evidence: the peripheral initiating pairing doesn't help either

    Since filing this report I tested whether the peripheral driving pairing,
    instead of the Connect IQ central, avoids the problem. My test peripheral
    (a BlueZ-based Linux GATT server, same one as in the original report) was
    changed to call `org.bluez.Device1.Pair()` itself immediately on accepting
    a connection -- a connection-level policy independent of anything the
    Connect IQ app does, and independent of `Ble.setConnectionStrategy()`.
    This is a real, verified BlueZ-level pairing attempt: BlueZ's own `Pair()`
    D-Bus method was called, and BlueZ itself reported the outcome.

    Result: identical failure. The peripheral's `Device1.Pair()` call sat for
    the full 30-second BlueZ pairing timeout with **zero response from the
    simulator side**, then BlueZ itself returned `Timeout was reached` and the
    peripheral correctly disconnected the unbonded central. Peripheral-side
    log (central MAC address redacted, `hci1` is the peripheral's adapter):

    ```text
    09:02:23.665  Connection policy starting Device1.Pair() for <central>
    09:02:23.676  Connected <central> (raw ACL connect, GATT proceeds normally)
       ... GATT notifications flow normally for the full 30s window ...
    09:02:53.673  Device1.Pair() failed for <central>: Timeout was reached
    09:02:53.675  Connection bonding requirement failed: Timeout was reached
    09:02:53.678  Disconnecting unbonded central <central>
    09:02:55.562  <central> disconnected
    ```

    This rules out an entire class of explanation and workaround: it is not
    that the *Connect IQ central's* bonding-request path specifically is
    broken while the underlying SM (Security Manager) channel itself is fine
    for a peripheral-initiated request. The simulator's nRF52840 dongle bridge
    does not complete an SMP exchange initiated from **either** side. Whatever
    is broken sits below both APIs -- most likely in the simulator's handling
    of the SM L2CAP channel between the nRF52840 SoftDevice and the app-facing
    `BleDelegate` surface, since (per my original report) the raw ACL
    connection and GATT service discovery/notification traffic both complete
    and function correctly throughout -- only the SMP pairing exchange itself
    never produces a single frame in either direction.

    This narrows the likely fix location for Garmin's own investigation: the
    defect is specific to the SM channel's handling in the simulator's BLE
    bridge for this dongle/firmware combination, not to any particular
    Connect IQ API surface (`pairDevice()`, `setConnectionStrategy()`, or
    otherwise) that an app might call.