Files
firmware/test/test_ble_mesh/BleMesh.cpp
T
James Rich 7e7248f6b3 Join an ESP32 advertisement the controller splits across scan reports
An HCI LE Extended Advertising Report carries at most 229 bytes of data, so
the controller hands a longer advertisement up as several reports from the
same address and SID, each but the last INCOMPLETE. NimBLE passes them
through one by one. handleExtendedAdvertisement() dropped every INCOMPLETE
report on the belief that nothing chains on send, then parsed the COMPLETE
tail as if it were the whole advertisement, so an ESP32 heard no packet over
221 bytes. That belief was wrong twice: a 251-byte advertisement is more than
the 245 one AUX_ADV_IND holds, so the Link Layer chains it, and a single PDU
over 229 bytes is split at HCI anyway.

BLEMeshHandler::onScanReport() now joins reports keyed on (address type,
address, SID): INCOMPLETE starts or extends a buffer, COMPLETE finishes it
and delivers once, TRUNCATED discards it. Memory is bounded at
BLE_MESH_REASSEMBLY_SLOTS buffers of BLE_MESH_ADV_TOTAL_MAX; a further
advertiser evicts the stalest, anything longer than a mesh advertisement is
skipped to its last report, and HCI marks no first report, so a buffer idle
for BLE_MESH_REASSEMBLY_TIMEOUT_MS is abandoned rather than prefixed to the
next advertisement.

The AD parser moves into BLEMeshHandler beside it, shared with nRF52. The
nRF52 scan path needs no join: S140 does not support report_incomplete_evts,
assembles a chained advertisement into the 255-byte scan buffer itself, and
reports it COMPLETE, TRUNCATED or MISSED, of which only COMPLETE was ever
parsed.

The comments that called 251 bytes one unfragmented PDU now say what it is:
the most one HCI Set Extended Advertising Data command carries.
2026-10-12 19:34:28 -05:00

693 lines
26 KiB
C++

