Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/AI_PROTOCOL_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 57 additions & 17 deletions include/ble.h
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<unsigned int>(connectionHandle), rc);
}

static bool bleCanNotifyCurrent() {
portENTER_CRITICAL(&bleFff4Mux);
const bool subscribed = connId != 0xFFFF && bleFff4SubscriptionHandle == connId;
Expand Down Expand Up @@ -464,46 +493,60 @@ 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)
void sendBleGyro() {
if (!bleCanNotifyCurrent()) return;
byte data[7];
buildGyroPacket(data);
pReadCharacteristic->setValue(data, 7);
pReadCharacteristic->notify();
bleNotifyReadPacket(data, 7);
}
#endif

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) {
Expand All @@ -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);
}


Expand All @@ -522,8 +564,7 @@ void sendBleLedResponse() {

byte data[7];
buildLedResponsePacket(data);
pReadCharacteristic->setValue(data, 7);
pReadCharacteristic->notify();
bleNotifyReadPacket(data, 7);
}

void sendAdsDebugInfoBLE() {
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions include/parameter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
135 changes: 132 additions & 3 deletions tools/test_ble_subscription_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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;",
)
Expand All @@ -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();")
Expand Down Expand Up @@ -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;")
Expand Down Expand Up @@ -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")


Expand Down