diff --git a/src/main.cpp b/src/main.cpp index 7211811a57..ecc1b4ed32 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -45,6 +45,9 @@ #include "detect/einkScan.h" #include "graphics/Screen.h" #include "main.h" +#if HAS_BLE_GATT_MESH && defined(ARCH_NRF52) +#include "platform/nrf52/NRF52BLEGattMesh.h" +#endif #include "memory/MemAudit.h" #include "mesh/generated/meshtastic/config.pb.h" #include "meshUtils.h" @@ -1081,6 +1084,14 @@ void setup() } #endif #endif + +#if HAS_BLE_GATT_MESH && defined(ARCH_NRF52) + // The service is registered by NRF52Bluetooth's setupMeshService(); this is the pump. + bleGattMeshHandler = new NRF52BLEGattMesh(); + if (config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_GATT_PEER) + bleGattMeshHandler->start(); +#endif + service = new MeshService(); service->init(); diff --git a/src/main.h b/src/main.h index 02f8c34a0f..9379937bc1 100644 --- a/src/main.h +++ b/src/main.h @@ -69,6 +69,9 @@ extern AudioThread *audioThread; #include "mesh/udp/UdpMulticastHandler.h" extern UdpMulticastHandler *udpHandler; #endif +#if HAS_BLE_GATT_MESH +#include "mesh/BLEGattMeshHandler.h" +#endif // Global Screen singleton. extern std::unique_ptr screen; diff --git a/src/mesh/BLEGattMeshHandler.cpp b/src/mesh/BLEGattMeshHandler.cpp new file mode 100644 index 0000000000..60da72cc42 --- /dev/null +++ b/src/mesh/BLEGattMeshHandler.cpp @@ -0,0 +1,590 @@ +#include "configuration.h" + +#if HAS_BLE_GATT_MESH + +#include "BLEGattMeshHandler.h" +#include "Throttle.h" +#include "UptimeClock.h" +#include "main.h" + +BLEGattMeshHandler *bleGattMeshHandler = nullptr; + +// --- framing ------------------------------------------------------------------------------------ + +bool BLEGattMeshHandler::parseFragment(const uint8_t *chunk, size_t len, FragmentHeader &hdr) +{ + if (!chunk || len < BLE_GATT_MESH_FRAG_HEADER || chunk[0] != BLE_GATT_MESH_FRAG_VERSION) + return false; + hdr.id = (uint16_t)(chunk[1] | (chunk[2] << 8)); + hdr.index = chunk[3]; + hdr.total = chunk[4]; + // A total of zero describes nothing and an index outside it can never complete; either would sit + // in the table until expiry, which is the buffer a hostile peer wants to fill. + return hdr.total != 0 && hdr.index < hdr.total; +} + +uint8_t BLEGattMeshHandler::fragmentCount(size_t packetLen, uint16_t chunk) +{ + if (chunk <= BLE_GATT_MESH_FRAG_HEADER) + return 0; + const size_t payloadPerChunk = chunk - BLE_GATT_MESH_FRAG_HEADER; + const size_t total = packetLen == 0 ? 1 : (packetLen + payloadPerChunk - 1) / payloadPerChunk; + return total > BLE_GATT_MESH_MAX_FRAGMENTS ? 0 : (uint8_t)total; +} + +size_t BLEGattMeshHandler::buildFragment(const uint8_t *packet, size_t packetLen, uint16_t fragId, uint8_t index, uint8_t total, + uint16_t chunk, uint8_t *out, size_t outCap) +{ + if (!packet || !out || chunk <= BLE_GATT_MESH_FRAG_HEADER || index >= total) + return 0; + const size_t payloadPerChunk = chunk - BLE_GATT_MESH_FRAG_HEADER; + const size_t from = (size_t)index * payloadPerChunk; + if (from > packetLen) + return 0; + const size_t n = std::min(payloadPerChunk, packetLen - from); + if (outCap < BLE_GATT_MESH_FRAG_HEADER + n) + return 0; + + out[0] = BLE_GATT_MESH_FRAG_VERSION; + out[1] = (uint8_t)(fragId & 0xFF); + out[2] = (uint8_t)(fragId >> 8); + out[3] = index; + out[4] = total; + memcpy(out + BLE_GATT_MESH_FRAG_HEADER, packet + from, n); + return BLE_GATT_MESH_FRAG_HEADER + n; +} + +// --- egress ------------------------------------------------------------------------------------- + +bool BLEGattMeshHandler::onSend(const meshtastic_MeshPacket *mp) +{ + if (!isRunning || !mp) + return false; + + // Router::send() encrypts before it reaches any transport, so plaintext here is a bug upstream. + if (mp->which_payload_variant != meshtastic_MeshPacket_encrypted_tag) { + LOG_WARN("BLE GATT mesh: refusing to send an unencrypted packet 0x%08x", mp->id); + return false; + } + if (mp->from == 0) { + LOG_WARN("BLE GATT mesh: refusing to send a packet with no sender"); + return false; + } + if (txCount >= BLE_GATT_MESH_TX_QUEUE_SIZE) { + LOG_WARN("BLE GATT mesh: TX queue full, dropping 0x%08x", mp->id); + return false; + } + + TxSlot &slot = txQueue[txTail]; + const size_t n = pb_encode_to_bytes(slot.data.data(), slot.data.size(), &meshtastic_MeshPacket_msg, mp); + if (n == 0) { + LOG_WARN("BLE GATT mesh: failed to encode 0x%08x", mp->id); + return false; + } + slot.len = (uint16_t)n; + slot.fragId = nextFragId++; + // A relay must not go back to the peer that handed it over - unless that peer originated it, in + // which case the echo is its implicit ack, the one a shared medium gives for free. + slot.exclude = relayExclusion(mp->from, mp->id); + + txTail = (txTail + 1) % BLE_GATT_MESH_TX_QUEUE_SIZE; + txCount++; + wake(); + return true; +} + +void BLEGattMeshHandler::wake() +{ + setIntervalFromNow(0); + concurrency::mainDelay.interrupt(); +} + +int32_t BLEGattMeshHandler::runOnce() +{ + if (!isRunning || !platformReady()) + return 500; + + const uint32_t now = Time::getMillis(); + pumpRx(now); + pumpGreet(now); + pumpLiveness(now); + return pumpTx() ? 10 : 100; +} + +bool BLEGattMeshHandler::isControl(const uint8_t *chunk, size_t len) +{ + return len >= 2 && chunk[0] == BLE_GATT_MESH_CTRL_MARKER; +} + +bool BLEGattMeshHandler::parseHello(const uint8_t *chunk, size_t len, uint8_t *idOut) +{ + if (!isControl(chunk, len) || chunk[1] != BLE_GATT_MESH_CTRL_HELLO || len != BLE_GATT_MESH_HELLO_SIZE) + return false; + memcpy(idOut, chunk + 2, BLE_GATT_MESH_LINK_ID_SIZE); + return true; +} + +// Drawn on first use, not in the constructor: a peer's HELLO can land before this node's first greet, +// and the election it starts must compare against the id the greet will carry, not zeros. +void BLEGattMeshHandler::ensureLinkId() +{ + if (linkIdReady) + return; + for (auto &b : linkId) + b = (uint8_t)random(256); + linkIdReady = true; +} + +size_t BLEGattMeshHandler::buildHello(uint8_t *out, size_t cap) +{ + if (cap < BLE_GATT_MESH_HELLO_SIZE) + return 0; + ensureLinkId(); + out[0] = BLE_GATT_MESH_CTRL_MARKER; + out[1] = BLE_GATT_MESH_CTRL_HELLO; + memcpy(out + 2, linkId, BLE_GATT_MESH_LINK_ID_SIZE); + return BLE_GATT_MESH_HELLO_SIZE; +} + +BLEGattMeshHandler::Greeting *BLEGattMeshHandler::greeting(BLEGattPeerId peer, bool create) +{ + for (auto &g : greetings) { + if (g.used && g.peer == peer) + return &g; + } + if (!create) + return nullptr; + for (auto &g : greetings) { + if (g.used) + continue; + g.used = true; + g.peer = peer; + g.sent = false; + g.heard = false; + g.heardMs = 0; + g.probedMs = 0; + g.probing = false; + g.probeMisses = 0; + return &g; + } + return nullptr; +} + +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, 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; + // A probe is out: read its answer, and give it the wait before calling silence a miss. + if (g->probing) { + const ProbeAnswer answer = platformProbeAnswer(all[i].id); + if (answer == ProbeAnswer::Pending && nowMs - g->probedMs < BLE_GATT_MESH_PROBE_WAIT_MS) + continue; + g->probing = false; + if (answer == ProbeAnswer::Answered) { + g->probeMisses = 0; + continue; + } + if (++g->probeMisses < 2) + continue; + LOG_WARN("BLE GATT mesh: dialled conn %u answers nothing; shedding it", all[i].id); + platformShedOutbound(all[i].id); + 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 (platformProbeStart(all[i].id)) { + g->probing = true; + 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); + if (!g) + return; + ensureLinkId(); + memcpy(g->id, id, BLE_GATT_MESH_LINK_ID_SIZE); + g->heard = true; + LOG_INFO("BLE GATT mesh: conn %u is link %02x%02x%02x%02x%02x%02x%02x%02x", peer, id[0], id[1], id[2], id[3], id[4], id[5], + id[6], id[7]); + // The same name on another link: one node reached both ways. Same rule as the client library - + // the lower id is the central. As the winner nothing is done; the peer sheds the link it dialled. + for (const auto &o : greetings) { + if (!o.used || !o.heard || o.peer == peer || memcmp(o.id, id, BLE_GATT_MESH_LINK_ID_SIZE) != 0) + continue; + const bool lose = memcmp(linkId, id, BLE_GATT_MESH_LINK_ID_SIZE) > 0; + LOG_INFO("BLE GATT mesh: conns %u and %u are one node; this node %s the election", o.peer, peer, lose ? "loses" : "wins"); + if (lose) { + platformShedOutbound(peer); + platformShedOutbound(o.peer); + } + return; + } +} + +void BLEGattMeshHandler::pumpRx(uint32_t nowMs) +{ + BLEGattPeerId peer; + uint8_t chunk[BLE_GATT_MESH_MAX_CHUNK]; + size_t len; + while (platformPollInbound(peer, chunk, sizeof(chunk), len)) + handleChunk(peer, chunk, len, nowMs); +} + +bool BLEGattMeshHandler::pumpTx() +{ + if (!txActive) { + if (txCount == 0) + return false; + // Snapshot the peers once per packet, minus the one it arrived from. A peer joining mid-packet + // waits for the next one rather than receiving half of this one. + const TxSlot &slot = txQueue[txHead]; + std::array all{}; + const size_t n = platformPeers(all.data(), all.size()); + txPeerCount = 0; + for (size_t i = 0; i < n; i++) { + if (all[i].id != slot.exclude) + txPeers[txPeerCount++] = all[i]; + } + txPeerIdx = 0; + txFragIdx = 0; + txAttempts = 0; + txActive = true; + } + + const TxSlot &slot = txQueue[txHead]; + while (txPeerIdx < txPeerCount) { + const BLEGattMeshPeer &peer = txPeers[txPeerIdx]; + const uint16_t chunk = + std::min(std::max(peer.chunk, BLE_GATT_MESH_MIN_CHUNK), BLE_GATT_MESH_MAX_CHUNK); + const uint8_t total = fragmentCount(slot.len, chunk); + if (total == 0) { + LOG_WARN("BLE GATT mesh: %u bytes do not fit peer %u at %u-byte chunks", (unsigned)slot.len, peer.id, chunk); + } else { + while (txFragIdx < total) { + uint8_t frag[BLE_GATT_MESH_MAX_CHUNK]; + const size_t fragLen = + buildFragment(slot.data.data(), slot.len, slot.fragId, txFragIdx, total, chunk, frag, sizeof(frag)); + if (fragLen == 0) + break; + if (!platformNotify(peer.id, frag, fragLen)) { + if (++txAttempts < BLE_GATT_MESH_TX_ATTEMPTS) + return true; // the stack is busy: retry this fragment on the next tick + LOG_WARN("BLE GATT mesh: peer %u is not accepting notifies, skipping it", peer.id); + break; + } + txAttempts = 0; + txFragIdx++; + } + } + txPeerIdx++; + txFragIdx = 0; + txAttempts = 0; + } + + txHead = (txHead + 1) % BLE_GATT_MESH_TX_QUEUE_SIZE; + txCount--; + txActive = false; + return txCount > 0; +} + +// --- ingress ------------------------------------------------------------------------------------ + +void BLEGattMeshHandler::handleChunk(BLEGattPeerId peer, const uint8_t *chunk, size_t len, uint32_t nowMs) +{ + if (!isRunning) + return; + if (len == 0) { + 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)) { + handleHello(peer, id); + return; + } + if (isControl(chunk, len)) + return; // an opcode this build does not know is not a fragment either + + uint8_t packet[meshtastic_MeshPacket_size]; + const size_t n = reassemble(peer, chunk, len, nowMs, packet, sizeof(packet)); + if (n > 0) + deliverToRouter(peer, packet, n); +} + +size_t BLEGattMeshHandler::reassemble(BLEGattPeerId peer, const uint8_t *chunk, size_t len, uint32_t nowMs, uint8_t *out, + size_t outCap) +{ + expireAssemblies(nowMs); + + FragmentHeader hdr; + if (!parseFragment(chunk, len, hdr)) + return 0; + const uint8_t *payload = chunk + BLE_GATT_MESH_FRAG_HEADER; + const size_t payloadLen = len - BLE_GATT_MESH_FRAG_HEADER; + if (payloadLen > outCap) + return 0; + + // The common case, and the one that must not take a slot: a packet that fitted one write. + if (hdr.total == 1) { + memcpy(out, payload, payloadLen); + return payloadLen; + } + + Assembly *a = findAssembly(peer, hdr.id); + // A peer reusing an id with a different length is not a fragment of what we were building. + if (a && a->total != hdr.total) { + a->used = false; + a = nullptr; + } + if (!a) { + // ATT delivers a peer's writes in order, so a packet always starts at index 0 here. + if (hdr.index != 0) + return 0; + a = newAssembly(peer, hdr.id, hdr.total, nowMs); + if (!a) + return 0; + } + + if (hdr.index < a->received) + return 0; // a re-delivered fragment on a retrying link: no-op + if (hdr.index > a->received || a->bytes + payloadLen > a->data.size()) { + // A gap can never be filled on an ordered link, and an oversize assembly can never legitimately + // complete; either way the slot must not sit occupied until expiry. + a->used = false; + return 0; + } + + memcpy(a->data.data() + a->bytes, payload, payloadLen); + a->bytes += payloadLen; + a->received++; + if (a->received < a->total) + return 0; + + const size_t n = a->bytes; + a->used = false; + if (n > outCap) + return 0; + memcpy(out, a->data.data(), n); + return n; +} + +BLEGattMeshHandler::Assembly *BLEGattMeshHandler::findAssembly(BLEGattPeerId peer, uint16_t id) +{ + for (auto &a : assemblies) { + if (a.used && a.peer == peer && a.id == id) + return &a; + } + return nullptr; +} + +BLEGattMeshHandler::Assembly *BLEGattMeshHandler::newAssembly(BLEGattPeerId peer, uint16_t id, uint8_t total, uint32_t nowMs) +{ + // Per-peer first, so one loud peer cannot crowd out everyone else's traffic. + size_t mine = 0; + size_t otherPeers = 0; + BLEGattPeerId seen[BLE_GATT_MESH_MAX_PEERS]; + for (const auto &a : assemblies) { + if (!a.used) + continue; + if (a.peer == peer) { + mine++; + continue; + } + bool counted = false; + for (size_t i = 0; i < otherPeers; i++) + counted |= seen[i] == a.peer; + if (!counted && otherPeers < BLE_GATT_MESH_MAX_PEERS) + seen[otherPeers++] = a.peer; + } + if (mine >= BLE_GATT_MESH_MAX_IN_FLIGHT_PER_PEER) + return nullptr; + if (mine == 0 && otherPeers >= BLE_GATT_MESH_MAX_PEERS) + return nullptr; + + for (auto &a : assemblies) { + if (a.used) + continue; + a.used = true; + a.peer = peer; + a.id = id; + a.total = total; + a.received = 0; + a.bytes = 0; + a.startedMs = nowMs; + return &a; + } + return nullptr; // unreachable while the bounds above hold: the table is sized to their product +} + +void BLEGattMeshHandler::forgetPeer(BLEGattPeerId peer) +{ + if (Greeting *g = greeting(peer, false)) + g->used = false; + for (auto &a : assemblies) { + if (a.used && a.peer == peer) + a.used = false; + } +} + +void BLEGattMeshHandler::expireAssemblies(uint32_t nowMs) +{ + for (auto &a : assemblies) { + if (a.used && Throttle::deadlinePassedAt(nowMs, a.startedMs + BLE_GATT_MESH_REASSEMBLY_EXPIRY_MS)) + a.used = false; + } +} + +size_t BLEGattMeshHandler::pendingAssemblies() const +{ + size_t n = 0; + for (const auto &a : assemblies) + n += a.used ? 1 : 0; + return n; +} + +void BLEGattMeshHandler::rememberArrival(NodeNum from, PacketId id, BLEGattPeerId peer, bool fromOrigin) +{ + arrivals[arrivalNext] = {from, id, peer, fromOrigin}; + arrivalNext = (arrivalNext + 1) % arrivals.size(); +} + +BLEGattPeerId BLEGattMeshHandler::arrivalPeer(NodeNum from, PacketId id) const +{ + for (const auto &a : arrivals) { + if (a.peer != BLE_GATT_MESH_NO_PEER && a.from == from && a.id == id) + return a.peer; + } + return BLE_GATT_MESH_NO_PEER; +} + +/* + * On LoRa an originator hears its own packet relayed and takes that as the implicit ack. A + * point-to-point bearer has no such echo: excluding the delivering peer from the relay leaves a + * neighbour that reached us over GATT alone with no delivery evidence at all. So the exclusion + * applies only when we are carrying someone else's packet onward. + * + * This is the only bearer with an exclusion at all - BLEMeshHandler and the UDP transport are + * broadcast media and have none - which is why GATT was the only one where an implicit ack could + * not arrive. node-kmp's MeshNode.scheduleRelay holds the same rule as `header.hopsAway == 0`; + * change one and change the other. + * + * Echoing to the originator cannot loop - it drops a packet bearing its own node number, and so do + * we - and it costs one write per relayed broadcast per adjacent originator. + */ +BLEGattPeerId BLEGattMeshHandler::relayExclusion(NodeNum from, PacketId id) const +{ + for (const auto &a : arrivals) { + if (a.peer != BLE_GATT_MESH_NO_PEER && a.from == from && a.id == id) + return a.fromOrigin ? BLE_GATT_MESH_NO_PEER : a.peer; + } + return BLE_GATT_MESH_NO_PEER; +} + +void BLEGattMeshHandler::deliverToRouter(BLEGattPeerId peer, const uint8_t *data, size_t len) +{ + if (!isRunning || !nodeDB || !data) + return; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + if (!pb_decode_from_bytes(data, len, &meshtastic_MeshPacket_msg, &mp)) + return; + if (mp.which_payload_variant != meshtastic_MeshPacket_encrypted_tag) + return; + + // The same guards the UDP and advertisement transports apply: a spoofed local origin reaches + // paths that trust isFromUs, and an out-of-range hop count is not relayable. + if (mp.from == 0) { + LOG_WARN("BLE GATT mesh: packet with no sender from peer %u, dropping", peer); + return; + } + if (mp.from == nodeDB->getNodeNum()) { + LOG_WARN("BLE GATT mesh: peer %u claims our own node number, dropping", peer); + return; + } + if (mp.hop_limit > HOP_MAX || mp.hop_start > HOP_MAX) { + LOG_WARN("BLE GATT mesh: invalid hop_limit(%u)/hop_start(%u), dropping", mp.hop_limit, mp.hop_start); + return; + } + + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_GATT; + // Wire-carried flags that only the local stack may set: a sender must not suppress our MQTT uplink + // or schedule our transmit. + mp.via_mqtt = false; + mp.tx_after = 0; + // priority is not carried in the LoRa header, so here a sender can choose it, and priority MAX + // outranks the ACK ceiling fixPriority assigns locally. + mp.priority = meshtastic_MeshPacket_Priority_UNSET; + + // Authentication metadata is local-only; the Router re-establishes it after a PKI decrypt. + mp.pki_encrypted = false; + mp.public_key.size = 0; + memset(mp.public_key.bytes, 0, sizeof(mp.public_key.bytes)); + + // No radio measurement exists for a GATT arrival. + mp.rx_snr = 0; + mp.rx_rssi = 0; + mp.has_rx_rssi = false; + + UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); + if (!p) + return; + + // Read here, before the Router decrements: equal counts mean no relay has touched it, so this + // peer is the node that sent it. + rememberArrival(mp.from, mp.id, peer, mp.hop_start == mp.hop_limit); + LOG_DEBUG("BLE GATT mesh RX from=0x%08x to=0x%08x id=0x%08x peer=%u len=%u", mp.from, mp.to, mp.id, peer, (unsigned)len); + enqueueReceived(p.release()); +} + +void BLEGattMeshHandler::enqueueReceived(meshtastic_MeshPacket *p) +{ + router->enqueueReceivedMessage(p); +} + +#endif // HAS_BLE_GATT_MESH diff --git a/src/mesh/BLEGattMeshHandler.h b/src/mesh/BLEGattMeshHandler.h new file mode 100644 index 0000000000..2bdc6c4a0a --- /dev/null +++ b/src/mesh/BLEGattMeshHandler.h @@ -0,0 +1,282 @@ +#pragma once + +#if HAS_BLE_GATT_MESH + +#include "MeshTransportBase.h" +#include "MeshTypes.h" +#include "NodeDB.h" +#include "Router.h" +#include "concurrency/OSThread.h" +#include "mesh-pb-constants.h" + +#include + +// The mesh-peer service a phone connects to. Private UUIDs shared with the client library +// (node-transport-ble-gatt); deliberately not the phone-API service, which a stock app dials. +#define BLE_GATT_MESH_SERVICE_UUID "4d657368-4e6f-6465-4741-545400000001" +#define BLE_GATT_MESH_CHARACTERISTIC_UUID "4d657368-4e6f-6465-4741-545400000002" + +// One write or notify carries one fragment: [version][id lo][id hi][index][total][payload...]. +// The id only separates packets in flight from the same peer; the link already tags chunks by peer. +#define BLE_GATT_MESH_FRAG_VERSION 1 +#define BLE_GATT_MESH_FRAG_HEADER 5 +#define BLE_GATT_MESH_MAX_FRAGMENTS 255 + +// ATT spends 3 bytes of the negotiated MTU on its own header. 23 is the MTU every peer supports, +// 512 is the largest attribute value NimBLE will carry. +#define BLE_GATT_MESH_MIN_CHUNK 20 +#define BLE_GATT_MESH_MAX_CHUNK 512 + +// Reassembly is bounded because the input is attacker-controlled: anyone in range can connect to an +// open service and start writing. A fragment that never completes costs memory until it expires. +#ifndef BLE_GATT_MESH_MAX_PEERS +#define BLE_GATT_MESH_MAX_PEERS 2 +#endif +#ifndef BLE_GATT_MESH_MAX_IN_FLIGHT_PER_PEER +#define BLE_GATT_MESH_MAX_IN_FLIGHT_PER_PEER 2 +#endif +#ifndef BLE_GATT_MESH_REASSEMBLY_EXPIRY_MS +#define BLE_GATT_MESH_REASSEMBLY_EXPIRY_MS 15000 +#endif + +// Whole outbound packets waiting for the pump; each is fragmented per peer as it goes out. +#ifndef BLE_GATT_MESH_TX_QUEUE_SIZE +#define BLE_GATT_MESH_TX_QUEUE_SIZE 4 +#endif + +// A refused notify means the host is out of buffers, not that the peer is gone; retry with the +// 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 +#endif + +// 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 +// How long a started probe may stay unanswered before it counts as a miss. A backgrounded iPhone on +// a long connection interval answers later than Bluefruit's own 100 ms write timeout but inside this. +#ifndef BLE_GATT_MESH_PROBE_WAIT_MS +#define BLE_GATT_MESH_PROBE_WAIT_MS 1500 +#endif + +// (from, id) -> arrival peer, so a relay is never written back to the peer that delivered it. +#define BLE_GATT_MESH_RECENT_ARRIVALS 8 + +// Link-level control, shared with the client library (GattControl.kt): a chunk that starts with the +// marker is the link's own business, never a fragment. HELLO carries an id that stands for this node +// on GATT links and nowhere else - random per boot, deliberately not the node number. +#define BLE_GATT_MESH_CTRL_MARKER 0xC0 +#define BLE_GATT_MESH_CTRL_HELLO 0x01 +#define BLE_GATT_MESH_LINK_ID_SIZE 8 +#define BLE_GATT_MESH_HELLO_SIZE (2 + BLE_GATT_MESH_LINK_ID_SIZE) + +/// A connection handle. Stable for the life of a connection; not an identity. +typedef uint16_t BLEGattPeerId; +#define BLE_GATT_MESH_NO_PEER 0xFFFF + +struct BLEGattMeshPeer { + BLEGattPeerId id; + uint16_t chunk; // negotiated ATT MTU - 3 + bool outbound = false; // this node dialled it, so nothing but this node will ever notice it die +}; + +/** + * Carries mesh frames between this node and phones connected over a BLE GATT link, the SIG Mesh + * "GATT proxy" role. The node is the GATT server; each phone is a central that writes fragments to + * the mesh characteristic and subscribes to it. The liveness probe and the HELLO election cover a + * link this node dialled itself, which no platform here does yet: the hooks keep their defaults. + * + * Point-to-point where LoRa and the advertisement transport are one-to-many: a broadcast here is N + * notifies to N peers. Egress skips the peer a relayed packet arrived from. Framing, reassembly + * bounds and ingress sanitising live here in platform-neutral code so the native suite covers them; + * the platform half only moves opaque chunks. + * + * Both ends of every ring run on the main task: onSend() is reached from Router::send(), runOnce() + * is an OSThread on the same task, and platformPollInbound() is called from runOnce(). The BLE + * stack's own task never touches this class. + */ +class BLEGattMeshHandler : private concurrency::OSThread, public MeshTransportBase +{ + public: + BLEGattMeshHandler() : concurrency::OSThread("BLEGattMesh") {} + virtual ~BLEGattMeshHandler() {} + + virtual void start() = 0; + virtual void stop() = 0; + + bool isEnabled() const override + { + return config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_GATT_PEER; + } + + /// Called from Router::send(). Encodes and queues; never notifies inline. + bool onSend(const meshtastic_MeshPacket *mp) override; + + /// Schedule the pump now. Safe from the BLE stack's task (mirrors BluetoothPhoneAPI). + void wake(); + + struct FragmentHeader { + uint16_t id; + uint8_t index; + uint8_t total; + }; + /// Read a fragment header; false if it is not ours or is self-contradictory. + static bool parseFragment(const uint8_t *chunk, size_t len, FragmentHeader &hdr); + /// Fragments a packet of packetLen needs at this chunk size; 0 when it cannot be carried. + static uint8_t fragmentCount(size_t packetLen, uint16_t chunk); + /// Write fragment `index` of `packet` into out. Returns the fragment length, 0 on refusal. + static size_t buildFragment(const uint8_t *packet, size_t packetLen, uint16_t fragId, uint8_t index, uint8_t total, + uint16_t chunk, uint8_t *out, size_t outCap); + /// The id a HELLO names, into idOut (BLE_GATT_MESH_LINK_ID_SIZE bytes); false when chunk is not one. + static bool parseHello(const uint8_t *chunk, size_t len, uint8_t *idOut); + /// True for any control chunk, a HELLO or an opcode this build does not know. + static bool isControl(const uint8_t *chunk, size_t len); + + protected: + // --- platform hooks --------------------------------------------------------------------- + /// True once the stack is up and the service is registered. Must query the stack itself. + virtual bool platformReady() = 0; + /// Copy the peers currently subscribed to the mesh characteristic into out; returns the count. + virtual size_t platformPeers(BLEGattMeshPeer *out, size_t cap) = 0; + /// Notify one fragment to one peer. False when the stack refused it; the pump retries. + virtual bool platformNotify(BLEGattPeerId peer, const uint8_t *data, size_t len) = 0; + /// Take one received chunk. A zero-length chunk means the peer disconnected. False when none waits. + virtual bool platformPollInbound(BLEGattPeerId &peer, uint8_t *buf, size_t cap, size_t &len) = 0; + /// 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); + /// What became of the probe last started on a link. + enum class ProbeAnswer : uint8_t { Pending, Answered, Refused }; + /// Ask `peer` for proof of life - a write it must acknowledge - and return at once; the answer is + /// read by platformProbeAnswer on later pumps. False means it could not even be asked (the stack + /// is busy, or the link is already gone), which counts as a miss. Never blocks: this runs on the + /// main task, which also times LoRa transmissions. A platform that cannot ask says true and + /// answers at once, and so never sheds a link for silence. + virtual bool platformProbeStart(BLEGattPeerId peer) + { + (void)peer; + return true; + } + virtual ProbeAnswer platformProbeAnswer(BLEGattPeerId peer) + { + (void)peer; + return ProbeAnswer::Answered; + } + + int32_t runOnce() override; + + /// Reassemble one chunk from `peer`, delivering the packet to the router when it completes. + void handleChunk(BLEGattPeerId peer, const uint8_t *chunk, size_t len, uint32_t nowMs); + + /// Decode a reassembled packet, apply the ingress guards, and enqueue it into the router. + void deliverToRouter(BLEGattPeerId peer, const uint8_t *data, size_t len); + + /// Hand an accepted packet on. Virtual only so the native tests can observe what survives the + /// ingress guards without a live Router; production always takes the default. + virtual void enqueueReceived(meshtastic_MeshPacket *p); + + /// Assemblies still waiting on fragments. Exposed so the bounds are observable in tests. + size_t pendingAssemblies() const; + + bool isRunning = false; + + private: + struct Assembly { + bool used; + BLEGattPeerId peer; + uint16_t id; + uint8_t total; + uint8_t received; + uint16_t bytes; + uint32_t startedMs; + std::array data; + }; + std::array assemblies{}; + + /// Offer one chunk; returns the whole packet's length in out once its last fragment lands, else 0. + size_t reassemble(BLEGattPeerId peer, const uint8_t *chunk, size_t len, uint32_t nowMs, uint8_t *out, size_t outCap); + Assembly *findAssembly(BLEGattPeerId peer, uint16_t id); + Assembly *newAssembly(BLEGattPeerId peer, uint16_t id, uint8_t total, uint32_t nowMs); + void forgetPeer(BLEGattPeerId peer); + void expireAssemblies(uint32_t nowMs); + + struct Arrival { + NodeNum from; + PacketId id; + BLEGattPeerId peer; + // Whether this peer originated the packet rather than relaying it: hop_start == hop_limit on + // arrival, read before the Router decrements. Decides whether the relay may go back to it. + bool fromOrigin; + }; + std::array arrivals{}; + size_t arrivalNext = 0; + void rememberArrival(NodeNum from, PacketId id, BLEGattPeerId peer, bool fromOrigin); + + // This node's link id, and what each peer has said. A greeting goes out once per link, retried + // while the stack refuses it; a HELLO in names the peer, and the same name on two links is one + // node reached both ways - the lower id is the central, the loser sheds the link it dialled. + uint8_t linkId[BLE_GATT_MESH_LINK_ID_SIZE] = {}; + bool linkIdReady = false; + struct Greeting { + bool used; + BLEGattPeerId peer; + 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 + bool probing; // a probe is out and its answer not yet read + }; + std::array greetings{}; + Greeting *greeting(BLEGattPeerId peer, bool create); + 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 + // its originator - that echo is the originator's only implicit ack on a point-to-point bearer. + BLEGattPeerId relayExclusion(NodeNum from, PacketId id) const; + + struct TxSlot { + std::array data; + uint16_t len; + uint16_t fragId; + BLEGattPeerId exclude; + }; + std::array txQueue{}; + size_t txHead = 0; + size_t txTail = 0; + size_t txCount = 0; + + // The send in progress: the peer snapshot taken when it started, and the position within it. + bool txActive = false; + std::array txPeers{}; + size_t txPeerCount = 0; + size_t txPeerIdx = 0; + uint8_t txFragIdx = 0; + uint8_t txAttempts = 0; + uint16_t nextFragId = 0; + + void pumpRx(uint32_t nowMs); + /// Advance the send in progress. True while there is more to do. + bool pumpTx(); +}; + +extern BLEGattMeshHandler *bleGattMeshHandler; + +#endif // HAS_BLE_GATT_MESH diff --git a/src/platform/nrf52/NRF52BLEGattMesh.cpp b/src/platform/nrf52/NRF52BLEGattMesh.cpp new file mode 100644 index 0000000000..9bb101c4f5 --- /dev/null +++ b/src/platform/nrf52/NRF52BLEGattMesh.cpp @@ -0,0 +1,286 @@ +#include "configuration.h" + +#if HAS_BLE_GATT_MESH && defined(ARCH_NRF52) + +#include "NRF52BLEGattMesh.h" +#include "concurrency/Lock.h" +#include "concurrency/LockGuard.h" +#include "main.h" +#include +#include + +namespace +{ +// Shared between Bluefruit's callback task and the main task (the pump). +concurrency::Lock lock; + +struct Link { + bool used; + uint16_t conn; + bool subscribed; // wrote the CCCD: a notify target, and the mark of a mesh peer + bool everSubscribed; // subscribed at any point, which outlives an unsubscribe +}; +std::array links{}; + +struct RxChunk { + uint16_t conn; + uint16_t len; // 0 marks a disconnect + uint8_t data[BLE_GATT_MESH_MAX_CHUNK]; +}; +std::array rxQueue{}; +size_t rxHead = 0; +size_t rxTail = 0; +size_t rxCount = 0; +bool serviceReady = false; + +// BLE_GATT_MESH_SERVICE_UUID / _CHARACTERISTIC_UUID as the SoftDevice wants them: little-endian. +const uint8_t serviceUuid[16] = {0x01, 0x00, 0x00, 0x00, 0x54, 0x54, 0x41, 0x47, 0x65, 0x64, 0x6f, 0x4e, 0x68, 0x73, 0x65, 0x4d}; +const uint8_t characteristicUuid[16] = {0x02, 0x00, 0x00, 0x00, 0x54, 0x54, 0x41, 0x47, + 0x65, 0x64, 0x6f, 0x4e, 0x68, 0x73, 0x65, 0x4d}; +BLEService meshPeerService = BLEService(BLEUuid(serviceUuid)); +BLECharacteristic meshPeerCharacteristic = BLECharacteristic(BLEUuid(characteristicUuid)); + +// The negotiated MTU is read live: Bluefruit exposes no MTU-changed callback. +uint16_t chunkFor(uint16_t conn) +{ + BLEConnection *c = Bluefruit.Connection(conn); + const uint16_t mtu = c ? c->getMtu() : 0; + return mtu > 3 ? mtu - 3 : BLE_GATT_MESH_MIN_CHUNK; +} + +// Callers hold `lock` for everything below this line. +Link *findLink(uint16_t conn) +{ + for (auto &l : links) { + if (l.used && l.conn == conn) + return &l; + } + return nullptr; +} + +Link *addLink(uint16_t conn) +{ + if (Link *l = findLink(conn)) + return l; + for (auto &l : links) { + if (l.used) + continue; + l.used = true; + l.conn = conn; + l.subscribed = false; + l.everSubscribed = false; + return &l; + } + return nullptr; +} + +// Counted since boot so a dropped write carries its own denominator: the log reaches a host as a +// sparse LogRecord stream, and one surviving line has to be enough to compute a rate from. +uint32_t rxArrived = 0; +uint32_t rxAccepted = 0; +uint32_t rxDropped = 0; + +void pushRx(uint16_t conn, const uint8_t *data, uint16_t len) +{ + if (rxCount >= rxQueue.size()) { + if (len) { + rxDropped++; + LOG_WARN("BLE GATT mesh: RX queue full, dropping a %u-byte write from conn %u (accepted %u, dropped %u)", len, conn, + (unsigned)rxAccepted, (unsigned)rxDropped); + return; + } + // A disconnect marker must land or the pump keeps that handle's half-built packets for the + // next peer the stack gives it: overwrite the newest chunk, which belongs to a dead link anyway. + rxTail = (rxTail + rxQueue.size() - 1) % rxQueue.size(); + rxCount--; + } + RxChunk &r = rxQueue[rxTail]; + r.conn = conn; + r.len = len; + if (len) + memcpy(r.data, data, len); + rxTail = (rxTail + 1) % rxQueue.size(); + rxCount++; + if (len) + rxAccepted++; +} + +void onWrite(uint16_t conn, BLECharacteristic *, uint8_t *data, uint16_t len) +{ + // Bluefruit's callback task: copy the value out and wake the pump. Nothing here touches the mesh. + if (len == 0 || len > BLE_GATT_MESH_MAX_CHUNK) { + LOG_WARN("BLE GATT mesh: write from conn %u refused at the door, %u bytes", conn, len); + return; + } + { + concurrency::LockGuard guard(&lock); + addLink(conn); + pushRx(conn, data, len); + } + // After the lock, never inside it: a LOG_ that takes one deadlocks against this same mutex. + // Every arrival is logged because the question this answers is whether writes reach the door at + // all - pushRx speaks only when it turns one away. + rxArrived++; + LOG_DEBUG("BLE GATT mesh: write %u bytes from conn %u (arrived %u, accepted %u, dropped %u)", len, conn, (unsigned)rxArrived, + (unsigned)rxAccepted, (unsigned)rxDropped); + if (bleGattMeshHandler) + bleGattMeshHandler->wake(); +} + +void onCccd(uint16_t conn, BLECharacteristic *, uint16_t value) +{ + // The canonical "this link is a mesh peer" signal: a CCCD write enabling notifications. Only a + // subscribed link is a notify target, so a plain phone-API client is never sent mesh frames. + const bool subscribed = (value & 0x0001) != 0; + { + concurrency::LockGuard guard(&lock); + if (Link *l = addLink(conn)) { + l->subscribed = subscribed; + l->everSubscribed |= subscribed; + } + } + LOG_INFO("BLE GATT mesh: conn %u %s (chunk %u)", conn, subscribed ? "subscribed" : "unsubscribed", chunkFor(conn)); + if (bleGattMeshHandler) + bleGattMeshHandler->wake(); +} +} // namespace + +void NRF52BLEGattMesh::setupService() +{ + // Deliberately no encryption or pairing requirement: a mesh peer is a stranger by design, exactly + // as on LoRa, and the channel PSK is the security. The client library does not pair. + meshPeerService.setPermission(SECMODE_OPEN, SECMODE_OPEN); + meshPeerService.begin(); + meshPeerCharacteristic.setProperties(CHR_PROPS_WRITE | CHR_PROPS_WRITE_WO_RESP | CHR_PROPS_NOTIFY); + meshPeerCharacteristic.setPermission(SECMODE_OPEN, SECMODE_OPEN); + meshPeerCharacteristic.setFixedLen(0); + meshPeerCharacteristic.setMaxLen(BLE_GATT_MESH_MAX_CHUNK); + meshPeerCharacteristic.setWriteCallback(onWrite, true); + meshPeerCharacteristic.setCccdWriteCallback(onCccd, true); + meshPeerCharacteristic.begin(); + { + concurrency::LockGuard guard(&lock); + for (auto &l : links) + l.used = false; + rxHead = rxTail = rxCount = 0; + serviceReady = true; + } + LOG_INFO("BLE GATT mesh: mesh-peer service registered"); +} + +bool NRF52BLEGattMesh::addToScanResponse() +{ + if (!(config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_GATT_PEER)) + return false; + // 18 of the scan response's 31 bytes; the name that follows is shortened to what is left. + Bluefruit.ScanResponse.addService(meshPeerService); + LOG_INFO("BLE GATT mesh: advertising the mesh-peer service in the scan response"); + return true; +} + +void NRF52BLEGattMesh::rearmAdvertising() +{ + // Bluefruit stops the connectable advertisement on connect and restarts it only when no peripheral + // link is left, so with the phone and a peer sharing the radio the free slot would otherwise go + // unadvertised. Bluefruit's own connect/disconnect handling ran synchronously before the deferred + // callbacks this is reached from. + if ((config.network.enabled_protocols & meshtastic_Config_NetworkConfig_ProtocolFlags_BLE_GATT_PEER) && + Bluefruit.Periph.connected() < 2 && !Bluefruit.Advertising.isRunning()) + Bluefruit.Advertising.start(0); +} + +void NRF52BLEGattMesh::onConnect(uint16_t conn) +{ + { + concurrency::LockGuard guard(&lock); + addLink(conn); + } + rearmAdvertising(); +} + +bool NRF52BLEGattMesh::onDisconnect(uint16_t conn) +{ + bool subscribed = false; + { + concurrency::LockGuard guard(&lock); + if (Link *l = findLink(conn)) { + subscribed = l->everSubscribed; + l->used = false; + pushRx(conn, nullptr, 0); // the pump drops its half-built packets + } + } + LOG_INFO("BLE GATT mesh: conn %u disconnected (%s)", conn, subscribed ? "was a mesh peer" : "never subscribed"); + if (bleGattMeshHandler) + bleGattMeshHandler->wake(); + // Bluefruit's restartOnDisconnect only fires when no peripheral link is left; with one still up, + // whoever just dropped could never come back without this. + rearmAdvertising(); + return subscribed; +} + +void NRF52BLEGattMesh::start() +{ + if (isRunning) + return; + isRunning = true; + LOG_INFO("BLE GATT mesh started (waiting for Bluetooth ready)"); +} + +void NRF52BLEGattMesh::stop() +{ + if (!isRunning) + return; + isRunning = false; + LOG_INFO("BLE GATT mesh stopped"); +} + +bool NRF52BLEGattMesh::platformReady() +{ + // setupService() runs inside NRF52Bluetooth::setup() after Bluefruit.begin(), so a registered + // service already means the SoftDevice is up. + concurrency::LockGuard guard(&lock); + return serviceReady; +} + +size_t NRF52BLEGattMesh::platformPeers(BLEGattMeshPeer *out, size_t cap) +{ + concurrency::LockGuard guard(&lock); + size_t n = 0; + for (const auto &l : links) { + if (!l.used || !l.subscribed) + continue; + if (n >= cap) + break; + out[n].id = l.conn; + out[n].chunk = chunkFor(l.conn); + out[n].outbound = false; // every link here was dialled by the peer + n++; + } + return n; +} + +bool NRF52BLEGattMesh::platformNotify(BLEGattPeerId peer, const uint8_t *data, size_t len) +{ + // Bluefruit's notify blocks up to 100 ms waiting for a buffer, so a peer whose link is gone must be + // refused here, not discovered by timing out fifty times on the main task. + if (!Bluefruit.connected(peer)) + return false; + return meshPeerCharacteristic.notify(peer, data, (uint16_t)len); +} + +bool NRF52BLEGattMesh::platformPollInbound(BLEGattPeerId &peer, uint8_t *buf, size_t cap, size_t &len) +{ + concurrency::LockGuard guard(&lock); + if (rxCount == 0) + return false; + const RxChunk &r = rxQueue[rxHead]; + peer = r.conn; + len = std::min(r.len, cap); + if (len) + memcpy(buf, r.data, len); + rxHead = (rxHead + 1) % rxQueue.size(); + rxCount--; + return true; +} + +#endif // HAS_BLE_GATT_MESH && ARCH_NRF52 diff --git a/src/platform/nrf52/NRF52BLEGattMesh.h b/src/platform/nrf52/NRF52BLEGattMesh.h new file mode 100644 index 0000000000..45304ed006 --- /dev/null +++ b/src/platform/nrf52/NRF52BLEGattMesh.h @@ -0,0 +1,44 @@ +#pragma once + +#if HAS_BLE_GATT_MESH && defined(ARCH_NRF52) + +#include "mesh/BLEGattMeshHandler.h" + +#include + +// Received writes waiting for the main task; each holds one ATT value, so up to 512 bytes. +#ifndef BLE_GATT_MESH_RX_QUEUE_SIZE +#define BLE_GATT_MESH_RX_QUEUE_SIZE 6 +#endif + +/** + * The mesh-peer GATT service on the SoftDevice. S140 has one advertising set, so the service UUID + * rides in the phone advertisement's scan response and a mesh peer connects to the same set the + * phone does; a link becomes a mesh peer when it subscribes to the mesh characteristic. Bluefruit + * runs the callbacks on its own task and the pump runs on the main task, hence the lock. + */ +class NRF52BLEGattMesh : public BLEGattMeshHandler +{ + public: + void start() override; + void stop() override; + /// Register the service. Called from setupMeshService() after the phone-API service. + static void setupService(); + /// Put the service UUID in the scan response. True when it was added (and the name will be + /// shortened to fit beside it); false when the GATT peer protocol is off. + static bool addToScanResponse(); + /// Bluefruit connect/disconnect, for every peripheral link. onDisconnect returns true when the + /// link had subscribed to the mesh characteristic: the phone API's session handling must not run. + static void onConnect(uint16_t conn); + static bool onDisconnect(uint16_t conn); + /// Restart the connectable advertisement if a peripheral slot is free and it is not running. + static void rearmAdvertising(); + + protected: + bool platformReady() override; + size_t platformPeers(BLEGattMeshPeer *out, size_t cap) override; + 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; +}; + +#endif // HAS_BLE_GATT_MESH && ARCH_NRF52 diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 917ae32a16..88ab050645 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -5,6 +5,9 @@ #include "PowerFSM.h" #include "SPILock.h" #include "configuration.h" +#if HAS_BLE_GATT_MESH +#include "NRF52BLEGattMesh.h" +#endif #include "error.h" #include "main.h" #include "mesh/PhoneAPI.h" @@ -74,7 +77,7 @@ static uint8_t toRadioBytes[meshtastic_ToRadio_size]; // Last ToRadio value received from the phone static uint8_t lastToRadio[MAX_TO_FROM_RADIO_SIZE]; -static uint16_t connectionHandle; +static uint16_t connectionHandle = BLE_CONN_HANDLE_INVALID; static bool passkeyShowing; class BluetoothPhoneAPI : public PhoneAPI @@ -87,7 +90,9 @@ class BluetoothPhoneAPI : public PhoneAPI PhoneAPI::onNowHasData(fromRadioNum); LOG_INFO("BLE notify fromNum"); - fromNum.notify32(fromRadioNum); + // The phone's link, not Bluefruit's default (the most recent connect): a mesh peer may be newer. + if (connectionHandle != BLE_CONN_HANDLE_INVALID) + fromNum.notify32(connectionHandle, fromRadioNum); } /// Check the current underlying physical link to see if the client is currently connected @@ -99,11 +104,28 @@ class BluetoothPhoneAPI : public PhoneAPI static BluetoothPhoneAPI *bluetoothPhoneAPI; +#if HAS_BLE_GATT_MESH +// The phone is whichever link uses the phone API, not whichever connected last: with a mesh peer on +// the radio at the same time, connect order says nothing. Called from every phone-API access. +static void notePhoneLink(uint16_t conn_handle) +{ + if (connectionHandle == conn_handle) + return; + connectionHandle = conn_handle; + meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); + bluetoothStatus->updateStatus(&newStatus); +} +#endif + void onConnect(uint16_t conn_handle) { // Get the reference to current connection BLEConnection *connection = Bluefruit.Connection(conn_handle); +#if HAS_BLE_GATT_MESH + NRF52BLEGattMesh::onConnect(conn_handle); +#else connectionHandle = conn_handle; +#endif char central_name[32] = {0}; connection->getPeerName(central_name, sizeof(central_name)); LOG_INFO("BLE Connected to %s", central_name); @@ -118,9 +140,11 @@ void onConnect(uint16_t conn_handle) } #endif +#if !HAS_BLE_GATT_MESH // Notify UI (or any other interested firmware components) meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED); bluetoothStatus->updateStatus(&newStatus); +#endif } /** * Callback invoked when a connection is dropped @@ -129,6 +153,16 @@ void onConnect(uint16_t conn_handle) */ void onDisconnect(uint16_t conn_handle, uint8_t reason) { +#if HAS_BLE_GATT_MESH + NRF52BLEGattMesh::onDisconnect(conn_handle); + // Only the phone's link ends the phone's session; a mesh peer, or a link that never used the + // phone API, dropping must not close it. + if (conn_handle != connectionHandle) { + LOG_INFO("BLE link %u dropped, reason = 0x%x (not the phone's)", conn_handle, reason); + return; + } +#endif + connectionHandle = BLE_CONN_HANDLE_INVALID; LOG_INFO("BLE Disconnected, reason = 0x%x", reason); if (bluetoothPhoneAPI) { bluetoothPhoneAPI->close(); @@ -162,6 +196,9 @@ void onCccd(uint16_t conn_hdl, BLECharacteristic *chr, uint16_t cccd_value) // and cccd value = 0x0002 means indications are enabled if (chr->uuid == fromNum.uuid || chr->uuid == logRadio.uuid) { +#if HAS_BLE_GATT_MESH + notePhoneLink(conn_hdl); +#endif auto result = cccd_value == 2 ? chr->indicateEnabled(conn_hdl) : chr->notifyEnabled(conn_hdl); if (result) { LOG_INFO("Notify/Indicate enabled"); @@ -176,7 +213,12 @@ void startAdv(void) Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE); // IncludeService UUID // Bluefruit.ScanResponse.addService(meshBleService); +#if HAS_BLE_GATT_MESH + if (!NRF52BLEGattMesh::addToScanResponse()) + Bluefruit.ScanResponse.addTxPower(); +#else Bluefruit.ScanResponse.addTxPower(); +#endif Bluefruit.ScanResponse.addName(); // Include Name // Bluefruit.Advertising.addName(); @@ -207,6 +249,9 @@ static void authorizeRead(uint16_t conn_hdl) */ void onFromRadioAuthorize(uint16_t conn_hdl, BLECharacteristic *chr, ble_gatts_evt_read_t *request) { +#if HAS_BLE_GATT_MESH + notePhoneLink(conn_hdl); +#endif if (request->offset == 0) { // If the read is long, we will get multiple authorize invocations - we only populate data on the first size_t numBytes = bluetoothPhoneAPI->getFromRadio(fromRadioBytes); @@ -221,6 +266,9 @@ void onFromRadioAuthorize(uint16_t conn_hdl, BLECharacteristic *chr, ble_gatts_e void onToRadioWrite(uint16_t conn_hdl, BLECharacteristic *chr, uint8_t *data, uint16_t len) { +#if HAS_BLE_GATT_MESH + notePhoneLink(conn_hdl); +#endif LOG_INFO("toRadioWriteCb data %p, len %u", data, len); if (memcmp(lastToRadio, data, len) != 0) { LOG_DEBUG("New ToRadio packet"); @@ -284,6 +332,9 @@ void setupMeshService(void) logRadio.setCccdWriteCallback(onCccd); logRadio.write32(0); logRadio.begin(); +#if HAS_BLE_GATT_MESH + NRF52BLEGattMesh::setupService(); +#endif } static uint32_t configuredPasskey; void NRF52Bluetooth::shutdown() @@ -327,7 +378,12 @@ void NRF52Bluetooth::setup() LOG_INFO("Init the Bluefruit nRF52 module"); Bluefruit.autoConnLed(false); Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); +#if HAS_BLE_GATT_MESH + // Two peripheral links, the phone and one mesh peer, and no central: this node never dials out. + if (!Bluefruit.begin(2, 0)) { +#else if (!Bluefruit.begin()) { +#endif // sd_ble_enable() rejected our RAM base: the linker RAM ORIGIN // (src/platform/nrf52/nrf52840_s140_v*.ld) is below what the SoftDevice needs for the // current Bluefruit config. Without this check the node would silently run without BLE. @@ -561,8 +617,8 @@ void NRF52Bluetooth::sendLog(const uint8_t *logMessage, size_t length) { if (!isConnected() || length > 512) return; - if (logRadio.indicateEnabled()) - logRadio.indicate(logMessage, (uint16_t)length); + if (logRadio.indicateEnabled(connectionHandle)) + logRadio.indicate(connectionHandle, logMessage, (uint16_t)length); else - logRadio.notify(logMessage, (uint16_t)length); + logRadio.notify(connectionHandle, logMessage, (uint16_t)length); } diff --git a/src/platform/nrf52/NRF52Bluetooth.h b/src/platform/nrf52/NRF52Bluetooth.h index 630ab05bc8..2b80211353 100644 --- a/src/platform/nrf52/NRF52Bluetooth.h +++ b/src/platform/nrf52/NRF52Bluetooth.h @@ -22,4 +22,4 @@ class NRF52Bluetooth : BluetoothApi static bool onUnwantedPairing(uint16_t conn_handle, uint8_t const passkey[6], bool match_request); static void disconnect(); -}; \ No newline at end of file +}; diff --git a/test/test_ble_gatt_mesh/BleGattMesh.cpp b/test/test_ble_gatt_mesh/BleGattMesh.cpp new file mode 100644 index 0000000000..6f6c83d893 --- /dev/null +++ b/test/test_ble_gatt_mesh/BleGattMesh.cpp @@ -0,0 +1,822 @@ +#include "DebugConfiguration.h" +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) && HAS_BLE_GATT_MESH + +#include "mesh/BLEGattMeshHandler.h" +#include "mesh/NodeDB.h" +#include "mesh/Router.h" + +#include +#include +#include +#include + +namespace +{ + +/** + * A BLEGattMeshHandler with the BLE stack replaced by a record of what it was asked to do. + * + * Everything worth testing is platform-independent - the fragment framing, the reassembly bounds, the + * ingress guards, and which peer each fragment goes to - so the platform hooks only need to be + * observable, not real. + */ +class FakeGattMesh : public BLEGattMeshHandler +{ + public: + std::vector peers; + std::map>> notified; + std::map>> greeted; + std::vector shed; + std::map probed; + std::vector dead; + std::vector slow; // asked, and never answers: the wait must run out + std::deque>> inbound; + std::vector received; + bool ready = true; + bool busy = false; + int notifyCalls = 0; + + 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); + } + void lost(BLEGattPeerId peer, uint32_t nowMs = 0) { handleChunk(peer, nullptr, 0, nowMs); } + int32_t pump() { return runOnce(); } + size_t pending() const { return pendingAssemblies(); } + + void enqueueReceived(meshtastic_MeshPacket *p) override + { + received.push_back(*p); + packetPool.release(p); + } + + protected: + bool platformReady() override { return ready; } + size_t platformPeers(BLEGattMeshPeer *out, size_t cap) override + { + const size_t n = std::min(cap, peers.size()); + for (size_t i = 0; i < n; i++) + out[i] = peers[i]; + return n; + } + bool platformNotify(BLEGattPeerId peer, const uint8_t *data, size_t len) override + { + // Control chunks are kept apart so every fragment count below stays what the link carries. + if (BLEGattMeshHandler::isControl(data, len)) { + if (busy) + return false; + greeted[peer].emplace_back(data, data + len); + return true; + } + notifyCalls++; + if (busy) + return false; + notified[peer].emplace_back(data, data + len); + return true; + } + void platformShedOutbound(BLEGattPeerId peer) override { shed.push_back(peer); } + bool platformProbeStart(BLEGattPeerId peer) override + { + probed[peer]++; + return true; + } + ProbeAnswer platformProbeAnswer(BLEGattPeerId peer) override + { + if (std::find(slow.begin(), slow.end(), peer) != slow.end()) + return ProbeAnswer::Pending; + return std::find(dead.begin(), dead.end(), peer) == dead.end() ? ProbeAnswer::Answered : ProbeAnswer::Refused; + } + bool platformPollInbound(BLEGattPeerId &peer, uint8_t *buf, size_t cap, size_t &len) override + { + if (inbound.empty()) + return false; + const auto &front = inbound.front(); + peer = front.first; + len = std::min(cap, front.second.size()); + memcpy(buf, front.second.data(), len); + inbound.pop_front(); + return true; + } +}; + +/// A packet in the state Router::send hands to a transport: encrypted, with a real sender. +meshtastic_MeshPacket encryptedPacket(uint32_t from = 0x3061b02e, uint32_t id = 0x04050b6e, size_t payload = 32) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = NODENUM_BROADCAST; + p.id = id; + p.channel = 50; + p.hop_limit = 3; + p.hop_start = 3; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = payload; + for (size_t i = 0; i < payload; i++) + p.encrypted.bytes[i] = (uint8_t)(i * 7 + 1); + return p; +} + +constexpr size_t MAX_ENCRYPTED_FOR_TEST = sizeof(meshtastic_MeshPacket().encrypted.bytes); + +std::vector encode(const meshtastic_MeshPacket &mp) +{ + std::vector out(meshtastic_MeshPacket_size); + const size_t n = pb_encode_to_bytes(out.data(), out.size(), &meshtastic_MeshPacket_msg, &mp); + out.resize(n); + return out; +} + +/// Split a packet the way a client does: one fragment per write at this chunk size. +std::vector> split(const std::vector &packet, uint16_t fragId, uint16_t chunk) +{ + std::vector> out; + const uint8_t total = BLEGattMeshHandler::fragmentCount(packet.size(), chunk); + for (uint8_t i = 0; i < total; i++) { + std::vector frag(chunk); + const size_t n = + BLEGattMeshHandler::buildFragment(packet.data(), packet.size(), fragId, i, total, chunk, frag.data(), frag.size()); + frag.resize(n); + out.push_back(frag); + } + return out; +} + +std::vector reassembleAll(const std::vector> &frags) +{ + std::vector out; + for (const auto &f : frags) + out.insert(out.end(), f.begin() + BLE_GATT_MESH_FRAG_HEADER, f.end()); + return out; +} + +} // namespace + +// --- framing ------------------------------------------------------------------------------------ + +void test_fragment_header_matches_the_client_format(void) +{ + std::vector packet(40, 0xAB); + auto frags = split(packet, 0xBEEF, 25); + + // 25-byte chunks carry 20 bytes of payload, so 40 bytes is exactly two fragments. + TEST_ASSERT_EQUAL(2, frags.size()); + TEST_ASSERT_EQUAL(25, frags[0].size()); + TEST_ASSERT_EQUAL_UINT8(BLE_GATT_MESH_FRAG_VERSION, frags[0][0]); + TEST_ASSERT_EQUAL_UINT8(0xEF, frags[0][1]); // id, little-endian + TEST_ASSERT_EQUAL_UINT8(0xBE, frags[0][2]); + TEST_ASSERT_EQUAL_UINT8(0, frags[0][3]); // index + TEST_ASSERT_EQUAL_UINT8(2, frags[0][4]); // total + TEST_ASSERT_EQUAL_UINT8(1, frags[1][3]); + TEST_ASSERT_EQUAL_UINT8(2, frags[1][4]); + TEST_ASSERT_EQUAL_UINT8_ARRAY(packet.data(), reassembleAll(frags).data(), packet.size()); + + BLEGattMeshHandler::FragmentHeader hdr; + TEST_ASSERT_TRUE(BLEGattMeshHandler::parseFragment(frags[1].data(), frags[1].size(), hdr)); + TEST_ASSERT_EQUAL_UINT16(0xBEEF, hdr.id); + TEST_ASSERT_EQUAL_UINT8(1, hdr.index); + TEST_ASSERT_EQUAL_UINT8(2, hdr.total); +} + +void test_fragment_count_bounds(void) +{ + TEST_ASSERT_EQUAL_UINT8(0, BLEGattMeshHandler::fragmentCount(10, BLE_GATT_MESH_FRAG_HEADER)); // no room for payload + TEST_ASSERT_EQUAL_UINT8(1, BLEGattMeshHandler::fragmentCount(0, 20)); // empty is still one + TEST_ASSERT_EQUAL_UINT8(30, BLEGattMeshHandler::fragmentCount(450, 20)); // 15 bytes each + TEST_ASSERT_EQUAL_UINT8(1, BLEGattMeshHandler::fragmentCount(450, 512)); + // total is one byte, so a packet needing more than 255 pieces cannot be carried at all. + TEST_ASSERT_EQUAL_UINT8(0, BLEGattMeshHandler::fragmentCount(255 * 15 + 1, 20)); + + uint8_t out[32]; + // An index past the end, or a chunk with no payload room, is refused rather than truncated. + TEST_ASSERT_EQUAL(0, BLEGattMeshHandler::buildFragment(out, 10, 1, 1, 1, 20, out, sizeof(out))); + TEST_ASSERT_EQUAL(0, BLEGattMeshHandler::buildFragment(out, 10, 1, 0, 1, 5, out, sizeof(out))); +} + +void test_parse_rejects_foreign_or_contradictory_headers(void) +{ + BLEGattMeshHandler::FragmentHeader hdr; + const uint8_t wrongVersion[] = {2, 0, 0, 0, 1}; + const uint8_t zeroTotal[] = {1, 0, 0, 0, 0}; + const uint8_t indexPastTotal[] = {1, 0, 0, 3, 3}; + const uint8_t tooShort[] = {1, 0, 0, 0}; + TEST_ASSERT_FALSE(BLEGattMeshHandler::parseFragment(wrongVersion, sizeof(wrongVersion), hdr)); + TEST_ASSERT_FALSE(BLEGattMeshHandler::parseFragment(zeroTotal, sizeof(zeroTotal), hdr)); + TEST_ASSERT_FALSE(BLEGattMeshHandler::parseFragment(indexPastTotal, sizeof(indexPastTotal), hdr)); + TEST_ASSERT_FALSE(BLEGattMeshHandler::parseFragment(tooShort, sizeof(tooShort), hdr)); +} + +// --- reassembly --------------------------------------------------------------------------------- + +void test_reassembles_a_fragmented_packet(void) +{ + FakeGattMesh h; + h.start(); + auto p = encryptedPacket(0x3061b02e, 0x04050b6e, 200); + auto frags = split(encode(p), 7, 30); + TEST_ASSERT_TRUE(frags.size() > 5); + + for (size_t i = 0; i < frags.size(); i++) { + h.feed(1, frags[i]); + TEST_ASSERT_EQUAL_MESSAGE(i + 1 == frags.size() ? 1 : 0, h.received.size(), "delivered only once complete"); + } + TEST_ASSERT_EQUAL_UINT32(0x3061b02e, h.received[0].from); + TEST_ASSERT_EQUAL_UINT32(0x04050b6e, h.received[0].id); + TEST_ASSERT_EQUAL(200, h.received[0].encrypted.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(p.encrypted.bytes, h.received[0].encrypted.bytes, 200); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_GATT, h.received[0].transport_mechanism); + TEST_ASSERT_EQUAL_MESSAGE(0, h.pending(), "the slot is released on completion"); +} + +void test_a_full_size_packet_survives_the_smallest_chunk(void) +{ + FakeGattMesh h; + h.start(); + auto p = encryptedPacket(0x3061b02e, 0x04050b6e, MAX_ENCRYPTED_FOR_TEST); + auto frags = split(encode(p), 1, BLE_GATT_MESH_MIN_CHUNK); + for (const auto &f : frags) + h.feed(1, f); + TEST_ASSERT_EQUAL(1, h.received.size()); + TEST_ASSERT_EQUAL(MAX_ENCRYPTED_FOR_TEST, h.received[0].encrypted.size); +} + +void test_single_fragment_completes_without_a_slot(void) +{ + FakeGattMesh h; + h.start(); + auto frags = split(encode(encryptedPacket()), 3, 512); + TEST_ASSERT_EQUAL(1, frags.size()); + h.feed(1, frags[0]); + TEST_ASSERT_EQUAL(1, h.received.size()); + TEST_ASSERT_EQUAL(0, h.pending()); +} + +void test_a_gap_drops_the_assembly(void) +{ + FakeGattMesh h; + h.start(); + auto frags = split(encode(encryptedPacket(0x3061b02e, 1, 100)), 9, 30); + TEST_ASSERT_TRUE(frags.size() >= 3); + + h.feed(1, frags[0]); + TEST_ASSERT_EQUAL(1, h.pending()); + // ATT delivers one peer's writes in order, so a missing index means the packet is lost, not late. + h.feed(1, frags[2]); + TEST_ASSERT_EQUAL_MESSAGE(0, h.pending(), "a gap frees the slot rather than waiting for expiry"); + h.feed(1, frags[1]); + TEST_ASSERT_EQUAL_MESSAGE(0, h.pending(), "a packet cannot start mid-way"); + TEST_ASSERT_EQUAL(0, h.received.size()); +} + +void test_a_repeated_fragment_is_a_no_op(void) +{ + FakeGattMesh h; + h.start(); + auto frags = split(encode(encryptedPacket(0x3061b02e, 2, 40)), 9, 30); + TEST_ASSERT_TRUE(frags.size() >= 2); + h.feed(1, frags[0]); + h.feed(1, frags[0]); // a retrying link re-delivers + for (size_t i = 1; i < frags.size(); i++) + h.feed(1, frags[i]); + TEST_ASSERT_EQUAL(1, h.received.size()); +} + +void test_in_flight_packets_per_peer_are_bounded(void) +{ + FakeGattMesh h; + h.start(); + for (uint16_t id = 1; id <= BLE_GATT_MESH_MAX_IN_FLIGHT_PER_PEER + 2; id++) { + auto frags = split(encode(encryptedPacket(0x3061b02e, id, 40)), id, 30); + h.feed(1, frags[0]); // start, never finish + } + TEST_ASSERT_EQUAL(BLE_GATT_MESH_MAX_IN_FLIGHT_PER_PEER, h.pending()); +} + +void test_peers_holding_slots_are_bounded(void) +{ + FakeGattMesh h; + h.start(); + auto frags = split(encode(encryptedPacket(0x3061b02e, 5, 40)), 5, 30); + for (BLEGattPeerId peer = 1; peer <= BLE_GATT_MESH_MAX_PEERS + 2; peer++) + h.feed(peer, frags[0]); + TEST_ASSERT_EQUAL(BLE_GATT_MESH_MAX_PEERS, h.pending()); +} + +void test_half_built_packets_expire(void) +{ + FakeGattMesh h; + h.start(); + auto frags = split(encode(encryptedPacket(0x3061b02e, 6, 40)), 6, 30); + h.feed(1, frags[0], 1000); + TEST_ASSERT_EQUAL(1, h.pending()); + // The second half arrives after the expiry: the first is gone, and a packet cannot start at index 1. + h.feed(1, frags[1], 1000 + BLE_GATT_MESH_REASSEMBLY_EXPIRY_MS); + TEST_ASSERT_EQUAL(0, h.pending()); + TEST_ASSERT_EQUAL(0, h.received.size()); +} + +void test_a_lost_peer_forgets_its_assemblies(void) +{ + FakeGattMesh h; + h.start(); + auto frags = split(encode(encryptedPacket(0x3061b02e, 8, 40)), 8, 30); + h.feed(1, frags[0]); + h.feed(2, frags[0]); + TEST_ASSERT_EQUAL(2, h.pending()); + h.lost(1); + TEST_ASSERT_EQUAL_MESSAGE(1, h.pending(), "only the departed peer's slot is freed"); +} + +// --- ingress guards ----------------------------------------------------------------------------- + +void test_ingress_drops_a_packet_with_no_sender(void) +{ + FakeGattMesh h; + h.start(); + h.feed(1, split(encode(encryptedPacket(0 /* from */)), 1, 512)[0]); + TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "spoofed origin rejected"); +} + +void test_ingress_drops_a_packet_claiming_to_be_us(void) +{ + FakeGattMesh h; + h.start(); + h.feed(1, split(encode(encryptedPacket(nodeDB->getNodeNum())), 1, 512)[0]); + TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "a peer cannot speak as this node"); +} + +void test_ingress_drops_an_impossible_hop_count(void) +{ + FakeGattMesh h; + h.start(); + auto p = encryptedPacket(); + p.hop_limit = HOP_MAX + 1; + h.feed(1, split(encode(p), 1, 512)[0]); + TEST_ASSERT_EQUAL(0, h.received.size()); +} + +void test_ingress_clears_local_only_metadata(void) +{ + FakeGattMesh h; + h.start(); + auto p = encryptedPacket(); + p.pki_encrypted = true; + p.public_key.size = 32; + p.rx_rssi = -40; + p.has_rx_rssi = true; + p.rx_snr = 9; + h.feed(1, split(encode(p), 1, 512)[0]); + TEST_ASSERT_EQUAL(1, h.received.size()); + TEST_ASSERT_FALSE_MESSAGE(h.received[0].pki_encrypted, "claimed authentication stripped"); + TEST_ASSERT_EQUAL(0, h.received[0].public_key.size); + TEST_ASSERT_FALSE_MESSAGE(h.received[0].has_rx_rssi, "no radio measurement exists for a GATT arrival"); + TEST_ASSERT_EQUAL(0, h.received[0].rx_snr); +} + +void test_ingress_rejects_bytes_that_do_not_decode(void) +{ + FakeGattMesh h; + h.start(); + std::vector garbage(60, 0xFF); + h.feed(1, split(garbage, 1, 512)[0]); + TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "validate before relay"); +} + +// --- egress ------------------------------------------------------------------------------------- + +void test_send_queues_rather_than_notifying(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 512}}; + auto p = encryptedPacket(); + TEST_ASSERT_TRUE(h.onSend(&p)); + TEST_ASSERT_EQUAL_MESSAGE(0, h.notifyCalls, "Router::send must not block on the radio"); + h.pump(); + TEST_ASSERT_EQUAL(1, h.notified[1].size()); +} + +void test_send_fragments_per_peer_chunk_size(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 20}, {2, 200}}; + auto p = encryptedPacket(0x3061b02e, 0x04050b6e, 100); + const auto encoded = encode(p); + TEST_ASSERT_TRUE(h.onSend(&p)); + while (h.pump() == 10) { + } + + const uint8_t small = BLEGattMeshHandler::fragmentCount(encoded.size(), 20); + TEST_ASSERT_TRUE(small > 1); + TEST_ASSERT_EQUAL(small, h.notified[1].size()); + TEST_ASSERT_EQUAL(1, h.notified[2].size()); + for (const auto &f : h.notified[1]) + TEST_ASSERT_TRUE(f.size() <= 20); + + // Both peers can rebuild the same bytes the router handed us. + TEST_ASSERT_EQUAL(encoded.size(), reassembleAll(h.notified[1]).size()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(encoded.data(), reassembleAll(h.notified[1]).data(), encoded.size()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(encoded.data(), reassembleAll(h.notified[2]).data(), encoded.size()); + + // And a receiving handler decodes them into the original packet. + FakeGattMesh rx; + rx.start(); + for (const auto &f : h.notified[1]) + rx.feed(1, f); + TEST_ASSERT_EQUAL(1, rx.received.size()); + TEST_ASSERT_EQUAL_UINT32(p.id, rx.received[0].id); +} + +void test_a_relay_is_not_written_back_to_the_peer_it_came_from(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 512}, {2, 512}}; + // A hop already spent: peer 1 is carrying someone else's packet, not originating it. + auto carried = encryptedPacket(0x3061b02e, 0x1234); + carried.hop_limit = 2; + h.feed(1, split(encode(carried), 1, 512)[0]); + TEST_ASSERT_EQUAL(1, h.received.size()); + + // What perhapsRebroadcast hands back: a copy of the received packet, still marked as it arrived. + meshtastic_MeshPacket relay = h.received[0]; + TEST_ASSERT_TRUE(h.onSend(&relay)); + while (h.pump() == 10) { + } + TEST_ASSERT_EQUAL_MESSAGE(0, h.notified[1].size(), "peer 1 already has this packet"); + TEST_ASSERT_EQUAL_MESSAGE(1, h.notified[2].size(), "peer 2 does not"); +} + +void test_a_relay_echoes_to_the_peer_that_originated_it(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 512}, {2, 512}}; + // hop_start == hop_limit: nothing has relayed it, so peer 1 is the originator and the echo is + // the implicit ack a shared medium would have given it for free. + h.feed(1, split(encode(encryptedPacket(0x3061b02e, 0x1235)), 1, 512)[0]); + TEST_ASSERT_EQUAL(1, h.received.size()); + meshtastic_MeshPacket relay = h.received[0]; + TEST_ASSERT_TRUE(h.onSend(&relay)); + while (h.pump() == 10) { + } + TEST_ASSERT_EQUAL_MESSAGE(1, h.notified[1].size(), "the originator hears its packet relayed"); + TEST_ASSERT_EQUAL_MESSAGE(1, h.notified[2].size(), "and so does everyone else"); +} + +void test_an_origination_reaches_every_peer(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 512}, {2, 512}}; + auto p = encryptedPacket(0x3061b02e, 0x4321); + TEST_ASSERT_TRUE(h.onSend(&p)); + while (h.pump() == 10) { + } + TEST_ASSERT_EQUAL(1, h.notified[1].size()); + TEST_ASSERT_EQUAL(1, h.notified[2].size()); +} + +void test_send_refuses_unencrypted_or_senderless_packets(void) +{ + FakeGattMesh h; + h.start(); + auto plain = encryptedPacket(); + plain.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + TEST_ASSERT_FALSE(h.onSend(&plain)); + auto nobody = encryptedPacket(0 /* from */); + TEST_ASSERT_FALSE(h.onSend(&nobody)); +} + +void test_tx_queue_is_bounded(void) +{ + FakeGattMesh h; + h.start(); + size_t accepted = 0; + for (size_t i = 0; i < BLE_GATT_MESH_TX_QUEUE_SIZE * 3; i++) { + auto p = encryptedPacket(0x3061b02e, (uint32_t)(0x1000 + i)); + if (h.onSend(&p)) + accepted++; + } + TEST_ASSERT_EQUAL(BLE_GATT_MESH_TX_QUEUE_SIZE, accepted); +} + +void test_a_busy_stack_is_retried_then_skipped(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 512}}; + h.busy = true; + auto p = encryptedPacket(); + TEST_ASSERT_TRUE(h.onSend(&p)); + + for (int i = 0; i < BLE_GATT_MESH_TX_ATTEMPTS - 1; i++) + TEST_ASSERT_EQUAL_MESSAGE(10, h.pump(), "still retrying the same fragment"); + TEST_ASSERT_EQUAL(BLE_GATT_MESH_TX_ATTEMPTS - 1, h.notifyCalls); + + h.busy = false; + h.pump(); + TEST_ASSERT_EQUAL_MESSAGE(1, h.notified[1].size(), "delivered once the stack accepts"); + + // A peer that never accepts is skipped after the budget, so it cannot stall the ring forever. + h.busy = true; + h.notifyCalls = 0; + auto q = encryptedPacket(0x3061b02e, 0x99); + TEST_ASSERT_TRUE(h.onSend(&q)); + for (int i = 0; i < BLE_GATT_MESH_TX_ATTEMPTS; i++) + h.pump(); + TEST_ASSERT_EQUAL(BLE_GATT_MESH_TX_ATTEMPTS, h.notifyCalls); + h.busy = false; + h.pump(); + TEST_ASSERT_EQUAL_MESSAGE(1, h.notified[1].size(), "the skipped packet is not resurrected"); +} + +void test_pump_waits_for_the_platform(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 512}}; + h.ready = false; + auto p = encryptedPacket(); + TEST_ASSERT_TRUE(h.onSend(&p)); + h.pump(); + TEST_ASSERT_EQUAL(0, h.notifyCalls); + h.ready = true; + h.pump(); + TEST_ASSERT_EQUAL(1, h.notified[1].size()); +} + +// --- link control ------------------------------------------------------------------------------ + +std::vector helloWith(uint8_t fill) +{ + std::vector h(BLE_GATT_MESH_HELLO_SIZE, fill); + h[0] = BLE_GATT_MESH_CTRL_MARKER; + h[1] = BLE_GATT_MESH_CTRL_HELLO; + return h; +} + +void test_greets_each_peer_once(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 512}, {2, 20}}; + h.pump(); + h.pump(); + TEST_ASSERT_EQUAL(1, h.greeted[1].size()); + TEST_ASSERT_EQUAL(1, h.greeted[2].size()); + TEST_ASSERT_EQUAL(BLE_GATT_MESH_HELLO_SIZE, h.greeted[1][0].size()); + TEST_ASSERT_EQUAL_HEX8(BLE_GATT_MESH_CTRL_MARKER, h.greeted[1][0][0]); + TEST_ASSERT_EQUAL_HEX8(BLE_GATT_MESH_CTRL_HELLO, h.greeted[1][0][1]); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(h.greeted[1][0].data(), h.greeted[2][0].data(), BLE_GATT_MESH_HELLO_SIZE, + "one id on every link"); + TEST_ASSERT_EQUAL_MESSAGE(0, h.notifyCalls, "a greeting is not a fragment"); +} + +void test_a_refused_greeting_is_offered_again(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 512}}; + h.busy = true; + h.pump(); + TEST_ASSERT_EQUAL(0, h.greeted[1].size()); + h.busy = false; + h.pump(); + TEST_ASSERT_EQUAL(1, h.greeted[1].size()); +} + +void test_a_hello_in_is_never_a_fragment(void) +{ + FakeGattMesh h; + h.start(); + h.feed(1, helloWith(0x42)); + std::vector unknown = {BLE_GATT_MESH_CTRL_MARKER, 0x7f, 1, 2, 3}; + h.feed(1, unknown); + TEST_ASSERT_EQUAL(0, h.pending()); + TEST_ASSERT_EQUAL(0, h.received.size()); +} + +void test_the_loser_sheds_a_node_reached_both_ways(void) +{ + FakeGattMesh h; + h.start(); + // An id of all 0xff is above any random one, all 0x00 below it: the peer wins, then loses. + h.feed(1, helloWith(0x00)); + h.feed(2, helloWith(0x00)); + TEST_ASSERT_EQUAL_MESSAGE(2, h.shed.size(), "below us on both links: this node loses and sheds"); + h.shed.clear(); + h.feed(3, helloWith(0xff)); + h.feed(4, helloWith(0xff)); + TEST_ASSERT_EQUAL_MESSAGE(0, h.shed.size(), "above us: the winner keeps its links"); + // The shed is asynchronous on hardware - the disconnect arrives later as a zero-length chunk - so + // the names stay until then. Once both links are gone the name is forgotten, and a fresh pair of + // links carrying it is a new election. + h.lost(1); + h.lost(2); + h.feed(5, helloWith(0x00)); + TEST_ASSERT_EQUAL_MESSAGE(0, h.shed.size(), "a lost link's name is forgotten"); + h.feed(6, helloWith(0x00)); + 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]); + h.liveness(30010); // the refusal is read on the next pump, never on the one that asked + 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(0, h.shed.size(), "asked again, not yet answered"); + h.liveness(32005); + 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.liveness(30010); // the miss is read + h.dead.clear(); + h.liveness(32000); + TEST_ASSERT_EQUAL(2, h.probed[1]); + h.liveness(32010); + 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_a_probe_nobody_answers_is_a_miss_after_the_wait(void) +{ + FakeGattMesh h; + h.start(); + h.peers = {{1, 244, true}}; + h.slow = {1}; + h.liveness(0); + h.liveness(30000); + TEST_ASSERT_EQUAL(1, h.probed[1]); + h.liveness(31000); + TEST_ASSERT_EQUAL_MESSAGE(1, h.probed[1], "a second in: still waiting, and not asked again"); + TEST_ASSERT_EQUAL(0, h.shed.size()); + h.liveness(31600); + TEST_ASSERT_EQUAL_MESSAGE(1, h.probed[1], "past the wait: a miss, and the retry waits its two seconds"); + h.liveness(32000); + TEST_ASSERT_EQUAL_MESSAGE(2, h.probed[1], "the retry goes out two seconds after the ask"); + h.liveness(33600); + TEST_ASSERT_EQUAL_MESSAGE(1, h.shed.size(), "silence twice: shed"); +} + +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; + h.start(); + auto frags = split(encode(encryptedPacket(0x3061b02e, 0x77, 60)), 4, 40); + for (const auto &f : frags) + h.inbound.emplace_back(1, f); + h.pump(); + TEST_ASSERT_EQUAL(1, h.received.size()); + TEST_ASSERT_TRUE(h.inbound.empty()); +} + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + // deliverToRouter consults nodeDB to drop a peer claiming our own node number. + if (!nodeDB) + nodeDB = new NodeDB(); + UNITY_BEGIN(); + RUN_TEST(test_fragment_header_matches_the_client_format); + RUN_TEST(test_fragment_count_bounds); + RUN_TEST(test_parse_rejects_foreign_or_contradictory_headers); + RUN_TEST(test_reassembles_a_fragmented_packet); + RUN_TEST(test_a_full_size_packet_survives_the_smallest_chunk); + RUN_TEST(test_single_fragment_completes_without_a_slot); + RUN_TEST(test_a_gap_drops_the_assembly); + RUN_TEST(test_a_repeated_fragment_is_a_no_op); + RUN_TEST(test_in_flight_packets_per_peer_are_bounded); + RUN_TEST(test_peers_holding_slots_are_bounded); + RUN_TEST(test_half_built_packets_expire); + RUN_TEST(test_a_lost_peer_forgets_its_assemblies); + RUN_TEST(test_ingress_drops_a_packet_with_no_sender); + RUN_TEST(test_ingress_drops_a_packet_claiming_to_be_us); + RUN_TEST(test_ingress_drops_an_impossible_hop_count); + RUN_TEST(test_ingress_clears_local_only_metadata); + RUN_TEST(test_ingress_rejects_bytes_that_do_not_decode); + RUN_TEST(test_send_queues_rather_than_notifying); + RUN_TEST(test_send_fragments_per_peer_chunk_size); + RUN_TEST(test_a_relay_is_not_written_back_to_the_peer_it_came_from); + RUN_TEST(test_a_relay_echoes_to_the_peer_that_originated_it); + RUN_TEST(test_an_origination_reaches_every_peer); + RUN_TEST(test_send_refuses_unencrypted_or_senderless_packets); + RUN_TEST(test_tx_queue_is_bounded); + RUN_TEST(test_a_busy_stack_is_retried_then_skipped); + RUN_TEST(test_pump_waits_for_the_platform); + RUN_TEST(test_inbound_chunks_are_drained_by_the_pump); + RUN_TEST(test_greets_each_peer_once); + 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_a_probe_nobody_answers_is_a_miss_after_the_wait); + RUN_TEST(test_an_inbound_link_is_never_probed); + exit(UNITY_END()); +} + +#else + +void setup() +{ + initializeTestEnvironment(); + LOG_WARN("BLE GATT mesh tests require ARCH_PORTDUINO with HAS_BLE_GATT_MESH"); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +#endif // ARCH_PORTDUINO && HAS_BLE_GATT_MESH + +void loop() {} diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index ae5569afce..08cc836981 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -11,6 +11,11 @@ build_flags = ${portduino_base.build_flags} -I variants/native/portduino ; flags to whatever line ends this value. Landing them on a `!` line makes them trailing ; arguments to echo or to `:` instead of compiler flags, and they vanish with no error. !pkg-config --exists --silence-errors libnotify && pkg-config --cflags --libs libnotify && echo -D HAS_LIBNOTIFY=1 || : + ; Compiles BLEGattMeshHandler, the GATT mesh-peer transport's protocol half: framing, the ingress + ; guards, the rings. It has no platform BLE dependency (the NRF52 subclass does), so building it + ; here is what lets test_ble_gatt_mesh run under the native suite. Nothing runs at runtime: main() + ; instantiates no platform subclass on portduino, so bleGattMeshHandler stays null. + -DHAS_BLE_GATT_MESH=1 -I /usr/include board = cross_platform board_level = extra