#include "DebugConfiguration.h"
#include "TestUtil.h"
#include <unity.h>
#if defined(ARCH_PORTDUINO) && HAS_BLE_MESH
#include "UptimeClock.h"
#include "mesh/BLEMeshHandler.h"
#include "mesh/NodeDB.h"
#include "mesh/Router.h"
#include <vector>
namespace
{
/// A BLEMeshHandler with the radio replaced by a record of what it was asked to send.
///
/// What is under test is platform-independent - the advertisement the transport builds and the
/// guards it applies to what it receives - so the platform hooks need only be observable, not real.
class FakeBLEMesh : public BLEMeshHandler
{
public:
std::vector<std::vector<uint8_t>> sent;
std::vector<meshtastic_MeshPacket> received;
bool ready = true;
bool advertising = false;
using BLEMeshHandler::ScanReportStatus;
void start() override { isRunning = true; }
void stop() override { isRunning = false; }
void feed(const uint8_t *data, size_t len, int8_t rssi) { deliverToRouter(data, len, rssi); }
bool cancel(meshtastic_MeshPacket_TransportMechanism medium, NodeNum from, PacketId id)
{
return onCancelSending(medium, from, id);
}
uint8_t build(const meshtastic_MeshPacket *mp, uint8_t *out, size_t cap) { return buildAdvPayload(mp, out, cap); }
void report(uint8_t sid, ScanReportStatus status, const uint8_t *data, size_t len, const uint8_t *addr = defaultAddr)
{
onScanReport(0 /* public */, addr, sid, status, data, len, -60);
}
static constexpr uint8_t defaultAddr[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
int32_t pump() { return runOnce(); }
void enqueueReceived(meshtastic_MeshPacket *p) override
{
received.push_back(*p);
packetPool.release(p);
}
protected:
bool platformBeginAdvertising(const uint8_t *adv, size_t len) override
{
sent.emplace_back(adv, adv + len);
advertising = true;
return true;
}
bool platformAdvertisingActive() override { return advertising; }
void platformEndAdvertising() override { advertising = false; }
bool platformReady() override { return ready; }
};
/// 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;
return p;
}
/// A packet at a chosen priority, for the queue-order tests.
meshtastic_MeshPacket packetAt(meshtastic_MeshPacket_Priority priority, uint32_t id)
{
meshtastic_MeshPacket p = encryptedPacket(0x3061b02e, id);
p.priority = priority;
return p;
}
/// What Router::send actually hands a transport, as opposed to the minimal fixture above.
///
/// fixPriority() never leaves priority UNSET and FloodingRouter::send stamps relay_node on
/// everything sent; a relay also carries transport_mechanism and the reception metadata it arrived
/// with. Measuring the ceiling against the minimal fixture measures the fixture, not the bearer.
meshtastic_MeshPacket productionPacket(size_t payload = 32, bool relayed = false)
{
meshtastic_MeshPacket p = encryptedPacket(0x3061b02e, 0x04050b6e, payload);
p.priority = meshtastic_MeshPacket_Priority_DEFAULT;
p.relay_node = 0x2e;
if (relayed) {
p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
p.rx_rssi = -113;
p.has_rx_rssi = true;
p.rx_snr = -7.25f;
p.rx_time = 1757808000;
p.has_rx_time = true;
}
return p;
}
/// Largest ciphertext a MeshPacket can carry, i.e. one guaranteed not to fit an advertisement.
constexpr size_t MAX_ENCRYPTED_FOR_TEST = sizeof(meshtastic_MeshPacket().encrypted.bytes);
/// Encode `mp` the way the transport does, so ingress tests have a real advertisement body.
size_t encodeForAir(const meshtastic_MeshPacket &mp, uint8_t *out, size_t cap)
{
return pb_encode_to_bytes(out, cap, &meshtastic_MeshPacket_msg, &mp);
}
/// Build a whole advertisement of exactly `total` bytes, the size an HCI split is measured against.
size_t advertisementOf(FakeBLEMesh &h, size_t total, uint32_t id, uint8_t *out, meshtastic_MeshPacket *sent = nullptr)
{
for (size_t payload = 1; payload <= MAX_ENCRYPTED_FOR_TEST; payload++) {
auto p = encryptedPacket(0x3061b02e, id, payload);
if (h.build(&p, out, BLE_MESH_ADV_TOTAL_MAX) == total) {
if (sent)
*sent = p;
return total;
}
}
return 0;
}
using Status = FakeBLEMesh::ScanReportStatus;
} // namespace
void test_advertisement_carries_the_packet(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket();
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
uint8_t len = h.build(&p, adv, sizeof(adv));
TEST_ASSERT_TRUE_MESSAGE(len > BLE_MESH_ADV_OVERHEAD, "built an advertisement");
// Flags AD, then manufacturer-specific data with the company ID and protocol version.
TEST_ASSERT_EQUAL_UINT8(2, adv[0]);
TEST_ASSERT_EQUAL_UINT8(0x01, adv[1]);
TEST_ASSERT_EQUAL_UINT8(0xFF, adv[4]);
TEST_ASSERT_EQUAL_UINT8(BLE_MESH_COMPANY_ID & 0xFF, adv[5]);
TEST_ASSERT_EQUAL_UINT8((BLE_MESH_COMPANY_ID >> 8) & 0xFF, adv[6]);
TEST_ASSERT_EQUAL_UINT8(BLE_MESH_PROTOCOL_VERSION, adv[7]);
// The AD length byte counts everything after itself.
TEST_ASSERT_EQUAL_UINT8(len - 4, adv[3]);
}
void test_refuses_an_unencrypted_packet(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket();
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
// Router::send encrypts before any transport sees a packet, so plaintext here would put a
// readable message on air.
TEST_ASSERT_EQUAL_UINT8(0, h.build(&p, adv, sizeof(adv)));
}
void test_refuses_a_packet_with_no_sender(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket(0 /* from */);
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL_UINT8(0, h.build(&p, adv, sizeof(adv)));
}
void test_drops_a_packet_too_large_for_one_advertisement(void)
{
FakeBLEMesh h;
h.start();
// One advertisement holds 251 bytes, one HCI Set Extended Advertising Data command, so the
// largest packets cannot ride BLE. They still go out over LoRa: Router::send has already handed
// them to the radio by the time the transport refuses.
auto p = encryptedPacket(0x3061b02e, 0x04050b6e, MAX_ENCRYPTED_FOR_TEST);
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL_UINT8(0, h.build(&p, adv, sizeof(adv)));
}
/// The largest ciphertext that still fits one advertisement, for a given packet shape.
///
/// BLE_MESH_MAX_PROTO_LEN is the budget for the *whole* encoded MeshPacket, so the answer is that
/// minus whatever envelope the packet carries, and differs per shape.
size_t largestCiphertextThatFits(meshtastic_MeshPacket shape)
{
FakeBLEMesh h;
h.start();
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
size_t lo = 0, hi = MAX_ENCRYPTED_FOR_TEST;
while (lo < hi) {
size_t mid = lo + (hi - lo + 1) / 2;
shape.encrypted.size = mid;
if (h.build(&shape, adv, sizeof(adv)) > 0)
lo = mid;
else
hi = mid - 1;
}
return lo;
}
void test_the_advertisement_ceiling_is_below_the_lora_ceiling(void)
{
const size_t fits = largestCiphertextThatFits(productionPacket());
// One extended advertisement cannot hold what one LoRa frame holds, and nothing fragments: the
// nRF52 SoftDevice caps the advertising data and the scan buffer at 255 in either direction.
// Everything above this rides LoRa only, counted by txDroppedTooLarge.
// relay_node costs 3 of the budget (field 19, so a two-byte tag); priority costs nothing,
// because stripForTransmit drops it.
TEST_ASSERT_EQUAL_size_t(216, fits);
TEST_ASSERT_LESS_THAN_size_t_MESSAGE(MAX_RADIO_PAYLOAD_LEN, fits, "BLE carries less than LoRa");
}
void test_relaying_no_longer_costs_budget(void)
{
// A relay reaches exactly as far as an originator: the rx_rssi, rx_snr and rx_time it arrived
// with are stripped on egress, since the receiver overwrites all three anyway.
TEST_ASSERT_EQUAL_size_t(largestCiphertextThatFits(productionPacket()),
largestCiphertextThatFits(productionPacket(32, true)));
}
void test_the_air_copy_drops_everything_the_receiver_overwrites(void)
{
FakeBLEMesh h;
h.start();
auto p = productionPacket(32, true);
p.via_mqtt = true;
p.tx_after = 4242;
p.pki_encrypted = true;
p.public_key.size = 32;
for (size_t i = 0; i < 32; i++)
p.public_key.bytes[i] = (uint8_t)(0xA0 + i);
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
const uint8_t len = h.build(&p, adv, sizeof(adv));
TEST_ASSERT_TRUE(len > BLE_MESH_ADV_OVERHEAD);
meshtastic_MeshPacket air = meshtastic_MeshPacket_init_zero;
TEST_ASSERT_TRUE(
pb_decode_from_bytes(adv + BLE_MESH_ADV_OVERHEAD, len - BLE_MESH_ADV_OVERHEAD, &meshtastic_MeshPacket_msg, &air));
// Exactly the set deliverToRouter rewrites, plus rx_time which Router::handleReceived stamps.
// Sending them costs budget for bytes the far side discards.
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_INTERNAL, air.transport_mechanism);
TEST_ASSERT_EQUAL_UINT32(0, air.tx_after);
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_Priority_UNSET, air.priority);
TEST_ASSERT_FALSE(air.pki_encrypted);
TEST_ASSERT_EQUAL_UINT16(0, air.public_key.size);
TEST_ASSERT_FALSE(air.has_rx_rssi);
TEST_ASSERT_FALSE(air.has_rx_time);
TEST_ASSERT_EQUAL_FLOAT(0.0f, air.rx_snr);
// And nothing routing needs went with them.
TEST_ASSERT_TRUE_MESSAGE(air.via_mqtt, "the receiver keeps via_mqtt, as from a LoRa header");
TEST_ASSERT_EQUAL_UINT32(p.from, air.from);
TEST_ASSERT_EQUAL_UINT32(p.to, air.to);
TEST_ASSERT_EQUAL_UINT32(p.id, air.id);
TEST_ASSERT_EQUAL_UINT8(p.hop_limit, air.hop_limit);
TEST_ASSERT_EQUAL_UINT8(p.hop_start, air.hop_start);
TEST_ASSERT_EQUAL_UINT8(p.relay_node, air.relay_node);
TEST_ASSERT_EQUAL_UINT32(p.channel, air.channel);
TEST_ASSERT_EQUAL_UINT16(p.encrypted.size, air.encrypted.size);
}
void test_an_oversized_packet_is_counted_not_just_logged(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket(0x3061b02e, 0x04050b6e, MAX_ENCRYPTED_FOR_TEST);
TEST_ASSERT_FALSE(h.onSend(&p));
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, h.txDroppedTooLarge, "the loss is countable");
}
void test_an_urgent_frame_overtakes_one_already_queued(void)
{
FakeBLEMesh h;
h.start();
auto background = packetAt(meshtastic_MeshPacket_Priority_BACKGROUND, 0x11111111);
auto ack = packetAt(meshtastic_MeshPacket_Priority_ACK, 0x22222222);
TEST_ASSERT_TRUE(h.onSend(&background));
TEST_ASSERT_TRUE(h.onSend(&ack));
// Extended advertising is set-and-repeat, so whatever leaves first holds the radio for a whole
// burst: arrival order would put a position update ahead of an ack a sender is timing out on.
uint8_t expected[BLE_MESH_ADV_TOTAL_MAX];
const uint8_t len = h.build(&ack, expected, sizeof(expected));
h.pump();
TEST_ASSERT_EQUAL_MESSAGE(1, h.sent.size(), "one frame on air");
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, h.sent[0].data(), len);
}
void test_equal_priorities_leave_in_arrival_order(void)
{
FakeBLEMesh h;
h.start();
auto first = packetAt(meshtastic_MeshPacket_Priority_DEFAULT, 0x11111111);
auto second = packetAt(meshtastic_MeshPacket_Priority_DEFAULT, 0x22222222);
TEST_ASSERT_TRUE(h.onSend(&first));
TEST_ASSERT_TRUE(h.onSend(&second));
uint8_t expected[BLE_MESH_ADV_TOTAL_MAX];
const uint8_t len = h.build(&first, expected, sizeof(expected));
// Priority orders the queue; it does not reorder within a priority.
h.pump();
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, h.sent[0].data(), len);
}
void test_a_full_queue_makes_room_only_for_something_more_important(void)
{
FakeBLEMesh h;
h.start();
for (size_t i = 0; i < BLE_MESH_TX_QUEUE_SIZE; i++) {
auto p = packetAt(meshtastic_MeshPacket_Priority_BACKGROUND, (uint32_t)(0x1000 + i));
TEST_ASSERT_TRUE(h.onSend(&p));
}
// Full of the least important work there is, so an ack displaces one.
auto ack = packetAt(meshtastic_MeshPacket_Priority_ACK, 0x22222222);
TEST_ASSERT_TRUE_MESSAGE(h.onSend(&ack), "an ack gets in");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, h.txDroppedQueueFull, "and the displacement is counted");
// Full again, and an equal has nothing to displace: shuffling equals only changes which packet
// is lost.
auto peer = packetAt(meshtastic_MeshPacket_Priority_BACKGROUND, 0x33333333);
TEST_ASSERT_FALSE_MESSAGE(h.onSend(&peer), "an equal is refused");
TEST_ASSERT_EQUAL_UINT32(2, h.txDroppedQueueFull);
uint8_t expected[BLE_MESH_ADV_TOTAL_MAX];
const uint8_t len = h.build(&ack, expected, sizeof(expected));
h.pump();
TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, h.sent[0].data(), len);
}
void test_the_frame_displaced_is_the_newest_of_the_least_important(void)
{
FakeBLEMesh h;
h.start();
auto oldest = packetAt(meshtastic_MeshPacket_Priority_BACKGROUND, 0x11111111);
TEST_ASSERT_TRUE(h.onSend(&oldest));
for (size_t i = 1; i < BLE_MESH_TX_QUEUE_SIZE; i++) {
auto p = packetAt(meshtastic_MeshPacket_Priority_BACKGROUND, (uint32_t)(0x2000 + i));
TEST_ASSERT_TRUE(h.onSend(&p));
}
auto ack = packetAt(meshtastic_MeshPacket_Priority_ACK, 0x33333333);
TEST_ASSERT_TRUE(h.onSend(&ack));
// The displaced slot is the newest of the least important, not the first one found: a frame
// that has already waited its turn is not the one to throw away.
uint8_t expectedAck[BLE_MESH_ADV_TOTAL_MAX];
uint8_t expectedOldest[BLE_MESH_ADV_TOTAL_MAX];
const uint8_t ackLen = h.build(&ack, expectedAck, sizeof(expectedAck));
const uint8_t oldestLen = h.build(&oldest, expectedOldest, sizeof(expectedOldest));
h.pump();
h.advertising = false;
h.pump();
TEST_ASSERT_EQUAL_MESSAGE(2, h.sent.size(), "two frames went out");
TEST_ASSERT_EQUAL_UINT8_ARRAY(expectedAck, h.sent[0].data(), ackLen);
TEST_ASSERT_EQUAL_UINT8_ARRAY(expectedOldest, h.sent[1].data(), oldestLen);
}
void test_a_dupe_heard_on_ble_cancels_our_queued_copy(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket(0x3061b02e, 0x04050b6e);
TEST_ASSERT_TRUE(h.onSend(&p));
// One advertisement reaches every neighbour at once, so a neighbour's relay has already done
// this node's work.
TEST_ASSERT_TRUE(h.cancel(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV, p.from, p.id));
h.pump();
TEST_ASSERT_EQUAL_MESSAGE(0, h.sent.size(), "nothing went out");
}
void test_a_dupe_heard_on_lora_leaves_the_ble_queue_alone(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket(0x3061b02e, 0x04050b6e);
TEST_ASSERT_TRUE(h.onSend(&p));
// A LoRa neighbour's relay says nothing about whether the BLE neighbours have it; cancelling
// here would thin the BLE flood wherever the two meshes overlap.
TEST_ASSERT_FALSE(h.cancel(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA, p.from, p.id));
h.pump();
TEST_ASSERT_EQUAL_MESSAGE(1, h.sent.size(), "still advertised");
}
void test_canceling_keeps_the_other_queued_frames_in_order(void)
{
FakeBLEMesh h;
h.start();
auto first = encryptedPacket(0x3061b02e, 0x11111111);
auto doomed = encryptedPacket(0x3061b02e, 0x22222222);
auto last = encryptedPacket(0x3061b02e, 0x33333333);
TEST_ASSERT_TRUE(h.onSend(&first));
TEST_ASSERT_TRUE(h.onSend(&doomed));
TEST_ASSERT_TRUE(h.onSend(&last));
TEST_ASSERT_TRUE(h.cancel(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV, doomed.from, doomed.id));
// Compacting the ring must not drop or reorder the frames either side, which are unrelated
// packets that still have to go out in the order they were queued.
uint8_t expectedFirst[BLE_MESH_ADV_TOTAL_MAX];
uint8_t expectedLast[BLE_MESH_ADV_TOTAL_MAX];
const uint8_t firstLen = h.build(&first, expectedFirst, sizeof(expectedFirst));
const uint8_t lastLen = h.build(&last, expectedLast, sizeof(expectedLast));
h.pump();
h.advertising = false;
h.pump();
h.advertising = false;
h.pump();
TEST_ASSERT_EQUAL_MESSAGE(2, h.sent.size(), "two frames survived");
TEST_ASSERT_EQUAL_UINT8_ARRAY(expectedFirst, h.sent[0].data(), firstLen);
TEST_ASSERT_EQUAL_UINT8_ARRAY(expectedLast, h.sent[1].data(), lastLen);
}
void test_canceling_cuts_a_burst_already_on_air(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket(0x3061b02e, 0x04050b6e);
TEST_ASSERT_TRUE(h.onSend(&p));
h.pump();
TEST_ASSERT_TRUE_MESSAGE(h.advertising, "on air");
// runOnce pops before it advertises, so the frame is no longer in the ring and only the
// live-burst identity can find the BLE_MESH_ADV_EVENTS repeats still to come.
TEST_ASSERT_TRUE(h.cancel(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV, p.from, p.id));
TEST_ASSERT_FALSE_MESSAGE(h.advertising, "burst ended");
// The state machine is not left half-advanced: the next pump finds an empty ring and idles
// rather than re-ending a burst that is already over.
h.pump();
TEST_ASSERT_EQUAL_MESSAGE(1, h.sent.size(), "nothing re-sent");
}
void test_send_queues_rather_than_transmitting(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket();
TEST_ASSERT_TRUE(h.onSend(&p));
// onSend is reached from Router::send on the main task, so advertising inline would stall the
// router, and with it LoRa timing, for the length of every burst.
TEST_ASSERT_EQUAL_MESSAGE(0, h.sent.size(), "nothing on air yet");
h.pump();
TEST_ASSERT_EQUAL_MESSAGE(1, h.sent.size(), "the pump transmits it");
}
void test_tx_queue_is_bounded(void)
{
FakeBLEMesh h;
h.start();
size_t accepted = 0;
for (size_t i = 0; i < BLE_MESH_TX_QUEUE_SIZE * 3; i++) {
auto p = encryptedPacket(0x3061b02e, (uint32_t)(0x1000 + i));
if (h.onSend(&p))
accepted++;
}
// A full ring drops rather than growing without bound or overwriting an unsent frame.
TEST_ASSERT_EQUAL(BLE_MESH_TX_QUEUE_SIZE, accepted);
}
void test_a_relayed_packet_is_re_advertised(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket();
p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_BLE_ADV;
// A rebroadcast arrives marked BLE-sourced: perhapsRebroadcast allocCopy()s the received packet
// and nothing on the TX path rewrites transport_mechanism. Refusing it caps the mesh at one hop.
TEST_ASSERT_TRUE_MESSAGE(h.onSend(&p), "relay must not be refused");
}
// The guards and field clearing every bearer shares, the hop RSSI included, run per bearer in
// test_transport_ingress. The self-echo is this bearer's own: its scanner can hear its own burst.
void test_ingress_ignores_our_own_advertisement(void)
{
FakeBLEMesh h;
h.start();
auto p = encryptedPacket(nodeDB->getNodeNum());
uint8_t body[meshtastic_MeshPacket_size];
size_t n = encodeForAir(p, body, sizeof(body));
// A self-echo back into the node's own scanner would loop.
h.feed(body, n, -50);
TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "self-echo dropped");
}
void test_pump_waits_for_the_platform(void)
{
FakeBLEMesh h;
h.start();
h.ready = false;
auto p = encryptedPacket();
TEST_ASSERT_TRUE(h.onSend(&p));
h.pump();
// Readiness is polled, not pushed: the order in which the BLE stack comes up and main()
// constructs the handler is not fixed, so a one-shot "ready" callback is a race.
TEST_ASSERT_EQUAL_MESSAGE(0, h.sent.size(), "nothing transmitted before the stack is up");
h.ready = true;
h.pump();
TEST_ASSERT_EQUAL_MESSAGE(1, h.sent.size(), "transmits once ready");
}
// NimBLE's legacy ble_gap_adv_start takes its duration in milliseconds, where the extended API
// counts events. 3 events at 48 x 0.625 ms last 90 ms, plus one interval for the advDelay each
// event may add.
void test_legacy_burst_duration_is_in_milliseconds(void)
{
TEST_ASSERT_EQUAL_INT32(120, bleMeshBurstMs(3, 48));
TEST_ASSERT_GREATER_OR_EQUAL_INT32(3 * 30, bleMeshBurstMs(3, 48));
}
// HCI LE Extended Advertising Report carries at most 229 bytes, so a controller hands a 245-byte
// advertisement (one full AUX_ADV_IND) up as a 229-byte INCOMPLETE head and a 16-byte COMPLETE tail.
// Dropping the head and parsing the tail alone loses every packet over 221 bytes.
void test_a_split_advertisement_is_joined(void)
{
FakeBLEMesh h;
h.start();
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
meshtastic_MeshPacket p;
TEST_ASSERT_EQUAL_MESSAGE(245, advertisementOf(h, 245, 0x0a0b0c0d, adv, &p), "a 245-byte advertisement exists");
h.report(0, Status::Incomplete, adv, 229);
TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "nothing delivered from the head alone");
h.report(0, Status::Complete, adv + 229, 16);
TEST_ASSERT_EQUAL_MESSAGE(1, h.received.size(), "the joined advertisement delivered once");
TEST_ASSERT_EQUAL_UINT32(p.id, h.received[0].id);
TEST_ASSERT_EQUAL_UINT32(p.encrypted.size, h.received[0].encrypted.size);
TEST_ASSERT_EQUAL_MEMORY(p.encrypted.bytes, h.received[0].encrypted.bytes, p.encrypted.size);
}
void test_an_unsplit_advertisement_is_delivered(void)
{
FakeBLEMesh h;
h.start();
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL(120, advertisementOf(h, 120, 0x0a0b0c0d, adv));
h.report(0, Status::Complete, adv, 120);
TEST_ASSERT_EQUAL(1, h.received.size());
}
// TRUNCATED means the controller lost the rest of the chain; what arrived is not a packet.
void test_a_truncated_advertisement_is_dropped(void)
{
FakeBLEMesh h;
h.start();
uint8_t adv[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL(245, advertisementOf(h, 245, 0x0a0b0c0d, adv));
h.report(0, Status::Incomplete, adv, 229);
h.report(0, Status::Truncated, adv + 229, 8);
TEST_ASSERT_EQUAL_MESSAGE(0, h.received.size(), "a truncated advertisement is not delivered");
// The abandoned head must not be joined to the advertiser's next burst.
h.report(0, Status::Incomplete, adv, 229);
h.report(0, Status::Complete, adv + 229, 16);
TEST_ASSERT_EQUAL_MESSAGE(1, h.received.size(), "the next burst arrives whole");
}
// The key is (address, SID): two advertisers, or two sets of one, interleave on air.
void test_interleaved_advertisers_stay_apart(void)
{
FakeBLEMesh h;
h.start();
uint8_t a[BLE_MESH_ADV_TOTAL_MAX];
uint8_t b[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL(245, advertisementOf(h, 245, 0x0000000a, a));
TEST_ASSERT_EQUAL(240, advertisementOf(h, 240, 0x0000000b, b));
const uint8_t otherAddr[6] = {0x66, 0x55, 0x44, 0x33, 0x22, 0x11};
h.report(0, Status::Incomplete, a, 229);
h.report(0, Status::Incomplete, b, 229, otherAddr);
h.report(0, Status::Complete, a + 229, 16);
h.report(0, Status::Complete, b + 229, 11, otherAddr);
TEST_ASSERT_EQUAL(2, h.received.size());
TEST_ASSERT_EQUAL_UINT32(0x0000000a, h.received[0].id);
TEST_ASSERT_EQUAL_UINT32(0x0000000b, h.received[1].id);
}
// HCI marks no first report, so a head whose tail never came must expire rather than prefix the
// advertiser's next advertisement.
void test_a_stale_partial_is_abandoned(void)
{
FakeBLEMesh h;
h.start();
uint8_t stale[BLE_MESH_ADV_TOTAL_MAX];
uint8_t fresh[BLE_MESH_ADV_TOTAL_MAX];
TEST_ASSERT_EQUAL(245, advertisementOf(h, 245, 0x0000000a, stale));
TEST_ASSERT_EQUAL(245, advertisementOf(h, 245, 0x0000000b, fresh));
Time::setTestMillis(10000);
h.report(0, Status::Incomplete, stale, 229);
Time::advanceTestMillis(BLE_MESH_REASSEMBLY_TIMEOUT_MS + 1);
h.report(0, Status::Incomplete, fresh, 229);
h.report(0, Status::Complete, fresh + 229, 16);
Time::useRealClock();
TEST_ASSERT_EQUAL(1, h.received.size());
TEST_ASSERT_EQUAL_UINT32(0x0000000b, h.received[0].id);
}
void setUp(void) {}
void tearDown(void) {}
void setup()
{
initializeTestEnvironment();
// deliverToRouter consults nodeDB to recognise and drop a self-echo, so ingress needs a real one.
if (!nodeDB)
nodeDB = new NodeDB();
UNITY_BEGIN();
RUN_TEST(test_advertisement_carries_the_packet);
RUN_TEST(test_refuses_an_unencrypted_packet);
RUN_TEST(test_refuses_a_packet_with_no_sender);
RUN_TEST(test_drops_a_packet_too_large_for_one_advertisement);
RUN_TEST(test_the_advertisement_ceiling_is_below_the_lora_ceiling);
RUN_TEST(test_relaying_no_longer_costs_budget);
RUN_TEST(test_the_air_copy_drops_everything_the_receiver_overwrites);
RUN_TEST(test_an_oversized_packet_is_counted_not_just_logged);
RUN_TEST(test_a_dupe_heard_on_ble_cancels_our_queued_copy);
RUN_TEST(test_a_dupe_heard_on_lora_leaves_the_ble_queue_alone);
RUN_TEST(test_canceling_keeps_the_other_queued_frames_in_order);
RUN_TEST(test_canceling_cuts_a_burst_already_on_air);
RUN_TEST(test_send_queues_rather_than_transmitting);
RUN_TEST(test_tx_queue_is_bounded);
RUN_TEST(test_an_urgent_frame_overtakes_one_already_queued);
RUN_TEST(test_equal_priorities_leave_in_arrival_order);
RUN_TEST(test_a_full_queue_makes_room_only_for_something_more_important);
RUN_TEST(test_the_frame_displaced_is_the_newest_of_the_least_important);
RUN_TEST(test_a_relayed_packet_is_re_advertised);
RUN_TEST(test_ingress_ignores_our_own_advertisement);
RUN_TEST(test_pump_waits_for_the_platform);
RUN_TEST(test_legacy_burst_duration_is_in_milliseconds);
RUN_TEST(test_a_split_advertisement_is_joined);
RUN_TEST(test_an_unsplit_advertisement_is_delivered);
RUN_TEST(test_a_truncated_advertisement_is_dropped);
RUN_TEST(test_interleaved_advertisers_stay_apart);
RUN_TEST(test_a_stale_partial_is_abandoned);
exit(UNITY_END());
}
#else
void setup()
{
initializeTestEnvironment();
LOG_WARN("BLE mesh tests require ARCH_PORTDUINO with HAS_BLE_MESH");
UNITY_BEGIN();
exit(UNITY_END());
}
#endif // ARCH_PORTDUINO && HAS_BLE_MESH
void loop() {}