Files
firmware/test/test_transparent_radio/test_main.cpp
T
Claude 70bd2cc579 TransparentRadio: verify what the device took, and don't rewrite it needlessly
- Read-back compares the settings that decide what is on the air (region,
  preset or bandwidth/SF, coding rate, slot or frequency, tx_enabled), not
  just transparent_mode. A device that rejects a LoRa config restores its
  old one whole, transparent_mode included, so that is now "Refused":
  retried after 5 minutes or at once on this node's next config change.
  Only a device that took the settings and still reads transparent_mode
  false is old firmware.
- The same comparison decides whether a device is already set up, so
  fields the device normalises no longer cause a push on every reconnect.
  coding_rate, spread_factor and bandwidth are clamped the way the
  device's AdminModule stores them.
- Firmware that reports a release older than 2.8.3 is refused before any
  set_config reaches it.
- With no LoRa region set here, nothing is pushed (it would set the
  device's region to UNSET and turn its TX off); setting a region pushes.
- Frames held for their own radio settings (RadioTxHooks::holdsRadio,
  e.g. MeshBeacon on another preset) are dropped, since the device can't
  switch per packet, and the hooks hear about every released frame.
- Reconnect attempts back off from 5 s to 60 s: opening a TCP link blocks
  the main loop.
- hop_limit/hop_start are clamped as encodeRadioBuffer() does instead of
  being refused by the device; slot_parity is no longer forwarded.
- Lora.Module transparent leaves spidev empty, so the SPI bus is untouched.
- Queued frames are released on destruction; the late-window clamp only
  reschedules when the moved frame is the front.
2026-11-04 04:07:10 +00:00

731 lines
32 KiB
C++

// TransparentRadio (src/platform/portduino/TransparentRadio.cpp): meshtasticd's Lora.Module "transparent",
// which uses another node's LoRa chip through that node's client API while the node runs
// LoRaConfig.transparent_mode.
//
// Under test, against an in-memory TransparentLink standing in for the serial/TCP connection:
// - the handshake: want_config with the config-only nonce, then a set_config pushing this node's LoRa
// settings with transparent_mode on, then a read-back that must confirm transparent_mode;
// - TX: a frame waits out its local delay in this node's queue, then goes to the device as an encrypted
// MeshPacket carrying only the radio header fields and ciphertext;
// - RX: encrypted frames from the device reach this node's router, everything else does not;
// - flow control, cancellation, link loss and live reconfiguration.
//
// Why: the device puts frames on the air exactly as given and leaves routing, retries and airtime to this
// node. So the frequency slot must be this node's (the device would otherwise hash its own channel name),
// relays must stay cancellable here until their delay ends (managed flooding depends on it), and nothing
// but genuine LoRa frames may enter the router.
//
// Regressions guarded:
// - Pushing config.lora unchanged, or without channel_num, leaves the device on a different frequency.
// - Overwriting the device's frequency_offset or front-end settings miscalibrates its hardware.
// - Treating a missing transparent_mode in the read-back as success hands frames to old firmware,
// which would decrypt, relay and re-originate them as a normal node.
// - Forwarding a frame as soon as it is queued makes cancelSending() useless and every relay go first.
// - Forwarding rx metadata or tx_after lets the device apply relay delays a second time.
// - Delivering decoded packets (the device's admin replies) or frames heard before Ready into the router.
// - Ignoring a full device queue, or not re-handshaking after the link drops or the device reboots.
// - Not re-pushing when this node's LoRa config changes, which leaves the device on the old settings.
#include "MeshTypes.h" // BEFORE TestUtil.h
#include "TestUtil.h"
#include <unity.h>
#include "platform/portduino/TransparentRadio.h"
#if HAS_TRANSPARENT_RADIO
#include "UptimeClock.h"
#include "airtime.h"
#include "mesh/Channels.h"
#include "mesh/MeshRadio.h"
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
#include "mesh/Router.h"
#include "support/MockMeshService.h"
#include <cstring>
#include <deque>
#include <pb_decode.h>
#include <pb_encode.h>
#include <vector>
static constexpr NodeNum DEVICE_NODE = 0x0D0D0D0D;
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B;
static constexpr uint32_t CLOCK_BASE_MS = 100000;
static constexpr const char *PRIMARY_CHANNEL_NAME = "transparent-test";
/// The serial/TCP connection, in memory: what the radio wrote, and what the "device" will send back.
class FakeLink : public TransparentLink
{
public:
bool open() override
{
opens++;
isOpenFlag = openOk;
return openOk;
}
void close() override { isOpenFlag = false; }
bool isOpen() const override { return isOpenFlag; }
int read(uint8_t *buf, size_t len) override
{
if (!isOpenFlag || readFails)
return -1;
size_t n = 0;
while (n < len && !incoming.empty()) {
buf[n++] = incoming.front();
incoming.pop_front();
}
return (int)n;
}
bool write(const uint8_t *buf, size_t len) override
{
if (!isOpenFlag)
return false;
written.insert(written.end(), buf, buf + len);
return true;
}
const std::string &describe() const override { return name; }
bool openOk = true;
bool isOpenFlag = false;
bool readFails = false;
int opens = 0;
std::deque<uint8_t> incoming;
std::vector<uint8_t> written;
std::string name = "fake-link";
};
/// Captures what the radio hands to the router instead of routing it.
class CaptureRouter : public Router
{
public:
void enqueueReceivedMessage(meshtastic_MeshPacket *p) override
{
received.push_back(*p);
packetPool.release(p);
}
std::vector<meshtastic_MeshPacket> received;
};
class TestRadio : public TransparentRadio
{
public:
using TransparentRadio::runOnce;
using TransparentRadio::TransparentRadio;
};
static FakeLink *fakeLink = nullptr;
static TestRadio *radio = nullptr;
static CaptureRouter *captureRouter = nullptr;
static MockMeshService *mockService = nullptr;
static AirTime *testAirTime = nullptr;
// ---------------------------------------------------------------------------
// Wire helpers
// ---------------------------------------------------------------------------
/// Every ToRadio the radio wrote since the last call, in order. Wake bytes and other non-frame bytes are skipped.
static std::vector<meshtastic_ToRadio> takeToRadio()
{
std::vector<meshtastic_ToRadio> out;
const std::vector<uint8_t> &w = fakeLink->written;
size_t i = 0;
while (i + 4 <= w.size()) {
if (w[i] != 0x94 || w[i + 1] != 0xc3) {
i++;
continue;
}
const size_t len = (w[i + 2] << 8) | w[i + 3];
TEST_ASSERT_TRUE_MESSAGE(i + 4 + len <= w.size(), "truncated frame on the link");
meshtastic_ToRadio tr = meshtastic_ToRadio_init_zero;
TEST_ASSERT_TRUE(pb_decode_from_bytes(w.data() + i + 4, len, &meshtastic_ToRadio_msg, &tr));
out.push_back(tr);
i += 4 + len;
}
fakeLink->written.clear();
return out;
}
/// Queue one framed FromRadio for the radio to read on its next runOnce().
static void deviceSends(const meshtastic_FromRadio &fr)
{
uint8_t buf[MAX_TO_FROM_RADIO_SIZE];
const size_t len = pb_encode_to_bytes(buf, sizeof(buf), &meshtastic_FromRadio_msg, &fr);
TEST_ASSERT_GREATER_THAN(0, len);
fakeLink->incoming.push_back(0x94);
fakeLink->incoming.push_back(0xc3);
fakeLink->incoming.push_back((len >> 8) & 0xff);
fakeLink->incoming.push_back(len & 0xff);
fakeLink->incoming.insert(fakeLink->incoming.end(), buf, buf + len);
}
static void deviceSendsLora(const meshtastic_Config_LoRaConfig &lora)
{
meshtastic_FromRadio fr = meshtastic_FromRadio_init_zero;
fr.which_payload_variant = meshtastic_FromRadio_config_tag;
fr.config.which_payload_variant = meshtastic_Config_lora_tag;
fr.config.payload_variant.lora = lora;
deviceSends(fr);
}
/// The device's answer to want_config: its node number, its LoRa config, then config_complete_id.
static void deviceSendsConfigDump(const meshtastic_Config_LoRaConfig &lora, const char *firmwareVersion = "2.8.3.abcdef0")
{
meshtastic_FromRadio fr = meshtastic_FromRadio_init_zero;
fr.which_payload_variant = meshtastic_FromRadio_my_info_tag;
fr.my_info.my_node_num = DEVICE_NODE;
deviceSends(fr);
fr = meshtastic_FromRadio_init_zero;
fr.which_payload_variant = meshtastic_FromRadio_metadata_tag;
strncpy(fr.metadata.firmware_version, firmwareVersion, sizeof(fr.metadata.firmware_version) - 1);
deviceSends(fr);
deviceSendsLora(lora);
fr = meshtastic_FromRadio_init_zero;
fr.which_payload_variant = meshtastic_FromRadio_config_complete_id_tag;
fr.config_complete_id = SPECIAL_NONCE_ONLY_CONFIG;
deviceSends(fr);
}
static void deviceSendsQueueFree(uint32_t freeSlots)
{
meshtastic_FromRadio fr = meshtastic_FromRadio_init_zero;
fr.which_payload_variant = meshtastic_FromRadio_queueStatus_tag;
fr.queueStatus.free = freeSlots;
fr.queueStatus.maxlen = 16;
deviceSends(fr);
}
static meshtastic_AdminMessage adminIn(const meshtastic_ToRadio &tr)
{
TEST_ASSERT_EQUAL(meshtastic_ToRadio_packet_tag, tr.which_payload_variant);
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, tr.packet.which_payload_variant);
TEST_ASSERT_EQUAL(meshtastic_PortNum_ADMIN_APP, tr.packet.decoded.portnum);
TEST_ASSERT_EQUAL_HEX32_MESSAGE(DEVICE_NODE, tr.packet.to, "admin must go to the device's own node number");
meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero;
TEST_ASSERT_TRUE(pb_decode_from_bytes(tr.packet.decoded.payload.bytes, tr.packet.decoded.payload.size,
&meshtastic_AdminMessage_msg, &admin));
return admin;
}
/// The LoRa config the radio should leave the device on: ours, transparent, pinned to our slot.
static meshtastic_Config_LoRaConfig expectedDeviceLora()
{
meshtastic_Config_LoRaConfig lora = config.lora;
lora.transparent_mode = true;
lora.channel_num = radio->getChannelNum() + 1;
lora.coding_rate = clampCodingRate(lora.coding_rate); // as the device's AdminModule stores it
lora.spread_factor = clampSpreadFactor(lora.spread_factor);
return lora;
}
/// The device's answer to the read-back (get_config_request) the radio sent as `request`.
static void deviceAnswersReadBack(const meshtastic_ToRadio &request, const meshtastic_Config_LoRaConfig &lora)
{
meshtastic_FromRadio reply = meshtastic_FromRadio_init_zero;
reply.which_payload_variant = meshtastic_FromRadio_packet_tag;
reply.packet.from = DEVICE_NODE;
reply.packet.to = DEVICE_NODE;
reply.packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
reply.packet.decoded.portnum = meshtastic_PortNum_ADMIN_APP;
reply.packet.decoded.request_id = request.packet.id;
meshtastic_AdminMessage readBack = meshtastic_AdminMessage_init_zero;
readBack.which_payload_variant = meshtastic_AdminMessage_get_config_response_tag;
readBack.get_config_response.which_payload_variant = meshtastic_Config_lora_tag;
readBack.get_config_response.payload_variant.lora = lora;
reply.packet.decoded.payload.size = pb_encode_to_bytes(
reply.packet.decoded.payload.bytes, sizeof(reply.packet.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &readBack);
deviceSends(reply);
}
/// Run the handshake against a device already in the state we want, so no config is pushed.
static void bringReady()
{
radio->runOnce();
takeToRadio();
deviceSendsConfigDump(expectedDeviceLora());
radio->runOnce();
TEST_ASSERT_EQUAL_MESSAGE((int)TransparentRadio::State::Ready, (int)radio->getState(), "handshake did not reach Ready");
TEST_ASSERT_EQUAL_MESSAGE(0, takeToRadio().size(), "a device already set up needs no admin traffic");
}
/// An encrypted frame as Router::send() hands it to the interface, due tx_after ms from now.
static meshtastic_MeshPacket *makeOutgoing(PacketId id, uint32_t dueInMs)
{
meshtastic_MeshPacket *p = packetPool.allocZeroed();
TEST_ASSERT_NOT_NULL(p);
p->from = LOCAL_NODE;
p->to = REMOTE_NODE;
p->id = id;
p->channel = 0x42;
p->hop_limit = 3;
p->hop_start = 5;
p->want_ack = true;
p->via_mqtt = true;
p->next_hop = 0x0b;
p->relay_node = 0x0a;
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
p->rx_snr = 7.5f; // a relayed frame carries the SNR it was heard at
p->rx_rssi = -90;
p->has_rx_rssi = true;
p->rx_time = 1234;
p->has_rx_time = true;
p->tx_after = Time::getMillis() + dueInMs;
p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p->encrypted.size = 24;
for (pb_size_t i = 0; i < p->encrypted.size; i++)
p->encrypted.bytes[i] = (uint8_t)(0xA0 + i);
return p;
}
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
void setUp(void)
{
Time::setTestMillis(CLOCK_BASE_MS);
config = meshtastic_LocalConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
config.lora.tx_enabled = true;
config.lora.hop_limit = 3;
initRegion();
channels.initDefaults();
strncpy(channelFile.channels[0].settings.name, PRIMARY_CHANNEL_NAME, sizeof(channelFile.channels[0].settings.name) - 1);
channels.onConfigChanged();
service = mockService = new MockMeshService();
router = captureRouter = new CaptureRouter();
airTime = testAirTime = new AirTime();
auto owned = std::unique_ptr<FakeLink>(new FakeLink());
fakeLink = owned.get();
radio = new TestRadio(std::move(owned));
TEST_ASSERT_TRUE(radio->init());
}
void tearDown(void)
{
delete radio;
radio = nullptr;
fakeLink = nullptr;
router = nullptr;
delete captureRouter;
captureRouter = nullptr;
service = nullptr;
delete mockService;
mockService = nullptr;
airTime = nullptr;
delete testAirTime;
testAirTime = nullptr;
Time::useRealClock();
}
// ---------------------------------------------------------------------------
// Handshake
// ---------------------------------------------------------------------------
// A device that is still a normal node is switched over: want_config (config only), then set_config with
// our LoRa settings plus transparent_mode, then a read-back. Only the read-back confirming transparent_mode
// makes the radio Ready.
void test_handshake_pushes_our_lora_config_with_transparent_mode(void)
{
radio->runOnce();
TEST_ASSERT_EQUAL(1, fakeLink->opens);
std::vector<meshtastic_ToRadio> sent = takeToRadio();
TEST_ASSERT_EQUAL(1, sent.size());
TEST_ASSERT_EQUAL(meshtastic_ToRadio_want_config_id_tag, sent[0].which_payload_variant);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(SPECIAL_NONCE_ONLY_CONFIG, sent[0].want_config_id, "only the config, not the node DB");
meshtastic_Config_LoRaConfig deviceNow = meshtastic_Config_LoRaConfig_init_zero;
deviceNow.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
deviceNow.use_preset = true;
deviceNow.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST;
deviceNow.frequency_offset = 0.015f;
deviceNow.sx126x_rx_boosted_gain = true;
deviceSendsConfigDump(deviceNow);
radio->runOnce();
sent = takeToRadio();
TEST_ASSERT_EQUAL(2, sent.size());
const meshtastic_AdminMessage set = adminIn(sent[0]);
TEST_ASSERT_EQUAL(meshtastic_AdminMessage_set_config_tag, set.which_payload_variant);
TEST_ASSERT_EQUAL(meshtastic_Config_lora_tag, set.set_config.which_payload_variant);
const meshtastic_Config_LoRaConfig &pushed = set.set_config.payload_variant.lora;
TEST_ASSERT_TRUE(pushed.transparent_mode);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, pushed.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, pushed.modem_preset);
meshtastic_Config_LoRaConfig unpinned = config.lora;
unpinned.channel_num = 0;
TEST_ASSERT_EQUAL_UINT32_MESSAGE(RadioInterface::resolveFrequencySlot(unpinned, PRIMARY_CHANNEL_NAME), pushed.channel_num,
"the device must use the slot our primary channel name hashes to");
TEST_ASSERT_EQUAL_FLOAT_MESSAGE(0.015f, pushed.frequency_offset, "the device keeps its own crystal calibration");
TEST_ASSERT_TRUE_MESSAGE(pushed.sx126x_rx_boosted_gain, "the device keeps its own front-end setting");
const meshtastic_AdminMessage get = adminIn(sent[1]);
TEST_ASSERT_EQUAL(meshtastic_AdminMessage_get_config_request_tag, get.which_payload_variant);
TEST_ASSERT_EQUAL(meshtastic_AdminMessage_ConfigType_LORA_CONFIG, get.get_config_request);
TEST_ASSERT_TRUE(sent[1].packet.decoded.want_response);
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Pushing, (int)radio->getState());
deviceAnswersReadBack(sent[1], pushed);
radio->runOnce();
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Ready, (int)radio->getState());
TEST_ASSERT_EQUAL_MESSAGE(0, captureRouter->received.size(), "the admin reply is not a LoRa frame");
}
// A device already transparent with our settings needs no set_config: reconnects must not rewrite its flash.
void test_device_already_set_up_goes_ready_without_pushing(void)
{
bringReady();
}
// Firmware that predates transparent_mode drops the unknown field: it takes our radio settings, but the
// read-back comes back without transparent_mode. The radio must refuse that device rather than hand frames
// to a node that will route them itself.
void test_firmware_without_transparent_mode_is_refused(void)
{
radio->runOnce();
takeToRadio();
deviceSendsConfigDump(meshtastic_Config_LoRaConfig_init_zero);
radio->runOnce();
std::vector<meshtastic_ToRadio> sent = takeToRadio();
TEST_ASSERT_EQUAL(2, sent.size());
meshtastic_Config_LoRaConfig tookItAll = adminIn(sent[0]).set_config.payload_variant.lora;
tookItAll.transparent_mode = false;
deviceAnswersReadBack(sent[1], tookItAll);
radio->runOnce();
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Unsupported, (int)radio->getState());
TEST_ASSERT_FALSE(fakeLink->isOpen());
Time::advanceTestMillis(60000);
radio->runOnce();
TEST_ASSERT_EQUAL_MESSAGE(1, fakeLink->opens, "an unsupported device is not retried");
}
// A release the device reports as older than transparent mode is refused before anything is written to it:
// old firmware would apply and save our region and preset, and only then fail the read-back.
void test_old_firmware_version_is_refused_before_any_write(void)
{
radio->runOnce();
takeToRadio();
deviceSendsConfigDump(meshtastic_Config_LoRaConfig_init_zero, "2.7.15.1234abc");
radio->runOnce();
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Unsupported, (int)radio->getState());
TEST_ASSERT_EQUAL_MESSAGE(0, takeToRadio().size(), "no set_config may reach old firmware");
}
// A device that rejects a LoRa config restores its old one whole, so transparent_mode reads back false
// there too. That is not old firmware: the radio waits and retries, and retries at once when this node's
// config changes, instead of writing the device off until restart.
void test_device_refusing_our_settings_is_retried_not_written_off(void)
{
radio->runOnce();
takeToRadio();
meshtastic_Config_LoRaConfig deviceNow = meshtastic_Config_LoRaConfig_init_zero;
deviceNow.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
deviceNow.use_preset = true;
deviceSendsConfigDump(deviceNow);
radio->runOnce();
std::vector<meshtastic_ToRadio> sent = takeToRadio();
TEST_ASSERT_EQUAL(2, sent.size());
deviceAnswersReadBack(sent[1], deviceNow); // kept its own settings
radio->runOnce();
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Refused, (int)radio->getState());
TEST_ASSERT_TRUE_MESSAGE(fakeLink->isOpen(), "a refusal is not a reason to drop the link");
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
TEST_ASSERT_TRUE(radio->reconfigure());
sent = takeToRadio();
TEST_ASSERT_EQUAL_MESSAGE(2, sent.size(), "a config change retries straight away");
TEST_ASSERT_EQUAL(meshtastic_AdminMessage_set_config_tag, adminIn(sent[0]).which_payload_variant);
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Pushing, (int)radio->getState());
}
// With no region set here, pushing would set the device's region to UNSET (and turn its TX off) and persist
// that. Nothing is pushed until this node has a region; setting one pushes at once.
void test_no_region_here_leaves_the_device_alone_until_one_is_set(void)
{
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
initRegion();
TEST_ASSERT_TRUE(radio->reconfigure());
radio->runOnce();
takeToRadio();
meshtastic_Config_LoRaConfig deviceNow = meshtastic_Config_LoRaConfig_init_zero;
deviceNow.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
deviceSendsConfigDump(deviceNow);
radio->runOnce();
TEST_ASSERT_EQUAL((int)TransparentRadio::State::WaitingForRegion, (int)radio->getState());
TEST_ASSERT_EQUAL_MESSAGE(0, takeToRadio().size(), "no admin traffic without a region");
Time::advanceTestMillis(60000);
radio->runOnce();
TEST_ASSERT_EQUAL_MESSAGE((int)TransparentRadio::State::WaitingForRegion, (int)radio->getState(),
"waiting for a region is not a stuck handshake");
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
TEST_ASSERT_TRUE(radio->reconfigure());
std::vector<meshtastic_ToRadio> sent = takeToRadio();
TEST_ASSERT_EQUAL(2, sent.size());
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, adminIn(sent[0]).set_config.payload_variant.lora.region);
}
// ---------------------------------------------------------------------------
// TX
// ---------------------------------------------------------------------------
// The frame waits out its delay here, then goes to the device with the header fields and ciphertext only.
void test_frame_goes_out_at_its_due_time_with_header_and_ciphertext_only(void)
{
bringReady();
meshtastic_MeshPacket *p = makeOutgoing(0x5000, 3000);
const meshtastic_MeshPacket original = *p;
TEST_ASSERT_EQUAL(ERRNO_OK, radio->send(p));
Time::advanceTestMillis(2999);
radio->runOnce();
TEST_ASSERT_EQUAL_MESSAGE(0, takeToRadio().size(), "nothing goes to the device before the frame is due");
Time::advanceTestMillis(1);
radio->runOnce();
std::vector<meshtastic_ToRadio> sent = takeToRadio();
TEST_ASSERT_EQUAL(1, sent.size());
TEST_ASSERT_EQUAL(meshtastic_ToRadio_packet_tag, sent[0].which_payload_variant);
const meshtastic_MeshPacket &out = sent[0].packet;
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, out.which_payload_variant);
TEST_ASSERT_EQUAL_HEX32(original.from, out.from);
TEST_ASSERT_EQUAL_HEX32(original.to, out.to);
TEST_ASSERT_EQUAL_HEX32(original.id, out.id);
TEST_ASSERT_EQUAL_HEX8(original.channel, out.channel);
TEST_ASSERT_EQUAL_UINT8(original.hop_limit, out.hop_limit);
TEST_ASSERT_EQUAL_UINT8(original.hop_start, out.hop_start);
TEST_ASSERT_TRUE(out.want_ack);
TEST_ASSERT_TRUE(out.via_mqtt);
TEST_ASSERT_EQUAL_HEX8(original.next_hop, out.next_hop);
TEST_ASSERT_EQUAL_HEX8(original.relay_node, out.relay_node);
TEST_ASSERT_EQUAL_UINT32(original.encrypted.size, out.encrypted.size);
TEST_ASSERT_EQUAL_HEX8_ARRAY(original.encrypted.bytes, out.encrypted.bytes, original.encrypted.size);
TEST_ASSERT_EQUAL_MESSAGE(0, out.tx_after, "the device must not delay it again");
TEST_ASSERT_EQUAL_FLOAT_MESSAGE(0.0f, out.rx_snr, "rx metadata would make the device weight a relay delay");
TEST_ASSERT_FALSE(out.has_rx_rssi);
TEST_ASSERT_FALSE(out.has_rx_time);
TEST_ASSERT_EQUAL_UINT32(1, radio->txGood);
TEST_ASSERT_GREATER_THAN_FLOAT(0.0f, testAirTime->utilizationTXPercent());
}
// Managed flooding cancels a relay when someone else relays first. That only works while the frame is
// still here, so a cancelled frame must never reach the device.
void test_cancelled_frame_never_reaches_the_device(void)
{
bringReady();
TEST_ASSERT_EQUAL(ERRNO_OK, radio->send(makeOutgoing(0x5001, 3000)));
TEST_ASSERT_TRUE(radio->findInTxQueue(LOCAL_NODE, 0x5001));
TEST_ASSERT_TRUE(radio->cancelSending(LOCAL_NODE, 0x5001));
Time::advanceTestMillis(10000);
radio->runOnce();
TEST_ASSERT_EQUAL(0, takeToRadio().size());
}
// The device answers each frame with its queue's free slots. At zero the radio holds its frames until a
// QueueStatus says there is room, instead of having the device drop them.
void test_full_device_queue_holds_frames_until_space_is_reported(void)
{
bringReady();
deviceSendsQueueFree(0);
TEST_ASSERT_EQUAL(ERRNO_OK, radio->send(makeOutgoing(0x5002, 1000)));
Time::advanceTestMillis(5000);
radio->runOnce();
TEST_ASSERT_EQUAL_MESSAGE(0, takeToRadio().size(), "held while the device queue is full");
deviceSendsQueueFree(3);
Time::advanceTestMillis(100);
radio->runOnce(); // reads the QueueStatus, then forwards the held frame in the same pass
std::vector<meshtastic_ToRadio> sent = takeToRadio();
TEST_ASSERT_EQUAL(1, sent.size());
TEST_ASSERT_EQUAL_HEX32(0x5002, sent[0].packet.id);
}
// ---------------------------------------------------------------------------
// RX
// ---------------------------------------------------------------------------
// Encrypted frames are LoRa traffic and go to the router as heard. Decoded packets are the device's own
// local traffic, and frames heard before Ready came from a device that may still be routing for itself.
void test_only_encrypted_frames_after_ready_reach_the_router(void)
{
meshtastic_FromRadio heard = meshtastic_FromRadio_init_zero;
heard.which_payload_variant = meshtastic_FromRadio_packet_tag;
heard.packet.from = REMOTE_NODE;
heard.packet.to = NODENUM_BROADCAST;
heard.packet.id = 0x6000;
heard.packet.channel = 0x42;
heard.packet.hop_limit = 2;
heard.packet.hop_start = 3;
heard.packet.relay_node = 0x0b;
heard.packet.rx_snr = 4.25f;
heard.packet.rx_rssi = -101;
heard.packet.has_rx_rssi = true;
heard.packet.rx_time = 999;
heard.packet.has_rx_time = true;
heard.packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
heard.packet.encrypted.size = 20;
memset(heard.packet.encrypted.bytes, 0x5A, heard.packet.encrypted.size);
radio->runOnce();
takeToRadio();
deviceSends(heard); // before the handshake completes
radio->runOnce();
TEST_ASSERT_EQUAL_MESSAGE(0, captureRouter->received.size(), "nothing is delivered before Ready");
deviceSendsConfigDump(expectedDeviceLora());
radio->runOnce();
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Ready, (int)radio->getState());
deviceSends(heard);
meshtastic_FromRadio local = meshtastic_FromRadio_init_zero;
local.which_payload_variant = meshtastic_FromRadio_packet_tag;
local.packet.from = DEVICE_NODE;
local.packet.to = DEVICE_NODE;
local.packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
local.packet.decoded.portnum = meshtastic_PortNum_TELEMETRY_APP;
deviceSends(local);
radio->runOnce();
TEST_ASSERT_EQUAL(1, captureRouter->received.size());
const meshtastic_MeshPacket &in = captureRouter->received[0];
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, in.which_payload_variant);
TEST_ASSERT_EQUAL_HEX32(0x6000, in.id);
TEST_ASSERT_EQUAL_HEX32(REMOTE_NODE, in.from);
TEST_ASSERT_EQUAL_HEX8(0x42, in.channel);
TEST_ASSERT_EQUAL_HEX8(0x0b, in.relay_node);
TEST_ASSERT_EQUAL_FLOAT_MESSAGE(4.25f, in.rx_snr, "the device's SNR drives this node's relay delay");
TEST_ASSERT_EQUAL_INT32(-101, in.rx_rssi);
TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA, in.transport_mechanism);
TEST_ASSERT_FALSE_MESSAGE(in.has_rx_time, "the router stamps this node's own arrival time");
TEST_ASSERT_EQUAL_MESSAGE(0, memcmp(heard.packet.encrypted.bytes, in.encrypted.bytes, heard.packet.encrypted.size),
"ciphertext unchanged");
TEST_ASSERT_EQUAL_UINT32(1, radio->rxGood);
}
// ---------------------------------------------------------------------------
// Link and lifecycle
// ---------------------------------------------------------------------------
// A dropped link makes the radio "off": due frames are dropped, not held. It reconnects on its own and
// redoes the handshake, since the device may have rebooted or been swapped.
void test_link_loss_drops_due_frames_then_reconnects_and_handshakes_again(void)
{
bringReady();
TEST_ASSERT_EQUAL(ERRNO_OK, radio->send(makeOutgoing(0x5003, 2000)));
fakeLink->readFails = true;
radio->runOnce();
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Disconnected, (int)radio->getState());
fakeLink->readFails = false;
Time::advanceTestMillis(2000);
radio->runOnce();
TEST_ASSERT_EQUAL_UINT32(1, radio->txDrop);
TEST_ASSERT_EQUAL_MESSAGE(1, fakeLink->opens, "reconnect waits out its interval");
Time::advanceTestMillis(5000);
radio->runOnce();
TEST_ASSERT_EQUAL(2, fakeLink->opens);
std::vector<meshtastic_ToRadio> sent = takeToRadio();
TEST_ASSERT_EQUAL(1, sent.size());
TEST_ASSERT_EQUAL(meshtastic_ToRadio_want_config_id_tag, sent[0].which_payload_variant);
}
// A device that reboots says so; its settings persisted, but the radio re-checks them before trusting it.
void test_device_reboot_restarts_the_handshake(void)
{
bringReady();
meshtastic_FromRadio rebooted = meshtastic_FromRadio_init_zero;
rebooted.which_payload_variant = meshtastic_FromRadio_rebooted_tag;
rebooted.rebooted = true;
deviceSends(rebooted);
radio->runOnce();
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Configuring, (int)radio->getState());
std::vector<meshtastic_ToRadio> sent = takeToRadio();
TEST_ASSERT_EQUAL(1, sent.size());
TEST_ASSERT_EQUAL(meshtastic_ToRadio_want_config_id_tag, sent[0].which_payload_variant);
}
// This node's LoRa config can change at runtime (admin, the app). The device must follow.
void test_lora_config_change_is_pushed_to_the_device(void)
{
bringReady();
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
TEST_ASSERT_TRUE(radio->reconfigure());
std::vector<meshtastic_ToRadio> sent = takeToRadio();
TEST_ASSERT_EQUAL(2, sent.size());
const meshtastic_AdminMessage set = adminIn(sent[0]);
TEST_ASSERT_EQUAL(meshtastic_AdminMessage_set_config_tag, set.which_payload_variant);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, set.set_config.payload_variant.lora.modem_preset);
TEST_ASSERT_TRUE(set.set_config.payload_variant.lora.transparent_mode);
TEST_ASSERT_EQUAL((int)TransparentRadio::State::Pushing, (int)radio->getState());
}
// The endpoint string picks the transport, and nonsense is refused rather than guessed at.
void test_endpoint_parsing(void)
{
TEST_ASSERT_NOT_NULL(makeTransparentLink("/dev/ttyACM0").get());
TEST_ASSERT_NOT_NULL(makeTransparentLink("meshnode.local").get());
TEST_ASSERT_NOT_NULL(makeTransparentLink("192.168.1.20:4403").get());
TEST_ASSERT_NOT_NULL(makeTransparentLink("[fe80::1]:4403").get());
TEST_ASSERT_NOT_NULL(makeTransparentLink("fe80::1").get());
TEST_ASSERT_EQUAL_STRING("192.168.1.20:4403", makeTransparentLink("192.168.1.20:4403")->describe().c_str());
TEST_ASSERT_NULL(makeTransparentLink("").get());
TEST_ASSERT_NULL(makeTransparentLink("host:0").get());
TEST_ASSERT_NULL(makeTransparentLink("host:70000").get());
TEST_ASSERT_NULL(makeTransparentLink("host:abc").get());
TEST_ASSERT_NULL(makeTransparentLink("[fe80::1").get());
TEST_ASSERT_NULL(makeTransparentLink(":4403").get());
}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_handshake_pushes_our_lora_config_with_transparent_mode);
RUN_TEST(test_device_already_set_up_goes_ready_without_pushing);
RUN_TEST(test_firmware_without_transparent_mode_is_refused);
RUN_TEST(test_old_firmware_version_is_refused_before_any_write);
RUN_TEST(test_device_refusing_our_settings_is_retried_not_written_off);
RUN_TEST(test_no_region_here_leaves_the_device_alone_until_one_is_set);
RUN_TEST(test_frame_goes_out_at_its_due_time_with_header_and_ciphertext_only);
RUN_TEST(test_cancelled_frame_never_reaches_the_device);
RUN_TEST(test_full_device_queue_holds_frames_until_space_is_reported);
RUN_TEST(test_only_encrypted_frames_after_ready_reach_the_router);
RUN_TEST(test_link_loss_drops_due_frames_then_reconnects_and_handshakes_again);
RUN_TEST(test_device_reboot_restarts_the_handshake);
RUN_TEST(test_lora_config_change_is_pushed_to_the_device);
RUN_TEST(test_endpoint_parsing);
exit(UNITY_END());
}
void loop() {}
#else // no POSIX serial/sockets on this build
void setUp(void) {}
void tearDown(void) {}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
exit(UNITY_END());
}
void loop() {}
#endif