From a17fb470868132b644eff980f27f500403f214ba Mon Sep 17 00:00:00 2001 From: John Buckman Date: Thu, 13 Aug 2026 13:18:17 +0200 Subject: [PATCH 1/3] fix(ble): deliver notifications to iOS 9 clients 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 --- include/ble.h | 51 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/include/ble.h b/include/ble.h index 74d8b3f..1db57bd 100644 --- a/include/ble.h +++ b/include/ble.h @@ -305,6 +305,17 @@ class Fff4Callbacks : public BLECharacteristicCallbacks { #if defined(CONFIG_NIMBLE_ENABLED) void onSubscribe(BLECharacteristic *pCharacteristic, ble_gap_conn_desc *desc, uint16_t subValue) { if (desc == nullptr) return; + // iOS 9 CoreBluetooth establishes the link and subscribes but the arduino-esp32 + // BLE server never runs its connect path (onConnect does not fire, getConnectedCount + // stays 0). onSubscribe still fires, so adopt this subscription as the live + // connection when none was registered. + if (subValue != 0 && connId == 0xFFFF) { + setBleFff4Connection(desc->conn_handle, desc->conn_handle); + t_firstConnect = millis(); + t_heartBeat = millis(); + bleState = CONNECTED; + deviceConnected = true; + } const uint16_t nextHandle = subValue == 0 ? 0xFFFF : desc->conn_handle; portENTER_CRITICAL(&bleFff4Mux); const bool changed = desc->conn_handle == connId && bleFff4SubscriptionHandle != nextHandle; @@ -381,7 +392,9 @@ void bleShutdown() { static bool bleHasLiveClient() { #if defined(CONFIG_NIMBLE_ENABLED) - return pServer != nullptr && pServer->getConnectedCount() > 0; + // connId != 0xFFFF covers the iOS 9 client adopted in onSubscribe, whose + // connection the server's getConnectedCount() never counted. + return pServer != nullptr && (pServer->getConnectedCount() > 0 || connId != 0xFFFF); #else return deviceConnected; #endif @@ -464,20 +477,30 @@ void processBleStatusResponse() { pServer->disconnect(currentConnId, 0x13); } +// Registered clients receive via the server's notify(). The iOS 9 client adopted in +// onSubscribe is never counted by the server, so send directly on its conn handle. +static void bleNotifyReadPacket(uint8_t *data, size_t len) { + pReadCharacteristic->setValue(data, len); + if (pServer->getConnectedCount() > 0) { + pReadCharacteristic->notify(); + } else if (connId != 0xFFFF) { + struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len); + if (om != nullptr) ble_gatts_notify_custom(connId, pReadCharacteristic->getHandle(), om); + } +} + void sendBleVoltage() { if (!bleCanNotifyCurrent()) return; byte data[7]; buildVoltagePacket(data); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } void sendBleHeartBeat() { if (!bleCanNotifyCurrent()) return; byte data[7]; buildHeartBeatPacket(data); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } #if defined(ACC_MPU6050) || defined(ACC_BMA400) @@ -485,8 +508,7 @@ void sendBleGyro() { if (!bleCanNotifyCurrent()) return; byte data[7]; buildGyroPacket(data); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } #endif @@ -494,16 +516,14 @@ void sendBleWeight() { if (!bleCanNotifyCurrent()) return; byte data[7]; buildWeightPacket(data); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } void sendBleButton(int buttonNumber, int buttonShortPress) { if (!bleCanNotifyCurrent()) return; byte data[7]; buildButtonPacket(data, buttonNumber, buttonShortPress); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } void sendBlePowerOff(int i_reason) { @@ -512,8 +532,7 @@ void sendBlePowerOff(int i_reason) { byte data[7]; buildPowerOffPacket(data, i_reason); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } @@ -522,8 +541,7 @@ void sendBleLedResponse() { byte data[7]; buildLedResponsePacket(data); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } void sendAdsDebugInfoBLE() { @@ -532,8 +550,7 @@ void sendAdsDebugInfoBLE() { byte data[41]; buildAdsDebugPacket(data); - pReadCharacteristic->setValue(data, 41); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 41); if (bleDebugMode == DEBUG_SINGLE) { bleDebugMode = DEBUG_OFF; From 205f4b357dc8ae03c96c8c9b2c1ef1c65b4f922a Mon Sep 17 00:00:00 2001 From: John Buckman Date: Thu, 13 Aug 2026 15:07:27 +0200 Subject: [PATCH 2/3] test(ble): route the FFF4 send contract through bleNotifyReadPacket 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 --- docs/AI_PROTOCOL_NOTES.md | 2 ++ tools/test_ble_subscription_contract.py | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/AI_PROTOCOL_NOTES.md b/docs/AI_PROTOCOL_NOTES.md index 717dd70..3b3adee 100644 --- a/docs/AI_PROTOCOL_NOTES.md +++ b/docs/AI_PROTOCOL_NOTES.md @@ -29,6 +29,8 @@ A live BLE connection does not imply an FFF4 notification subscription. A client All outbound FFF4 paths pass through `bleCanNotifyCurrent()`. The shared gate checks the feature state, characteristic, live connection, and current subscription before touching the characteristic. +On iOS 9 the arduino-esp32 BLE server never runs its connect path: `onConnect` does not fire and `getConnectedCount()` stays zero, even though the client subscribes to FFF4 and its writes are acknowledged. `onSubscribe` adopts that subscription as the live connection, `bleHasLiveClient()` counts a valid connection handle, and every gated sender emits through `bleNotifyReadPacket()`: the server's `notify()` for counted clients, or a direct `ble_gatts_notify_custom()` on the connection handle when the server counts no peer. + Display status responses are deferred through a mailbox. The BLE callback queues the response, and `processBleStatusResponse()` drains it from the main loop. The mailbox waits two seconds for the FFF4 subscription, then rechecks the connection handle, connection generation, pending requests, and subscription before disconnecting. A newer request or reused connection cancels stale recovery. ## Framing And Checksums diff --git a/tools/test_ble_subscription_contract.py b/tools/test_ble_subscription_contract.py index 02a0b1a..dd65869 100644 --- a/tools/test_ble_subscription_contract.py +++ b/tools/test_ble_subscription_contract.py @@ -98,9 +98,13 @@ def main(): assert_contains(gate, "bleHasLiveClient()", "portENTER_CRITICAL(&bleFff4Mux)") for name in OUTBOUND_FUNCTIONS: body = function_body(ble, name) - assert_contains(body, "if (!bleCanNotifyCurrent()) return;", "pReadCharacteristic->notify();") - if ble.count("pReadCharacteristic->notify();") != len(OUTBOUND_FUNCTIONS): + assert_contains(body, "if (!bleCanNotifyCurrent()) return;", "bleNotifyReadPacket(") + if ble.count("bleNotifyReadPacket(data,") != len(OUTBOUND_FUNCTIONS): raise AssertionError("outbound FFF4 notification bypasses the shared gate") + sender = function_body(ble, "bleNotifyReadPacket") + assert_contains(sender, "pReadCharacteristic->notify();", "ble_gatts_notify_custom(") + if ble.count("pReadCharacteristic->notify();") != 1: + raise AssertionError("outbound FFF4 notification bypasses the shared send path") subscribe = function_body(ble, "onSubscribe") assert_contains(subscribe, "portENTER_CRITICAL(&bleFff4Mux)", "bleFff4SubscriptionHandle = nextHandle;") From c6bfdd45664022a2155024908ceb49d643464f43 Mon Sep 17 00:00:00 2001 From: ODevStudio Date: Wed, 19 Aug 2026 12:26:16 +0200 Subject: [PATCH 3/3] fix(ble): handle uncounted subscription peers --- docs/AI_PROTOCOL_NOTES.md | 2 +- include/ble.h | 51 +++++++--- include/parameter.h | 1 + tools/test_ble_subscription_contract.py | 129 +++++++++++++++++++++++- 4 files changed, 166 insertions(+), 17 deletions(-) diff --git a/docs/AI_PROTOCOL_NOTES.md b/docs/AI_PROTOCOL_NOTES.md index 3b3adee..97291ac 100644 --- a/docs/AI_PROTOCOL_NOTES.md +++ b/docs/AI_PROTOCOL_NOTES.md @@ -29,7 +29,7 @@ A live BLE connection does not imply an FFF4 notification subscription. A client All outbound FFF4 paths pass through `bleCanNotifyCurrent()`. The shared gate checks the feature state, characteristic, live connection, and current subscription before touching the characteristic. -On iOS 9 the arduino-esp32 BLE server never runs its connect path: `onConnect` does not fire and `getConnectedCount()` stays zero, even though the client subscribes to FFF4 and its writes are acknowledged. `onSubscribe` adopts that subscription as the live connection, `bleHasLiveClient()` counts a valid connection handle, and every gated sender emits through `bleNotifyReadPacket()`: the server's `notify()` for counted clients, or a direct `ble_gatts_notify_custom()` on the connection handle when the server counts no peer. +On iOS 9 the pinned arduino-esp32 framework can insert a peer before descriptor lookup, then skip both `onConnect` and its connection-count increment when that lookup fails. `onSubscribe` removes the uncounted peer before disconnect can underflow the framework counter, adopts the connection without clearing queued responses, and records that the connection requires direct NimBLE notifications. Normal connections continue through `BLECharacteristic::notify()`. Display status responses are deferred through a mailbox. The BLE callback queues the response, and `processBleStatusResponse()` drains it from the main loop. The mailbox waits two seconds for the FFF4 subscription, then rechecks the connection handle, connection generation, pending requests, and subscription before disconnecting. A newer request or reused connection cancels stale recovery. diff --git a/include/ble.h b/include/ble.h index 1db57bd..5f8a5a4 100644 --- a/include/ble.h +++ b/include/ble.h @@ -44,6 +44,7 @@ void resetBleFff4StateLocked(uint16_t subscriptionHandle) { bleVoltageResponsesPending = 0; bleStatusRequestAt = 0; bleNotifyFailureLogged = false; + bleDirectNotifyRequired = false; } void setBleFff4Connection(uint16_t connectionHandle, uint16_t subscriptionHandle) { @@ -54,6 +55,16 @@ void setBleFff4Connection(uint16_t connectionHandle, uint16_t subscriptionHandle portEXIT_CRITICAL(&bleFff4Mux); } +void adoptBleFff4ConnectionFromSubscription(uint16_t connectionHandle) { + portENTER_CRITICAL(&bleFff4Mux); + bleFff4ConnectionGeneration = bleFff4ConnectionGeneration + 1; + connId = connectionHandle; + bleFff4SubscriptionHandle = connectionHandle; + bleNotifyFailureLogged = false; + bleDirectNotifyRequired = true; + portEXIT_CRITICAL(&bleFff4Mux); +} + bool clearBleFff4Connection(uint16_t connectionHandle) { portENTER_CRITICAL(&bleFff4Mux); const bool isCurrent = connectionHandle == connId; @@ -305,12 +316,9 @@ class Fff4Callbacks : public BLECharacteristicCallbacks { #if defined(CONFIG_NIMBLE_ENABLED) void onSubscribe(BLECharacteristic *pCharacteristic, ble_gap_conn_desc *desc, uint16_t subValue) { if (desc == nullptr) return; - // iOS 9 CoreBluetooth establishes the link and subscribes but the arduino-esp32 - // BLE server never runs its connect path (onConnect does not fire, getConnectedCount - // stays 0). onSubscribe still fires, so adopt this subscription as the live - // connection when none was registered. if (subValue != 0 && connId == 0xFFFF) { - setBleFff4Connection(desc->conn_handle, desc->conn_handle); + pServer->removePeerDevice(desc->conn_handle, false); + adoptBleFff4ConnectionFromSubscription(desc->conn_handle); t_firstConnect = millis(); t_heartBeat = millis(); bleState = CONNECTED; @@ -392,14 +400,22 @@ void bleShutdown() { static bool bleHasLiveClient() { #if defined(CONFIG_NIMBLE_ENABLED) - // connId != 0xFFFF covers the iOS 9 client adopted in onSubscribe, whose - // connection the server's getConnectedCount() never counted. - return pServer != nullptr && (pServer->getConnectedCount() > 0 || connId != 0xFFFF); + return pServer != nullptr && connId != 0xFFFF; #else return deviceConnected; #endif } +static void logBleDirectNotifyFailure(uint16_t connectionHandle, int rc) { + portENTER_CRITICAL(&bleFff4Mux); + const bool shouldLog = !bleNotifyFailureLogged && bleDirectNotifyRequired && connId == connectionHandle; + if (shouldLog) bleNotifyFailureLogged = true; + portEXIT_CRITICAL(&bleFff4Mux); + if (!shouldLog) return; + Serial.printf("FFF4 direct notification failure for connId: %u, code: %d\n", + static_cast(connectionHandle), rc); +} + static bool bleCanNotifyCurrent() { portENTER_CRITICAL(&bleFff4Mux); const bool subscribed = connId != 0xFFFF && bleFff4SubscriptionHandle == connId; @@ -477,16 +493,23 @@ void processBleStatusResponse() { pServer->disconnect(currentConnId, 0x13); } -// Registered clients receive via the server's notify(). The iOS 9 client adopted in -// onSubscribe is never counted by the server, so send directly on its conn handle. static void bleNotifyReadPacket(uint8_t *data, size_t len) { + portENTER_CRITICAL(&bleFff4Mux); + const bool directNotifyRequired = bleDirectNotifyRequired; + const uint16_t connectionHandle = connId; + portEXIT_CRITICAL(&bleFff4Mux); pReadCharacteristic->setValue(data, len); - if (pServer->getConnectedCount() > 0) { + if (!directNotifyRequired) { pReadCharacteristic->notify(); - } else if (connId != 0xFFFF) { - struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len); - if (om != nullptr) ble_gatts_notify_custom(connId, pReadCharacteristic->getHandle(), om); + return; + } + struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len); + if (om == nullptr) { + logBleDirectNotifyFailure(connectionHandle, BLE_HS_ENOMEM); + return; } + const int rc = ble_gatts_notify_custom(connectionHandle, pReadCharacteristic->getHandle(), om); + if (rc != 0) logBleDirectNotifyFailure(connectionHandle, rc); } void sendBleVoltage() { diff --git a/include/parameter.h b/include/parameter.h index a8f08c0..1e9d2d2 100644 --- a/include/parameter.h +++ b/include/parameter.h @@ -17,6 +17,7 @@ volatile uint16_t bleStatusResponsesPending = 0; volatile uint16_t bleVoltageResponsesPending = 0; volatile unsigned long bleStatusRequestAt = 0; volatile bool bleNotifyFailureLogged = false; +volatile bool bleDirectNotifyRequired = false; volatile uint32_t bleFff4ConnectionGeneration = 0; portMUX_TYPE bleFff4Mux = portMUX_INITIALIZER_UNLOCKED; volatile bool b_usbweight_enabled = false; diff --git a/tools/test_ble_subscription_contract.py b/tools/test_ble_subscription_contract.py index dd65869..099adb2 100644 --- a/tools/test_ble_subscription_contract.py +++ b/tools/test_ble_subscription_contract.py @@ -24,9 +24,11 @@ def __init__(self): self.connection = 7 self.subscription = 0xFFFF self.pending = 0 + self.voltage_pending = 0 self.requested_at = 0 self.generation = 1 self.retiring_generation = 0 + self.direct_notify_required = False def queue(self, now): self.requested_at = now @@ -35,6 +37,34 @@ def queue(self, now): def subscribe(self): self.subscription = self.connection + def queue_voltage(self): + self.voltage_pending += 1 + + def connect_normally(self, connection): + self.connection = connection + self.subscription = 0xFFFF + self.pending = 0 + self.voltage_pending = 0 + self.direct_notify_required = False + self.generation += 1 + + def adopt_fallback(self, connection): + self.connection = connection + self.subscription = connection + self.direct_notify_required = True + self.generation += 1 + + def disconnect(self): + self.connection = 0xFFFF + self.subscription = 0xFFFF + self.pending = 0 + self.voltage_pending = 0 + self.direct_notify_required = False + self.generation += 1 + + def notify_path(self): + return "raw" if self.direct_notify_required else "normal" + def begin_process(self, now): if self.pending == 0: return "wait" @@ -57,6 +87,23 @@ def finish_retire(self): return "disconnect" +class FrameworkPeers: + def __init__(self): + self.peers = set() + self.connected_count = 0 + + def connect_with_failed_descriptor_lookup(self, connection): + self.peers.add(connection) + + def remove_peer(self, connection): + self.peers.discard(connection) + + def disconnect(self, connection): + if connection in self.peers: + self.peers.remove(connection) + self.connected_count = (self.connected_count - 1) & 0xFFFFFFFF + + def function_body(text, name): match = re.search(rf"\b\w+\s+{re.escape(name)}\([^;{{}}]*\)\s*{{", text) if match is None: @@ -90,6 +137,7 @@ def main(): "volatile uint16_t bleStatusResponsesPending = 0;", "volatile unsigned long bleStatusRequestAt = 0;", "volatile bool bleNotifyFailureLogged = false;", + "volatile bool bleDirectNotifyRequired = false;", "volatile uint32_t bleFff4ConnectionGeneration = 0;", "portMUX_TYPE bleFff4Mux = portMUX_INITIALIZER_UNLOCKED;", ) @@ -102,14 +150,55 @@ def main(): if ble.count("bleNotifyReadPacket(data,") != len(OUTBOUND_FUNCTIONS): raise AssertionError("outbound FFF4 notification bypasses the shared gate") sender = function_body(ble, "bleNotifyReadPacket") - assert_contains(sender, "pReadCharacteristic->notify();", "ble_gatts_notify_custom(") + assert_contains( + sender, + "bleDirectNotifyRequired", + "pReadCharacteristic->notify();", + "ble_gatts_notify_custom(", + "const int rc =", + "if (rc != 0)", + "logBleDirectNotifyFailure(", + ) + if "getConnectedCount()" in sender: + raise AssertionError("notification routing uses the framework counter as connection state") if ble.count("pReadCharacteristic->notify();") != 1: raise AssertionError("outbound FFF4 notification bypasses the shared send path") subscribe = function_body(ble, "onSubscribe") - assert_contains(subscribe, "portENTER_CRITICAL(&bleFff4Mux)", "bleFff4SubscriptionHandle = nextHandle;") + assert_contains( + subscribe, + "pServer->removePeerDevice(desc->conn_handle, false);", + "adoptBleFff4ConnectionFromSubscription(desc->conn_handle);", + "portENTER_CRITICAL(&bleFff4Mux)", + "bleFff4SubscriptionHandle = nextHandle;", + ) + if subscribe.index("removePeerDevice(") > subscribe.index("adoptBleFff4ConnectionFromSubscription("): + raise AssertionError("fallback adoption leaves the framework phantom peer installed") assert_contains(ble, "pReadCharacteristic->setCallbacks(new Fff4Callbacks());") + normal_connection = function_body(ble, "onConnect") + assert_contains(normal_connection, "setBleFff4Connection(desc->conn_handle, 0xFFFF);") + if "adoptBleFff4ConnectionFromSubscription" in normal_connection: + raise AssertionError("normal connections use fallback adoption") + + adoption = function_body(ble, "adoptBleFff4ConnectionFromSubscription") + assert_contains(adoption, "bleDirectNotifyRequired = true;", "bleFff4SubscriptionHandle = connectionHandle;") + for cleared_state in ("resetBleFff4StateLocked", "bleStatusResponsesPending", "bleVoltageResponsesPending"): + if cleared_state in adoption: + raise AssertionError("fallback adoption clears pending responses") + + reset = function_body(ble, "resetBleFff4StateLocked") + assert_contains( + reset, + "bleStatusResponsesPending = 0;", + "bleVoltageResponsesPending = 0;", + "bleDirectNotifyRequired = false;", + ) + + live_client = function_body(ble, "bleHasLiveClient") + if "getConnectedCount()" in live_client: + raise AssertionError("live-client state trusts the framework counter") + for name in ("displayOff", "displayOn"): body = function_body(ble, name) assert_contains(body, "queueBleStatusResponse();") @@ -164,6 +253,8 @@ def main(): disconnect = function_body(ble, "onDisconnect") assert_contains(disconnect, "clearBleFff4Connection(desc->conn_handle)") + clear_connection = function_body(ble, "clearBleFff4Connection") + assert_contains(clear_connection, "resetBleFff4StateLocked(0xFFFF);") status = function_body(ble, "onStatus") assert_contains(status, "bleNotifyFailureLogged", "bleNotifyFailureLogged = true;") @@ -200,6 +291,40 @@ def main(): mailbox.generation += 1 if mailbox.finish_retire() != "wait": raise AssertionError("a reused connection handle inherited an older timeout") + + mailbox = Mailbox() + mailbox.queue(0) + mailbox.queue_voltage() + mailbox.adopt_fallback(9) + if mailbox.pending != 1 or mailbox.voltage_pending != 1: + raise AssertionError("fallback adoption cleared pending responses") + if mailbox.notify_path() != "raw": + raise AssertionError("fallback connection did not use raw notifications") + mailbox.disconnect() + if mailbox.direct_notify_required or mailbox.connection != 0xFFFF: + raise AssertionError("disconnect did not clear fallback state") + + mailbox = Mailbox() + mailbox.queue(0) + mailbox.queue_voltage() + mailbox.connect_normally(9) + if mailbox.pending or mailbox.voltage_pending: + raise AssertionError("normal connection did not reset pending responses") + if mailbox.notify_path() != "normal": + raise AssertionError("normal connection did not use normal notifications") + + broken_framework = FrameworkPeers() + broken_framework.connect_with_failed_descriptor_lookup(9) + broken_framework.disconnect(9) + if broken_framework.connected_count != 0xFFFFFFFF: + raise AssertionError("framework model does not reproduce the counter underflow") + + fixed_framework = FrameworkPeers() + fixed_framework.connect_with_failed_descriptor_lookup(9) + fixed_framework.remove_peer(9) + fixed_framework.disconnect(9) + if fixed_framework.connected_count != 0 or fixed_framework.peers: + raise AssertionError("fallback disconnect underflowed the framework connection count") print("BLE subscription contract tests passed")