Skip to content

fix(ble): deliver notifications to iOS 9 clients (Decent Scale on old iPads) - #146

Open
johnbuckman wants to merge 2 commits into
mainfrom
fix/ble-ios9-notify-delivery
Open

fix(ble): deliver notifications to iOS 9 clients (Decent Scale on old iPads)#146
johnbuckman wants to merge 2 commits into
mainfrom
fix/ble-ios9-notify-delivery

Conversation

@johnbuckman

@johnbuckman johnbuckman commented Aug 13, 2026

Copy link
Copy Markdown

Problem

On a jailbroken iPad mini 1 (iOS 9.3.5), de1app connects the HDS/Decent Scale but never receives weight — the de1app watchdog reports "abandoning scale updates". The Atomax Skale works on the same iOS 9 device, and HDS works on macOS / modern iOS. So it is HDS-specific and iOS-9-specific.

How it was found

Diagnosed both ends at once — the iOS BLE stack and the scale's USB-C debug port (115200; use pyserial with dtr=False, rts=False so opening the port doesn't DTR-reset the ESP32).

An A/B of the same firmware, macOS vs iOS 9, was decisive:

scale-side macOS (works) iOS 9 (fails)
Device connected (onConnect) prints never prints
onSubscribe (FFF4) yes yes
every write (LED-on, heartbeat) ACKed yes yes
onStatus on each notify() s=1 SUCCESS_NOTIFY s=5 ERROR_NO_CLIENT
client receives weight 237 packets 0

So on iOS 9 the GATT layer is fully up — the client connects, subscribes to FFF4, and every command is received and ACKed — but the arduino-esp32 BLE server never runs its connect path: onConnect does not fire and getConnectedCount() stays 0. BLECharacteristic::notify() therefore bails at its getConnectedCount()==0 check with ERROR_NO_CLIENT, and the scale measures weight but never transmits it.

(macOS's identical setNotifyValue works, which is why the scale streamed there. iOS 9's older CoreBluetooth is the only client that trips this.)

The fix

onSubscribe does fire on iOS 9, so use it:

  1. Adopt the subscription as the live connection when none was registered — setBleFff4Connection(desc->conn_handle, desc->conn_handle) from the subscribe callback (mirrors what onConnect does).
  2. Count a valid conn handle in bleHasLiveClient() so bleCanNotifyCurrent() passes for that client.
  3. Send through bleNotifyReadPacket() — the normal notify() for server-counted clients, and a direct ble_gatts_notify_custom() on the conn handle when the server has no counted peer (iOS 9). All read-notification senders route through it.

No behavior change for registered (macOS / modern iOS / Android) clients — they take the normal notify() path.

Verification

Flashed to an HDS dev board (ESP32-S3) over USB and tested with de1app on a jailbroken iPad mini 1 (iOS 9.3.5):

  • iOS 9: ~290 weight packets received; de1app watchdog no longer logs "abandoning" — confirmed live in de1app (scale weight updates in real use).
  • macOS (normal client): unchanged (~180 packets), still via the standard notify() path.
  • Builds clean on env:esp32s3.

decentjohn and others added 2 commits August 13, 2026 13:18
On iOS 9 (CoreBluetooth) the arduino-esp32 BLE server never runs its
connect path for the client: onConnect does not fire and
getConnectedCount() stays 0, so BLECharacteristic::notify() bails with
ERROR_NO_CLIENT and the scale never streams weight -- even though the
client connects, subscribes to FFF4, and every write is ACKed. This is
why de1app on a jailbroken iPad mini 1 (iOS 9.3.5) connected the Decent
Scale but reported "abandoning scale updates". macOS and modern iOS are
unaffected because their connect path runs normally.

onSubscribe does fire on iOS 9, so:
- adopt that subscription as the live connection when none was registered
  (setBleFff4Connection from the conn handle);
- count a valid conn handle in bleHasLiveClient();
- send read notifications through bleNotifyReadPacket(), which uses the
  normal notify() for server-counted clients and a direct
  ble_gatts_notify_custom() on the conn handle otherwise.

