diff --git a/src/mesh/BLEGattMeshHandler.cpp b/src/mesh/BLEGattMeshHandler.cpp index 68c965ffb3..8ae2efa01d 100644 --- a/src/mesh/BLEGattMeshHandler.cpp +++ b/src/mesh/BLEGattMeshHandler.cpp @@ -104,8 +104,10 @@ int32_t BLEGattMeshHandler::runOnce() if (!isRunning || !platformReady()) return 500; - pumpRx(Time::getMillis()); - pumpGreet(); + const uint32_t now = Time::getMillis(); + pumpRx(now); + pumpGreet(now); + pumpLiveness(now); return pumpTx() ? 10 : 100; } @@ -159,26 +161,67 @@ BLEGattMeshHandler::Greeting *BLEGattMeshHandler::greeting(BLEGattPeerId peer, b g.peer = peer; g.sent = false; g.heard = false; + g.heardMs = 0; + g.probedMs = 0; + g.probeMisses = 0; return &g; } return nullptr; } -void BLEGattMeshHandler::pumpGreet() +void BLEGattMeshHandler::pumpGreet(uint32_t nowMs) { std::array all{}; const size_t n = platformPeers(all.data(), all.size()); uint8_t hello[BLE_GATT_MESH_HELLO_SIZE]; const size_t len = buildHello(hello, sizeof(hello)); for (size_t i = 0; i < n; i++) { - Greeting *g = greeting(all[i].id, true); - if (!g || g->sent) + Greeting *g = greeting(all[i].id, false); + if (!g) { + // First sight of this link: the quiet window runs from here, not from the epoch. + g = greeting(all[i].id, true); + if (!g) + continue; + g->heardMs = nowMs; + } + if (g->sent) continue; if (platformNotify(all[i].id, hello, len)) g->sent = true; // a refusal is the stack busy; the next pump offers it again } } +void BLEGattMeshHandler::pumpLiveness(uint32_t nowMs) +{ + std::array all{}; + const size_t n = platformPeers(all.data(), all.size()); + for (size_t i = 0; i < n; i++) { + if (!all[i].outbound) + continue; + Greeting *g = greeting(all[i].id, false); // pumpGreet listed it first, with the clock started + if (!g) + continue; + // Quiet for less than the idle window: nothing to ask. Asked already: wait the window, or the + // short retry after a miss. + if (nowMs - g->heardMs < BLE_GATT_MESH_PROBE_IDLE_MS) + continue; + const uint32_t wait = g->probeMisses ? BLE_GATT_MESH_PROBE_RETRY_MS : BLE_GATT_MESH_PROBE_IDLE_MS; + if (g->probedMs != 0 && nowMs - g->probedMs < wait) + continue; + g->probedMs = nowMs; + if (platformProbe(all[i].id)) { + g->probeMisses = 0; + continue; + } + if (++g->probeMisses < 2) + continue; + // The disconnect arrives later as a zero-length chunk and forgets the peer then; until it does + // the link is still listed, and probedMs keeps this from asking it again every pump. + LOG_WARN("BLE GATT mesh: dialled conn %u answers nothing; shedding it", all[i].id); + platformShedOutbound(all[i].id); + } +} + void BLEGattMeshHandler::handleHello(BLEGattPeerId peer, const uint8_t *id) { Greeting *g = greeting(peer, true); @@ -280,6 +323,8 @@ void BLEGattMeshHandler::handleChunk(BLEGattPeerId peer, const uint8_t *chunk, s forgetPeer(peer); return; } + if (Greeting *g = greeting(peer, true)) + g->heardMs = nowMs; uint8_t id[BLE_GATT_MESH_LINK_ID_SIZE]; if (parseHello(chunk, len, id)) { diff --git a/src/mesh/BLEGattMeshHandler.h b/src/mesh/BLEGattMeshHandler.h index ce36704ea1..9771feeb6e 100644 --- a/src/mesh/BLEGattMeshHandler.h +++ b/src/mesh/BLEGattMeshHandler.h @@ -48,6 +48,18 @@ // pump's 10 ms tick, bounded so a departed peer fails the send instead of stalling the ring. #ifndef BLE_GATT_MESH_TX_ATTEMPTS #define BLE_GATT_MESH_TX_ATTEMPTS 50 + +// A dialled link that has carried nothing for this long is probed with a write the peer must answer. +// The central keeps the ACL alive by itself, so a peer whose app died looks exactly like a quiet one +// until something is asked of it - and the one central slot is lost until reboot otherwise. +#ifndef BLE_GATT_MESH_PROBE_IDLE_MS +#define BLE_GATT_MESH_PROBE_IDLE_MS 30000 +#endif +// One unanswered probe may be the stack refusing a second GATTC procedure (an MTU exchange in flight, +// a write queue full); the retry after this long is what tells that apart from a peer that is gone. +#ifndef BLE_GATT_MESH_PROBE_RETRY_MS +#define BLE_GATT_MESH_PROBE_RETRY_MS 2000 +#endif #endif // (from, id) -> arrival peer, so a relay is never written back to the peer that delivered it. @@ -67,7 +79,8 @@ typedef uint16_t BLEGattPeerId; struct BLEGattMeshPeer { BLEGattPeerId id; - uint16_t chunk; // negotiated ATT MTU - 3 + uint16_t chunk; // negotiated ATT MTU - 3 + bool outbound = false; // this node dialled it, so nothing but this node will ever notice it die }; /** @@ -134,6 +147,21 @@ class BLEGattMeshHandler : private concurrency::OSThread, public MeshTransportBa /// One node named itself on two links and the election went against this node: drop `peer` if it is /// a link this node dialled. An inbound link is the peer's to shed, so the default keeps everything. virtual void platformShedOutbound(BLEGattPeerId peer) { (void)peer; } + /// This node's link id, drawn on first use, and the greeting that carries it - the platform's probe + /// sends the same frame, so both live here rather than below. + void ensureLinkId(); + size_t buildHello(uint8_t *out, size_t cap); + /// Greet every listed link once, and start its quiet window; then probe the dialled ones that have + /// gone quiet. A test drives both with its own clock. + void pumpGreet(uint32_t nowMs); + void pumpLiveness(uint32_t nowMs); + /// Ask `peer` for proof of life - a write it must acknowledge. False means it is gone. A platform + /// that cannot ask says true, and never sheds a link for silence. + virtual bool platformProbe(BLEGattPeerId peer) + { + (void)peer; + return true; + } int32_t runOnce() override; @@ -195,12 +223,12 @@ class BLEGattMeshHandler : private concurrency::OSThread, public MeshTransportBa bool sent; bool heard; uint8_t id[BLE_GATT_MESH_LINK_ID_SIZE]; + uint32_t heardMs; // last chunk of any kind from this peer, or when the link was first listed + uint32_t probedMs; // last proof-of-life asked of it + uint8_t probeMisses; // unanswered probes in a row; two is gone, one may be a busy stack }; std::array greetings{}; Greeting *greeting(BLEGattPeerId peer, bool create); - void ensureLinkId(); - size_t buildHello(uint8_t *out, size_t cap); - void pumpGreet(); void handleHello(BLEGattPeerId peer, const uint8_t *id); BLEGattPeerId arrivalPeer(NodeNum from, PacketId id) const; // The peer a relay of this packet must skip, which is none when the arrival came straight from diff --git a/src/platform/nrf52/NRF52BLEGattMesh.cpp b/src/platform/nrf52/NRF52BLEGattMesh.cpp index aae3802e03..deed25fc4f 100644 --- a/src/platform/nrf52/NRF52BLEGattMesh.cpp +++ b/src/platform/nrf52/NRF52BLEGattMesh.cpp @@ -155,14 +155,33 @@ BLEClientCharacteristic meshClientCharacteristic = BLEClientCharacteristic(BLEUu bool dialing = false; ble_gap_addr_t dialAddr{}; // A peer that dropped, refused or never answered is not redialled for a while: the scanner reports it -// again within 100 ms. +// again within 100 ms. One length for every outcome: a dial that fails is a race with a link the peer +// is still tearing down (a reflash, a relaunch), and a minute outlives that. The failure that would +// have earned a longer wait - a phone that already holds a link to this node redialled under the +// address it rotated to - cannot happen now that only the overflow bit is dialled, because the one +// central slot is that link. #ifndef BLE_GATT_MESH_DIAL_COOLDOWN_MS #define BLE_GATT_MESH_DIAL_COOLDOWN_MS 60000 #endif +// How long a dial may wait for the peer to accept the connection. Bluefruit's own default is forever. +#ifndef BLE_GATT_MESH_DIAL_TIMEOUT_MS +#define BLE_GATT_MESH_DIAL_TIMEOUT_MS 4000 +#endif bool cooldownArmed = false; ble_gap_addr_t cooldownAddr{}; uint32_t cooldownSinceMs = 0; +// The proof-of-life write request in flight on the dialled link, and how it ended. Bluefruit's own +// write_resp() gives the peer 100 ms, which a backgrounded iPhone on a long connection interval +// misses while still answering every time; this waits a few intervals and reads the response event +// through the mesh's event tap instead. +#ifndef BLE_GATT_MESH_PROBE_WAIT_MS +#define BLE_GATT_MESH_PROBE_WAIT_MS 1500 +#endif +enum ProbeState : uint8_t { PROBE_IDLE, PROBE_PENDING, PROBE_ANSWERED, PROBE_REFUSED }; +volatile uint16_t probeConn = BLE_CONN_HANDLE_INVALID; +volatile uint8_t probeState = PROBE_IDLE; + void armCooldown() { cooldownAddr = dialAddr; @@ -261,6 +280,9 @@ void NRF52BLEGattMesh::setupService() meshClientCharacteristic.setNotifyCallback(onNotify); Bluefruit.Central.setConnectCallback(onCentralConnect); Bluefruit.Central.setDisconnectCallback(onCentralDisconnect); + // Bluefruit dials with its scanner's parameters, whose timeout is 0: a peer that never accepts + // leaves the dial pending forever and this node never dials again. + Bluefruit.Scanner.getParams()->timeout = BLE_GATT_MESH_DIAL_TIMEOUT_MS / 10; } #endif { @@ -344,10 +366,19 @@ void NRF52BLEGattMesh::onScanReport(const ble_gap_evt_adv_report_t *report) if (cooldownArmed && memcmp(&cooldownAddr, &report->peer_addr, sizeof(cooldownAddr)) == 0 && Throttle::isWithinTimespanMs(cooldownSinceMs, BLE_GATT_MESH_DIAL_COOLDOWN_MS)) return; - // Only the primary advertisement is seen: the mesh scan is passive, so a node that puts the UUID in - // its scan response (this firmware on nRF52) is never dialled. Phones put it in the advertisement. + // Only the primary advertisement is seen: the mesh scan is passive, so a node that puts the UUID in + // its scan response (this firmware on nRF52) is never dialled. Phones put it in the advertisement. + // The dial is for the peer that cannot dial: an iOS app in the background, which always carries the + // overflow bit (in the foreground too). An advertiser showing the UUID alone is Android or another + // radio, and both reach this node by themselves - dialling one of those spent the single central + // slot on the phone that was about to connect anyway, then redialled its rotated address for ever. +#if BLE_GATT_MESH_DIAL_UUID if (!Bluefruit.Scanner.checkReportForUuid(report, BLEUuid(serviceUuid)) && !reportHasIosOverflowBit(report)) return; +#else + if (!reportHasIosOverflowBit(report)) + return; +#endif dialing = true; dialAddr = report->peer_addr; if (!Bluefruit.Central.connect(report)) { @@ -434,6 +465,7 @@ size_t NRF52BLEGattMesh::platformPeers(BLEGattMeshPeer *out, size_t cap) break; out[n].id = l.conn; out[n].chunk = chunkFor(l.conn); + out[n].outbound = l.outbound; n++; } return n; @@ -463,6 +495,80 @@ bool NRF52BLEGattMesh::platformNotify(BLEGattPeerId peer, const uint8_t *data, s return meshPeerCharacteristic.notify(peer, data, (uint16_t)len); } +bool NRF52BLEGattMesh::platformProbe(BLEGattPeerId peer) +{ +#if BLE_GATT_MESH_DIAL + bool outbound = false; + { + concurrency::LockGuard guard(&lock); + Link *l = findLink(peer); + outbound = l && l->outbound; + } + if (!outbound) + return true; + if (!Bluefruit.Central.connected(peer)) + return false; + // A write with response: the peer's ATT layer must answer, and a peer whose app died has no + // handle left to answer for. The greeting is idempotent on the client, so it is the probe. + uint8_t hello[BLE_GATT_MESH_HELLO_SIZE]; + const size_t len = buildHello(hello, sizeof(hello)); + ble_gattc_write_params_t params = { + .write_op = BLE_GATT_OP_WRITE_REQ, + .flags = 0, + .handle = meshClientCharacteristic.valueHandle(), + .offset = 0, + .len = (uint16_t)len, + .p_value = hello, + }; + probeConn = peer; + probeState = PROBE_PENDING; + if (sd_ble_gattc_write(peer, ¶ms) != NRF_SUCCESS) { + probeState = PROBE_IDLE; // the stack is busy with another procedure; the retry will ask again + return false; + } + // This runs on the main task; the response lands on the BLE task, and delay() yields to it. + const uint32_t started = millis(); + while (probeState == PROBE_PENDING && Bluefruit.Central.connected(peer) && + Throttle::isWithinTimespanMs(started, BLE_GATT_MESH_PROBE_WAIT_MS)) + delay(10); + const bool answered = probeState == PROBE_ANSWERED; + probeState = PROBE_IDLE; + probeConn = BLE_CONN_HANDLE_INVALID; + return answered; +#else + (void)peer; + return true; +#endif +} + +void NRF52BLEGattMesh::onWriteResponse(uint16_t conn, uint16_t status) +{ +#if BLE_GATT_MESH_DIAL + if (conn == probeConn && probeState == PROBE_PENDING) + probeState = status == BLE_GATT_STATUS_SUCCESS ? PROBE_ANSWERED : PROBE_REFUSED; +#else + (void)conn; + (void)status; +#endif +} + +void NRF52BLEGattMesh::onSecurityRequest(uint16_t conn) +{ +#if BLE_GATT_MESH_DIAL + // A phone bonded to this node's phone API asks the link be encrypted the moment it is dialled. + // Bluefruit answers nothing on a central link, the phone's SMP timer runs out at 30 s and it drops + // the link (0x05). The mesh-peer link is open by design - the channel key is the security - so + // decline, which the SoftDevice does for a NULL parameter set. + BLEConnection *c = Bluefruit.Connection(conn); + if (!c || c->getRole() != BLE_GAP_ROLE_CENTRAL) + return; + const uint32_t err = sd_ble_gap_authenticate(conn, NULL); + LOG_INFO("BLE GATT mesh: declined the security request on dialled conn %u (0x%x)", conn, (unsigned)err); +#else + (void)conn; +#endif +} + void NRF52BLEGattMesh::platformShedOutbound(BLEGattPeerId peer) { #if BLE_GATT_MESH_DIAL @@ -474,7 +580,7 @@ void NRF52BLEGattMesh::platformShedOutbound(BLEGattPeerId peer) } if (!outbound) return; - LOG_INFO("BLE GATT mesh: shedding dialled conn %u, the peer reaches us already", peer); + LOG_INFO("BLE GATT mesh: shedding dialled conn %u", peer); Bluefruit.disconnect(peer); // onCentralDisconnect arms the cooldown #else (void)peer; diff --git a/src/platform/nrf52/NRF52BLEGattMesh.h b/src/platform/nrf52/NRF52BLEGattMesh.h index 14c07d62cf..b9bd0b3fd0 100644 --- a/src/platform/nrf52/NRF52BLEGattMesh.h +++ b/src/platform/nrf52/NRF52BLEGattMesh.h @@ -39,6 +39,10 @@ class NRF52BLEGattMesh : public BLEGattMeshHandler static void onScanReport(const ble_gap_evt_adv_report_t *report); /// The dial never completed (BLE_GAP_EVT_TIMEOUT, source CONN). static void onDialTimeout(); + /// A peer on a dialled link asked for encryption. Declined - see the definition. + static void onSecurityRequest(uint16_t conn); + /// A write request on some link was answered, or refused with an ATT error. + static void onWriteResponse(uint16_t conn, uint16_t status); protected: bool platformReady() override; @@ -46,6 +50,7 @@ class NRF52BLEGattMesh : public BLEGattMeshHandler bool platformNotify(BLEGattPeerId peer, const uint8_t *data, size_t len) override; bool platformPollInbound(BLEGattPeerId &peer, uint8_t *buf, size_t cap, size_t &len) override; void platformShedOutbound(BLEGattPeerId peer) override; + bool platformProbe(BLEGattPeerId peer) override; }; #endif // HAS_BLE_GATT_MESH && ARCH_NRF52 diff --git a/src/platform/nrf52/NRF52BLEMesh.cpp b/src/platform/nrf52/NRF52BLEMesh.cpp index 7b8acc3882..f0bd60d568 100644 --- a/src/platform/nrf52/NRF52BLEMesh.cpp +++ b/src/platform/nrf52/NRF52BLEMesh.cpp @@ -233,6 +233,16 @@ void NRF52BLEMesh::onBleEvent(ble_evt_t *event) // A dial stops the scan; scanning and a central link coexist once it is up, or gone. instance->startScanning(); break; + case BLE_GATTC_EVT_WRITE_RSP: +#if HAS_BLE_GATT_MESH + NRF52BLEGattMesh::onWriteResponse(event->evt.gattc_evt.conn_handle, event->evt.gattc_evt.gatt_status); +#endif + break; + case BLE_GAP_EVT_SEC_REQUEST: +#if HAS_BLE_GATT_MESH + NRF52BLEGattMesh::onSecurityRequest(event->evt.gap_evt.conn_handle); +#endif + break; case BLE_GAP_EVT_TIMEOUT: if (event->evt.gap_evt.params.timeout.src == BLE_GAP_TIMEOUT_SRC_SCAN) { instance->startScanning(); diff --git a/test/test_ble_gatt_mesh/BleGattMesh.cpp b/test/test_ble_gatt_mesh/BleGattMesh.cpp index 25f1f63a8a..e14cf3028b 100644 --- a/test/test_ble_gatt_mesh/BleGattMesh.cpp +++ b/test/test_ble_gatt_mesh/BleGattMesh.cpp @@ -8,6 +8,7 @@ #include "mesh/NodeDB.h" #include "mesh/Router.h" +#include #include #include #include @@ -29,6 +30,8 @@ class FakeGattMesh : public BLEGattMeshHandler std::map>> notified; std::map>> greeted; std::vector shed; + std::map probed; + std::vector dead; std::deque>> inbound; std::vector received; bool ready = true; @@ -38,6 +41,11 @@ class FakeGattMesh : public BLEGattMeshHandler void start() override { isRunning = true; } void stop() override { isRunning = false; } + void liveness(uint32_t nowMs) + { + pumpGreet(nowMs); + pumpLiveness(nowMs); + } void feed(BLEGattPeerId peer, const std::vector &chunk, uint32_t nowMs = 0) { handleChunk(peer, chunk.data(), chunk.size(), nowMs); @@ -77,6 +85,11 @@ class FakeGattMesh : public BLEGattMeshHandler return true; } void platformShedOutbound(BLEGattPeerId peer) override { shed.push_back(peer); } + bool platformProbe(BLEGattPeerId peer) override + { + probed[peer]++; + return std::find(dead.begin(), dead.end(), peer) == dead.end(); + } bool platformPollInbound(BLEGattPeerId &peer, uint8_t *buf, size_t cap, size_t &len) override { if (inbound.empty()) @@ -611,6 +624,95 @@ void test_the_loser_sheds_a_node_reached_both_ways(void) TEST_ASSERT_EQUAL_MESSAGE(2, h.shed.size(), "the name on the new link is remembered"); } +void test_a_quiet_dialled_link_is_probed_and_kept(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 244, true}}; + h.liveness(500); // first listed here: the window runs from now, not from the epoch + h.feed(1, helloWith(0x11), 1000); + h.liveness(20000); + TEST_ASSERT_EQUAL_MESSAGE(0, h.probed[1], "heard 19 s ago: not yet"); + h.liveness(31001); + TEST_ASSERT_EQUAL_MESSAGE(1, h.probed[1], "quiet for the idle window: asked once"); + h.liveness(45000); + TEST_ASSERT_EQUAL_MESSAGE(1, h.probed[1], "asked 14 s ago: not again yet"); + TEST_ASSERT_EQUAL_MESSAGE(0, h.shed.size(), "it answered, so it stays"); + h.feed(1, split(encode(encryptedPacket()), 1, 244)[0], 50000); + h.liveness(70000); + TEST_ASSERT_EQUAL_MESSAGE(1, h.probed[1], "traffic 20 s ago resets the window"); +} + +void test_a_dead_dialled_link_is_shed(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 244, true}}; + h.dead = {1}; + h.liveness(0); + h.feed(1, helloWith(0x11), 0); + h.liveness(30000); + TEST_ASSERT_EQUAL(1, h.probed[1]); + TEST_ASSERT_EQUAL_MESSAGE(0, h.shed.size(), "one miss may be a busy stack"); + h.liveness(31000); + TEST_ASSERT_EQUAL_MESSAGE(1, h.probed[1], "the retry waits its two seconds"); + h.liveness(32000); + TEST_ASSERT_EQUAL(2, h.probed[1]); + TEST_ASSERT_EQUAL_MESSAGE(1, h.shed.size(), "two misses: shed"); + TEST_ASSERT_EQUAL(1, h.shed[0]); + // Still listed until the platform reports the disconnect, and not asked again meanwhile. + h.liveness(32010); + TEST_ASSERT_EQUAL_MESSAGE(2, h.probed[1], "asked nothing more while it drains"); + TEST_ASSERT_EQUAL(1, h.shed.size()); + // The disconnect forgets the name it gave: a fresh HELLO on a new link is a new peer, not a match. + h.lost(1); + h.feed(2, helloWith(0x11), 32020); + TEST_ASSERT_EQUAL_MESSAGE(1, h.shed.size(), "nothing left to be one node with"); +} + +void test_a_fresh_dialled_link_gets_its_full_window(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 244, true}}; + // Up at t=100 s of uptime, never heard: the MTU exchange is still running, and a probe now would + // be refused by the stack and read as death. + h.liveness(100000); + TEST_ASSERT_EQUAL_MESSAGE(0, h.probed[1], "listed just now: not asked"); + h.liveness(129000); + TEST_ASSERT_EQUAL(0, h.probed[1]); + h.liveness(130000); + TEST_ASSERT_EQUAL_MESSAGE(1, h.probed[1], "asked once the window has run from link-up"); +} + +void test_one_missed_probe_is_forgiven_when_the_retry_answers(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 244, true}}; + h.liveness(0); + h.dead = {1}; + h.liveness(30000); + TEST_ASSERT_EQUAL(1, h.probed[1]); + h.dead.clear(); + h.liveness(32000); + TEST_ASSERT_EQUAL(2, h.probed[1]); + TEST_ASSERT_EQUAL_MESSAGE(0, h.shed.size(), "the retry answered: kept"); + h.liveness(40000); + TEST_ASSERT_EQUAL_MESSAGE(2, h.probed[1], "back to the long window"); +} + +void test_an_inbound_link_is_never_probed(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 244, false}}; + h.dead = {1}; + h.liveness(100000); + TEST_ASSERT_EQUAL_MESSAGE(0, h.probed[1], "the peer dialled us; its stack tells us when it goes"); + TEST_ASSERT_EQUAL(0, h.shed.size()); +} + void test_inbound_chunks_are_drained_by_the_pump(void) { FakeGattMesh h; @@ -664,6 +766,11 @@ void setup() RUN_TEST(test_a_refused_greeting_is_offered_again); RUN_TEST(test_a_hello_in_is_never_a_fragment); RUN_TEST(test_the_loser_sheds_a_node_reached_both_ways); + RUN_TEST(test_a_quiet_dialled_link_is_probed_and_kept); + RUN_TEST(test_a_dead_dialled_link_is_shed); + RUN_TEST(test_a_fresh_dialled_link_gets_its_full_window); + RUN_TEST(test_one_missed_probe_is_forgiven_when_the_retry_answers); + RUN_TEST(test_an_inbound_link_is_never_probed); exit(UNITY_END()); }