fix(ble): deliver notifications to iOS 9 clients (Decent Scale on old iPads) - #146
fix(ble): deliver notifications to iOS 9 clients (Decent Scale on old iPads)#146johnbuckman wants to merge 2 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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:
- iOS 9 GAP connect arrives.
addPeerDevice()succeeds.ble_gap_conn_find()apparently races/fails at that instant.onConnect()is skipped and count remains0.- A little later the CCCD subscription arrives.
ble_gap_conn_find()now succeeds, soonSubscribe()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_CONNECTIONSYour 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 see4294967295with 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.
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=Falseso opening the port doesn't DTR-reset the ESP32).An A/B of the same firmware, macOS vs iOS 9, was decisive:
Device connected(onConnect)onSubscribe(FFF4)onStatuson eachnotify()s=1SUCCESS_NOTIFYs=5ERROR_NO_CLIENTSo 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:
onConnectdoes not fire andgetConnectedCount()stays0.BLECharacteristic::notify()therefore bails at itsgetConnectedCount()==0check withERROR_NO_CLIENT, and the scale measures weight but never transmits it.(macOS's identical
setNotifyValueworks, which is why the scale streamed there. iOS 9's older CoreBluetooth is the only client that trips this.)The fix
onSubscribedoes fire on iOS 9, so use it:setBleFff4Connection(desc->conn_handle, desc->conn_handle)from the subscribe callback (mirrors whatonConnectdoes).bleHasLiveClient()sobleCanNotifyCurrent()passes for that client.bleNotifyReadPacket()— the normalnotify()for server-counted clients, and a directble_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):
notify()path.env:esp32s3.