Verified on a jailbroken iPad mini 1 (iOS 9.3.5): de1app now streams
Decent Scale weight; macOS/modern clients unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The outbound senders now emit through bleNotifyReadPacket() instead of
calling pReadCharacteristic->notify() directly, so update the subscription
contract and the protocol notes to match: each gated sender must call
bleNotifyReadPacket(), and that helper is the single send path (normal
notify() plus the iOS 9 direct ble_gatts_notify_custom() fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@ODevStudio ODevStudio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks John for the PR

I reviewed PR #146 against the current de1app Decent Scale flow and the pinned Arduino-ESP32 3.3.11 BLE implementation. The first-session fix is sound, but I would request changes before merging because there is a reconnect blocker in the underlying failure path.

1. Blocker: the exact iOS 9 failure path corrupts getConnectedCount() on disconnect

The project pins Arduino-ESP32 3.3.11. In that version, the NimBLE BLEServer connect handler does this in order:

server->m_connId = event->connect.conn_handle;
server->addPeerDevice(...);
rc = ble_gap_conn_find(event->connect.conn_handle, &desc);
if (rc != 0) {
    return 0;
}
// onConnect callbacks...
// m_connectedCount++

So the peer is inserted before ble_gap_conn_find(), but the counter is incremented only afterward.

That lines up extremely well with your observations. More importantly, Arduino's characteristic subscription handler also calls ble_gap_conn_find() and will not call onSubscribe() if that lookup fails. Therefore:

  1. iOS 9 GAP connect arrives.
  2. addPeerDevice() succeeds.
  3. ble_gap_conn_find() apparently races/fails at that instant.
  4. onConnect() is skipped and count remains 0.
  5. A little later the CCCD subscription arrives.
  6. ble_gap_conn_find() now succeeds, so onSubscribe() fires.

That strongly suggests the PR has identified the right symptom but not the full lifecycle consequence.

On disconnect, Arduino does:

if (server->removePeerDevice(conn_handle, false)) {
    server->m_connectedCount--;
}

Because the peer was inserted but the count was never incremented, this is 0 → UINT32_MAX.

That then breaks reconnect. The Arduino advertising implementation refuses to start advertising when:

pServer->getConnectedCount() >= CONFIG_BT_NIMBLE_MAX_CONNECTIONS

Your onDisconnect() subsequently calls pAdvertising->start(), but after the underflow the library believes it has billions of connections.

So I expect this sequence to reproduce a failure:

boot
→ iOS 9 connect
→ weights work with PR
→ disconnect iPad
→ scale does not advertise again

There is a second consequence. PR #146 changes:

return pServer != nullptr &&
       (pServer->getConnectedCount() > 0 || connId != 0xFFFF);

After that underflow, bleHasLiveClient() remains true even after your own connId has been cleared. That is observable outside BLE: finger/button handling explicitly uses bleHasLiveClient() to decide whether local button behavior is allowed.

I would treat this as blocking.

The cleanest fix is upstream in Arduino-ESP32: don't insert the peer without balancing m_connectedCount, or move the count update before the descriptor lookup. But for an OpenScale-local workaround, you can undo the phantom peer entry when you detect this fallback:

if (subValue != 0 && connId == 0xFFFF) {
    const bool uncounted = pServer->getConnectedCount() == 0;

    if (uncounted) {
        // Arduino BLEServer inserted this peer before its failed
        // ble_gap_conn_find(), but never incremented m_connectedCount.
        // Remove it so disconnect won't decrement 0 -> UINT32_MAX.
        pServer->removePeerDevice(desc->conn_handle, false);
    }

    ...
}

Then track that this connection requires direct notifications rather than consulting getConnectedCount() again. I would also make bleHasLiveClient() derive from connId plus ble_gap_conn_find() rather than the poisoned library counter.

2. The adoption currently destroys responses DE1app queued before subscribing

setBleFff4Connection() calls resetBleFff4StateLocked(), which clears both:

bleStatusResponsesPending = 0;
bleVoltageResponsesPending = 0;

That is correct during a normal onConnect(), because the connection is registered before application traffic arrives. It is not correct when onSubscribe() is being used as a late substitute for onConnect().

This matters specifically with DE1app. On a Decent Scale connection it does approximately:

0 ms    heartbeat
200 ms  LED/display-on command
300 ms  enable FFF4 notifications
400 ms  enable FFF4 notifications again
500 ms  LED/display-on again

The LED/display command queues a status response in OpenScale; that mailbox was explicitly designed to wait for the notification subscription. But at 300 ms PR #146 calls setBleFff4Connection() from onSubscribe(), which erases the response queued by the 200 ms command.

DE1app happens to mask this because it sends the LED command again at 500 ms. A client that sends the command only once would lose its response.

I'd introduce a separate adoption operation that preserves the mailboxes, for example conceptually:

adoptBleFff4ConnectionFromSubscription(handle);

which updates:

connection generation
connId
subscription handle
notification failure state

without resetting pending status/voltage responses.

The existing contract test doesn't catch this; its simulated mailbox assumes subscription merely sets the subscription field and preserves pending responses.

3. Direct notification failures are now invisible

The normal Arduino notify() path invokes onStatus() on failures such as ERROR_NO_CLIENT. The new direct path does:

struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len);
if (om != nullptr)
    ble_gatts_notify_custom(connId, pReadCharacteristic->getHandle(), om);

and discards the return code.

Given that this code exists specifically for an anomalous BLE stack path, I would capture and rate-limit-log the ble_gatts_notify_custom() return value. Also log allocation failure. Otherwise a future failure becomes harder to diagnose than the bug this PR fixes.

DE1app protocol compatibility itself looks correct

There is no wire-format problem here. DE1app enables notifications on the Decent Scale read characteristic and routes incoming notifications through parse_decent_scale_recv; 7-byte 0xCE packets are interpreted as weight and passed into process_weight_update().

OpenScale's buildWeightPacket() still produces the same 7-byte Decent Scale packet, and bleNotifyReadPacket() transmits those exact bytes. So the architectural idea—use the successful FFF4 subscription as evidence of the real connection and issue a raw NimBLE notification on that handle—is correct.

I would add these hardware tests before merging:

  • iOS 9 connect → verify continuous weight.
  • iOS 9 disconnect → verify scale immediately advertises again.
  • iOS 9 reconnect without reboot → verify weight again.
  • Then connect macOS/modern iOS without reboot.
  • Verify local tare/timer buttons behave normally after the iOS 9 client disconnects.
  • Send LED-on once before CCCD subscription, then subscribe, and verify its queued status response is delivered.
  • Log getConnectedCount() during the first iOS 9 disconnect; I strongly expect you'll see 4294967295 with the current framework.

So: the notification workaround is directionally right, but I would not merge #146 as-is until the phantom peer / counter-underflow path is handled. The counter issue is the one likely to turn a successful one-shot test into a failed real-world reconnect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants