mirror of
https://github.com/meshtastic/firmware.git
synced 2026-11-07 23:04:49 -05:00
Add the BLE GATT mesh-peer transport: a phone connects to the node as a mesh peer
A phone can already reach a node's phone API over BLE, but it cannot be a node on the mesh through that link: the phone API carries commands and FromRadio state, not mesh frames. This adds a second GATT service, the mesh-peer service, on which a client library (node-transport-ble-gatt) exchanges whole encrypted MeshPackets with the node - the SIG Mesh "GATT proxy" role. The node stays the GATT server; the phone is a central that subscribes to the mesh characteristic and writes fragments to it. BLEGattMeshHandler is the platform-neutral half and holds everything the client library speaks, so the native suite covers it: the fragment framing ([version][id][index][total][payload], one ATT value per fragment), bounded reassembly (per-peer in-flight limit, per-table peer limit, expiry, so a hostile writer cannot fill memory), the ingress guards shared with the UDP transport (no sender, our own node number, out-of-range hop counts, local-only flags cleared), a relay exclusion so a packet is never written back to the peer that delivered it unless that peer originated it (the echo is its only implicit ack on a point-to-point bearer), the HELLO link-control frame that names a node on its GATT links so a node reached both ways can elect one link, and a liveness probe for a link this node dialled. No platform here dials yet, so the probe and shed hooks keep their base-class defaults; the dial-out half is a later change. It registers as a PostEncode transport, gated on ProtocolFlags.BLE_GATT_PEER, and tags its arrivals TRANSPORT_BLE_GATT. NRF52BLEGattMesh is the Bluefruit half: the service and characteristic, CCCD tracking (a link becomes a mesh peer when it subscribes), a write queue from Bluefruit's callback task to the main-task pump, per-connection MTU chunking, and re-arming the connectable advertisement while a peripheral slot is free, which Bluefruit only does by itself once no link is left. Bluefruit.begin(2, 0): two peripheral links, the phone and one mesh peer, no central. NRF52Bluetooth has to stop assuming one link. The phone is whichever link uses the phone API, not the most recent connect, so notePhoneLink() learns it from the first phone-API access and only that link's drop closes the phone session; fromNum and logRadio notify that handle explicitly rather than Bluefruit's default. The mesh-peer UUID rides in the scan response in place of the TX power entry, since S140 has one advertising set. The nanopb enums come from the protobufs branch this submodule now points at; nothing under src/mesh/generated/ is committed, the regen workflow supplies it once that PR merges (bin/regen-protos.sh locally until then). Native tests in test_ble_gatt_mesh, built by the portduino env's new HAS_BLE_GATT_MESH flag: framing and its bounds, reassembly, every ingress guard, per-peer fragmentation, the relay exclusion and its originator exception, the bounded TX ring and busy-stack retry, the greeting, the election, and the probe timings.
This commit is contained in:
1 parent
fd4e7a8b8f
commit
36ca64af16
10 files changed
+2105
-6
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
@@ -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<graphics::Screen> screen;
|
||||
|
||||
@@ -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<BLEGattMeshPeer, BLE_GATT_MESH_MAX_PEERS> 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<BLEGattMeshPeer, BLE_GATT_MESH_MAX_PEERS> 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<BLEGattMeshPeer, BLE_GATT_MESH_MAX_PEERS> 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<uint16_t>(std::max<uint16_t>(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
|
||||
@@ -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 <array>
|
||||
|
||||
// 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<uint8_t, meshtastic_MeshPacket_size> data;
|
||||
};
|
||||
std::array<Assembly, BLE_GATT_MESH_MAX_PEERS * BLE_GATT_MESH_MAX_IN_FLIGHT_PER_PEER> 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<Arrival, BLE_GATT_MESH_RECENT_ARRIVALS> 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<Greeting, BLE_GATT_MESH_MAX_PEERS * 2> 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<uint8_t, meshtastic_MeshPacket_size> data;
|
||||
uint16_t len;
|
||||
uint16_t fragId;
|
||||
BLEGattPeerId exclude;
|
||||
};
|
||||
std::array<TxSlot, BLE_GATT_MESH_TX_QUEUE_SIZE> 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<BLEGattMeshPeer, BLE_GATT_MESH_MAX_PEERS> 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
|
||||
@@ -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 <array>
|
||||
#include <bluefruit.h>
|
||||
|
||||
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<Link, 4> links{};
|
||||
|
||||
struct RxChunk {
|
||||
uint16_t conn;
|
||||
uint16_t len; // 0 marks a disconnect
|
||||
uint8_t data[BLE_GATT_MESH_MAX_CHUNK];
|
||||
};
|
||||
std::array<RxChunk, BLE_GATT_MESH_RX_QUEUE_SIZE> 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<size_t>(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
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#if HAS_BLE_GATT_MESH && defined(ARCH_NRF52)
|
||||
|
||||
#include "mesh/BLEGattMeshHandler.h"
|
||||
|
||||
#include <bluefruit.h>
|
||||
|
||||
// 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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,822 @@
|
||||
#include "DebugConfiguration.h"
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#if defined(ARCH_PORTDUINO) && HAS_BLE_GATT_MESH
|
||||
|
||||
#include "mesh/BLEGattMeshHandler.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/Router.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
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<BLEGattMeshPeer> peers;
|
||||
std::map<BLEGattPeerId, std::vector<std::vector<uint8_t>>> notified;
|
||||
std::map<BLEGattPeerId, std::vector<std::vector<uint8_t>>> greeted;
|
||||
std::vector<BLEGattPeerId> shed;
|
||||
std::map<BLEGattPeerId, int> probed;
|
||||
std::vector<BLEGattPeerId> dead;
|
||||
std::vector<BLEGattPeerId> slow; // asked, and never answers: the wait must run out
|
||||
std::deque<std::pair<BLEGattPeerId, std::vector<uint8_t>>> inbound;
|
||||
std::vector<meshtastic_MeshPacket> 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<uint8_t> &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<uint8_t> encode(const meshtastic_MeshPacket &mp)
|
||||
{
|
||||
std::vector<uint8_t> 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<std::vector<uint8_t>> split(const std::vector<uint8_t> &packet, uint16_t fragId, uint16_t chunk)
|
||||
{
|
||||
std::vector<std::vector<uint8_t>> out;
|
||||
const uint8_t total = BLEGattMeshHandler::fragmentCount(packet.size(), chunk);
|
||||
for (uint8_t i = 0; i < total; i++) {
|
||||
std::vector<uint8_t> 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<uint8_t> reassembleAll(const std::vector<std::vector<uint8_t>> &frags)
|
||||
{
|
||||
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> helloWith(uint8_t fill)
|
||||
{
|
||||
std::vector<uint8_t> 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<uint8_t> 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() {}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user