diff --git a/docs/AI_PROTOCOL_NOTES.md b/docs/AI_PROTOCOL_NOTES.md index 717dd70..97291ac 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 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. ## Framing And Checksums diff --git a/include/ble.h b/include/ble.h index 74d8b3f..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,6 +316,14 @@ 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; + if (subValue != 0 && connId == 0xFFFF) { + pServer->removePeerDevice(desc->conn_handle, false); + adoptBleFff4ConnectionFromSubscription(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,12 +400,22 @@ void bleShutdown() { static bool bleHasLiveClient() { #if defined(CONFIG_NIMBLE_ENABLED) - return pServer != nullptr && pServer->getConnectedCount() > 0; + 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; @@ -464,20 +493,37 @@ void processBleStatusResponse() { pServer->disconnect(currentConnId, 0x13); } +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 (!directNotifyRequired) { + pReadCharacteristic->notify(); + 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() { 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 +531,7 @@ void sendBleGyro() { if (!bleCanNotifyCurrent()) return; byte data[7]; buildGyroPacket(data); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } #endif @@ -494,16 +539,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 +555,7 @@ void sendBlePowerOff(int i_reason) { byte data[7]; buildPowerOffPacket(data, i_reason); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } @@ -522,8 +564,7 @@ void sendBleLedResponse() { byte data[7]; buildLedResponsePacket(data); - pReadCharacteristic->setValue(data, 7); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 7); } void sendAdsDebugInfoBLE() { @@ -532,8 +573,7 @@ void sendAdsDebugInfoBLE() { byte data[41]; buildAdsDebugPacket(data); - pReadCharacteristic->setValue(data, 41); - pReadCharacteristic->notify(); + bleNotifyReadPacket(data, 41); if (bleDebugMode == DEBUG_SINGLE) { bleDebugMode = DEBUG_OFF; 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 02a0b1a..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;", ) @@ -98,14 +146,59 @@ 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, + "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();") @@ -160,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;") @@ -196,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")