From e951b4fe60f570ac6080b197c43ba690b420c993 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:52:34 +0000 Subject: [PATCH 1/3] =?UTF-8?q?test:=20pin=20the=20CAD=20commands=20and=20?= =?UTF-8?q?LR20x0Interface's=20busy=20check=20against=20a=E2=80=A6=20(#120?= =?UTF-8?q?36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: pin the CAD commands and LR20x0Interface's busy check against a modelled chip The LR2021 refuses SetLoraCadParams exit mode 0x02, the value of RadioLib's RADIOLIB_LR2021_CAD_EXIT_MODE_RX, with CMD_PERR (-706), and LR20x0Interface::isChannelActive() reads the error as a free channel: on the bench the node transmitted over live frames (16/16), and a Mesh-Tracker X1 gave no busy verdict in 539 scans. RecordingHal gains deferred replies N transactions out (the IRQ status read has no opcode), a rejectUnless() chip model, and an IRQ pin that reads high so scanChannel() returns. lr2021_tests.h and lr11x0_tests.h pin the CAD command bytes, the exit-mode defines, and that a refused command returns an error and starts no CAD. lr20x0_interface_tests.h runs the real isChannelActive() against the modelled chip: a detected preamble must read busy, and sending 0x02 fails both of those tests (checked by mutating LR20x0Interface.cpp). * test: bring RecordingHal and suite-order comments in line with the code digitalRead() also models the IRQ line now, a deferred reply has a stated precedence when two come due on one transaction, and the LR20x0Interface set runs after the LR2021 set for the same DC-DC reason. * test: three small corrections to the CAD test comments and macros The RADIO macros still passed `true` for reply()'s fourth argument, which this branch turned from a bool into `uint8_t after`, a count of transactions to defer by. It converts to 1, so nothing changed, but 1 and 2 now mean different things - lr2021AnswerIrq() passes 2 - so the call sites should not read as a bool. The comments described the refused exit byte as something develop does. It does not: develop's isChannelActive() sends 0x01 from its own local CAD_EXIT_MODE_RX, and these tests are what keep it there. Reworded in test_main.cpp, lr2021_tests.h and lr20x0_interface_tests.h so a later reader does not go looking for a live bug. test_lr2021_cad_irq_routed_to_dio's comment claimed DIO5 is "the line the interface's ISR watches". 5 is LR2021::irqDioNum's in-class default in RadioLib; the firmware assigns irqDioNum in LR20x0Interface::init() from LR2021_IRQ_DIO_NUM, IRQ_DIO_NUM or portduino_config.irq_dio_num, and falls back to RadioLib's default only when the variant defines neither. The assertion is worth keeping - it just pins RadioLib's default rather than the firmware's choice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011C7D9biGfpxLruq7DFLzsP --------- Co-authored-by: Jonathan Bennett Co-authored-by: Claude Opus 5 --- test/test_radiolib_drivers/RecordingHal.h | 56 +++- test/test_radiolib_drivers/lr11x0_tests.h | 83 ++++- test/test_radiolib_drivers/lr2021_tests.h | 308 +++++++++++++++++- .../lr20x0_interface_tests.h | 125 +++++++ test/test_radiolib_drivers/test_main.cpp | 16 +- 5 files changed, 572 insertions(+), 16 deletions(-) create mode 100644 test/test_radiolib_drivers/lr20x0_interface_tests.h diff --git a/test/test_radiolib_drivers/RecordingHal.h b/test/test_radiolib_drivers/RecordingHal.h index 46004a6382..e6b494fc30 100644 --- a/test/test_radiolib_drivers/RecordingHal.h +++ b/test/test_radiolib_drivers/RecordingHal.h @@ -7,6 +7,8 @@ #include #include +#include +#include #include class RecordingHal : public RadioLibHal @@ -15,7 +17,8 @@ class RecordingHal : public RadioLibHal // A scripted answer, chosen by the leading bytes (the opcode) of what the driver sent. struct Reply { std::vector prefix; - bool nextTransaction; // answer the transaction after the match: LRxxxx reads reply in a second one + uint8_t after; // answer the after-th transaction following the match, 0 = the match itself. + // LRxxxx reads reply in a second transaction; prefix-less ones (IRQ status) need more std::vector head; uint8_t fill; // every byte after head }; @@ -32,7 +35,9 @@ class RecordingHal : public RadioLibHal registerEcho = false; memset(registers, 0, sizeof(registers)); defaultFill = fill; - pending = nullptr; + pending.clear(); + checks.clear(); + irqPin = RADIOLIB_NC; nowUs = 0; } @@ -44,11 +49,22 @@ class RecordingHal : public RadioLibHal bool registerEcho = false; uint8_t registers[128] = {}; - void reply(std::vector prefix, uint8_t fill, std::vector head = {}, bool nextTransaction = false) + void reply(std::vector prefix, uint8_t fill, std::vector head = {}, uint8_t after = 0) { - replies.push_back({std::move(prefix), nextTransaction, std::move(head), fill}); + replies.push_back({std::move(prefix), after, std::move(head), fill}); } + // Chip model: a command matching `prefix` whose bytes `accepts` refuses gets `status` in the first + // byte of the next transaction, where LRxxxx paranoid SPI reads a verified write's status. + using Accepts = std::function &)>; + void rejectUnless(std::vector prefix, Accepts accepts, uint8_t status) + { + checks.push_back({std::move(prefix), std::move(accepts), status}); + } + + // Reads high, so RadioLib's blocking scanChannel() sees its IRQ and returns; every other pin is low. + uint32_t irqPin = RADIOLIB_NC; + // Transactions whose leading bytes are `prefix`. size_t count(const std::vector &prefix) const { @@ -76,7 +92,7 @@ class RecordingHal : public RadioLibHal void pinMode(uint32_t, uint32_t) override {} void digitalWrite(uint32_t, uint32_t) override {} - uint32_t digitalRead(uint32_t) override { return 0; } // BUSY low + uint32_t digitalRead(uint32_t pin) override { return irqPin != RADIOLIB_NC && pin == irqPin; } // BUSY low, irqPin high void attachInterrupt(uint32_t, void (*)(void), uint32_t) override {} void detachInterrupt(uint32_t) override {} void delay(RadioLibTime_t ms) override { nowUs += ms * 1000; } @@ -97,23 +113,43 @@ class RecordingHal : public RadioLibHal echoRegisters(out, len, in); return; } - const Reply *r = pending; - pending = nullptr; + // A due deferred reply wins over a direct match, and the first due one wins over any other: each + // targets one transaction, so two due together is a test bug. Copies, so reply() cannot invalidate one. + Reply due{}; + const Reply *r = nullptr; + for (auto it = pending.begin(); it != pending.end();) { + if (--it->second == 0) { + if (!r) + due = it->first, r = &due; + it = pending.erase(it); + } else { + ++it; + } + } for (const auto &c : replies) { if (!startsWith(out, len, c.prefix)) continue; - if (c.nextTransaction) - pending = &c; + if (c.after) + pending.emplace_back(c, c.after); else if (!r) r = &c; } + for (const auto &c : checks) + if (startsWith(out, len, c.prefix) && !c.accepts(transactions.back())) + pending.emplace_back(Reply{{}, 1, {c.status}, defaultFill}, 1); for (size_t i = 0; i < len; i++) in[i] = !r ? defaultFill : (i < r->head.size() ? r->head[i] : r->fill); } private: uint8_t defaultFill; - const Reply *pending = nullptr; + std::vector> pending; // deferred replies, transactions still to wait + struct Check { + std::vector prefix; + Accepts accepts; + uint8_t status; + }; + std::vector checks; RadioLibTime_t nowUs = 0; static bool startsWith(const uint8_t *data, size_t len, const std::vector &prefix) diff --git a/test/test_radiolib_drivers/lr11x0_tests.h b/test/test_radiolib_drivers/lr11x0_tests.h index 64d311d727..3878f5d19d 100644 --- a/test/test_radiolib_drivers/lr11x0_tests.h +++ b/test/test_radiolib_drivers/lr11x0_tests.h @@ -10,7 +10,7 @@ // so it goes in the data byte only. #define LR11X0_RADIO(hal) \ RecordingHal &hal = freshHal(); \ - hal.reply(op16(RADIOLIB_LR11X0_CMD_GET_PACKET_TYPE), 0x04, {0x04, RADIOLIB_LR11X0_PACKET_TYPE_LORA}, true); \ + hal.reply(op16(RADIOLIB_LR11X0_CMD_GET_PACKET_TYPE), 0x04, {0x04, RADIOLIB_LR11X0_PACKET_TYPE_LORA}, 1); \ Module mod(&hal, 1, RADIOLIB_NC, RADIOLIB_NC, 2); \ LR1121 radio(&mod) @@ -70,6 +70,82 @@ static void test_lr11x0_other_setters_and_modes_succeed() TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR11X0_CMD_SET_RX)) >= 1); } +// --- CAD: LR11x0Interface::isChannelActive() -> startChannelScan(cfg) -> startCad() --- +// +// SetCadParams (0x020D), 9 bytes: [0-1] opcode [2] symNum [3] detPeak [4] detMin [5] exit mode +// [6-8] timeout, 24-bit big-endian. Unlike the LR2021 (lr2021_tests.h), RadioLib's LR11x0 exit +// defines are already the chip's values (STBY_RC 0x00, RX 0x01, LBT 0x10), so these pin them against +// a regression, e.g. an LR2021 "fix" copied across the LRxxxx family the wrong way round. + +static ChannelScanConfig_t lr11x0CadConfig(uint8_t exitMode = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT) +{ + return {.cad = {.symNum = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT, + .detPeak = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT, + .detMin = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT, + .exitMode = exitMode, + .timeout = 0, + .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, + .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; +} + +// As on the LR2021, startCad() indexes det_peak by SF - 5 and the SF starts at 0 without begin(). +static void lr11x0SetSf(RecordingHal &hal, LR1121 &radio, uint8_t sf) +{ + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSpreadingFactor(sf)); + hal.transactions.clear(); +} + +static const std::vector &lr11x0CadParams(const RecordingHal &hal) +{ + const auto *t = hal.last(op16(RADIOLIB_LR11X0_CMD_SET_CAD_PARAMS)); + TEST_ASSERT_NOT_NULL_MESSAGE(t, "no SetCadParams sent"); + TEST_ASSERT_EQUAL_UINT32(9, t->size()); + return *t; +} + +static void test_lr11x0_cad_exit_defines_match_datasheet() +{ + TEST_ASSERT_EQUAL_UINT8(0x00, RADIOLIB_LR11X0_CAD_EXIT_MODE_STBY_RC); + TEST_ASSERT_EQUAL_UINT8(0x01, RADIOLIB_LR11X0_CAD_EXIT_MODE_RX); + TEST_ASSERT_EQUAL_UINT8(0x10, RADIOLIB_LR11X0_CAD_EXIT_MODE_LBT); +} + +// develop's config at SF9: the default exit is standby (0x00), the byte LR11x0Interface sends today. +static void test_lr11x0_cad_default_config_frame() +{ + LR11X0_RADIO(hal); + lr11x0SetSf(hal, radio, 9); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr11x0CadConfig())); + const auto &t = lr11x0CadParams(hal); + TEST_ASSERT_EQUAL_UINT8(2, t[2]); // symNum: sentinel -> 2 + TEST_ASSERT_EQUAL_UINT8(55, t[3]); // detPeak, SF9 entry of RadioLib's table + TEST_ASSERT_EQUAL_UINT8(10, t[4]); // detMin + TEST_ASSERT_EQUAL_UINT8(0x00, t[5]); // STBY_RC + const uint8_t noTimeout[] = {0, 0, 0}; + TEST_ASSERT_EQUAL_UINT8_ARRAY(noTimeout, t.data() + 6, 3); + TEST_ASSERT_EQUAL_UINT32(1, hal.count(op16(RADIOLIB_LR11X0_CMD_SET_CAD))); +} + +// The RX exit define reaches the chip as 0x01, CAD-to-RX, the byte a CAD-to-RX handoff relies on. +static void test_lr11x0_cad_rx_exit_byte_sent() +{ + LR11X0_RADIO(hal); + lr11x0SetSf(hal, radio, 9); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr11x0CadConfig(RADIOLIB_LR11X0_CAD_EXIT_MODE_RX))); + TEST_ASSERT_EQUAL_UINT8(0x01, lr11x0CadParams(hal)[5]); +} + +// A refused SetCadParams (CMD_PERR) returns -706 and starts no CAD, the same contract as the LR2021: +// LR11x0Interface::isChannelActive() also reads any error but WRONG_MODEM as a free channel. +static void test_lr11x0_cad_params_rejected_returns_spi_cmd_invalid() +{ + LR11X0_RADIO(hal); + lr11x0SetSf(hal, radio, 9); + hal.reply(op16(RADIOLIB_LR11X0_CMD_SET_CAD_PARAMS), 0x04, {0x02}, 1); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_SPI_CMD_INVALID, radio.startChannelScan(lr11x0CadConfig())); + TEST_ASSERT_EQUAL_UINT32(0, hal.count(op16(RADIOLIB_LR11X0_CMD_SET_CAD))); +} + // Grows here - each needs replies scripted per opcode: // begin(): getVersion() device-type check // updateFirmware(): enter bootloader, report RADIOLIB_LR11X0_DEVICE_BOOT, then normal. Pins every @@ -77,7 +153,6 @@ static void test_lr11x0_other_setters_and_modes_succeed() // chunk in 7.8.0) and a failed chunk being reported (its result is currently dropped) // readData() / getPacketLength(): a chip-reported length longer than the caller's buffer // getRSSI() / getSNR() / getPacketStatus(): decoding of known reply bytes -// scanChannel(): the CAD parameters sent static void runLr11x0Tests() { @@ -86,4 +161,8 @@ static void runLr11x0Tests() RUN_TEST(test_lr11x0_coding_rate_long_interleaves_except_4_7); RUN_TEST(test_lr11x0_packet_setters_send_packet_params); RUN_TEST(test_lr11x0_other_setters_and_modes_succeed); + RUN_TEST(test_lr11x0_cad_exit_defines_match_datasheet); + RUN_TEST(test_lr11x0_cad_default_config_frame); + RUN_TEST(test_lr11x0_cad_rx_exit_byte_sent); + RUN_TEST(test_lr11x0_cad_params_rejected_returns_spi_cmd_invalid); } diff --git a/test/test_radiolib_drivers/lr2021_tests.h b/test/test_radiolib_drivers/lr2021_tests.h index ee8dd23403..ec3066f783 100644 --- a/test/test_radiolib_drivers/lr2021_tests.h +++ b/test/test_radiolib_drivers/lr2021_tests.h @@ -29,7 +29,7 @@ static void lr2021Tune(RecordingHal &hal, LR2021 &radio) // As LR11x0: the packet type comes back in the data byte of the next transaction. 0x00 is LoRa. #define LR2021_RADIO(hal) \ RecordingHal &hal = freshHal(); \ - hal.reply(op16(RADIOLIB_LR2021_CMD_GET_PACKET_TYPE), 0x04, {0x04, RADIOLIB_LR2021_PACKET_TYPE_LORA}, true); \ + hal.reply(op16(RADIOLIB_LR2021_CMD_GET_PACKET_TYPE), 0x04, {0x04, RADIOLIB_LR2021_PACKET_TYPE_LORA}, 1); \ Module mod(&hal, 1, RADIOLIB_NC, RADIOLIB_NC, 2); \ LR2021 radio(&mod); \ lr2021Tune(hal, radio) @@ -135,13 +135,301 @@ static void test_lr2021_other_setters_and_modes_succeed() TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR2021_CMD_SET_RX)) >= 1); } +// --- CAD: LR20x0Interface::isChannelActive() -> startChannelScan(cfg) -> startCad() --- +// +// LoRa CAD on the LR2021 is SetLoraCadParams (0x0227) + SetLoraCad (0x0228), 9 bytes: +// [0-1] opcode [2] nb_symbols [3] rfu(7:5) pbl_any(4) pnr_delta(3:0) [4] exit mode +// [5-7] timeout, 24-bit big-endian [8] det_peak & 0x7F (LR20xx DS 2.2, Table 6-17) +// Exit mode for this command is CAD_ONLY 0x00, CAD_RX 0x01, CAD_LBT 0x10 (Table 6-18). RadioLib's +// RADIOLIB_LR2021_CAD_EXIT_MODE_* hold the SetCadParams (0x021B) values instead, so its _RX is 0x02. +// On hardware the chip rejects 0x02 with CMD_PERR, scanChannel() returns -706, and +// LR20x0Interface::isChannelActive() reads that as a free channel, so a node sending it would transmit over +// live frames (16/16 trials on the bench). develop does not: it sends 0x01 from its own local +// CAD_EXIT_MODE_RX, which is what these tests keep it doing. +// +// Never call scanChannel() here: it spins on digitalRead(irq), which RecordingHal holds at 0. + +// Every field at its default sentinel, as LR20x0Interface::isChannelActive() builds it. +static ChannelScanConfig_t lr2021CadConfig(uint8_t exitMode = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + uint8_t detPeak = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + uint8_t symNum = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, RadioLibTime_t timeout = 0) +{ + return {.cad = {.symNum = symNum, + .detPeak = detPeak, + .detMin = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + .exitMode = exitMode, + .timeout = timeout, + .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, + .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; +} + +// LRxxxx::spreadingFactor starts at 0 and startCad() indexes its det_peak table by SF - 5, so a +// default-detPeak scan before any setSpreadingFactor() reads out of bounds. begin() would set it. +static void lr2021SetSf(RecordingHal &hal, LR2021 &radio, uint8_t sf) +{ + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSpreadingFactor(sf)); + hal.transactions.clear(); +} + +// The SetLoraCadParams frame of a scan that must have reached the chip. +static const std::vector &lr2021CadParams(const RecordingHal &hal) +{ + const auto *t = hal.last(op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD_PARAMS)); + TEST_ASSERT_NOT_NULL_MESSAGE(t, "no SetLoraCadParams sent"); + TEST_ASSERT_EQUAL_UINT32(9, t->size()); + return *t; +} + +static void test_lr2021_cad_exit_defines_are_setcadparams_values() +{ + // These are the SetCadParams (0x021B) values and correct for that command; they must not be + // "fixed" to the LoRa ones. The bug is startCad() sending them to 0x0227. + TEST_ASSERT_EQUAL_UINT8(0x00, RADIOLIB_LR2021_CAD_EXIT_MODE_FALLBACK); + TEST_ASSERT_EQUAL_UINT8(0x01, RADIOLIB_LR2021_CAD_EXIT_MODE_TX); + TEST_ASSERT_EQUAL_UINT8(0x02, RADIOLIB_LR2021_CAD_EXIT_MODE_RX); +} + +// develop's config, SF9. A default exit mode must reach the chip as CAD_ONLY (0x00) on every pin: +// it is the only exit byte develop sends, and the one that works on hardware today. +static void test_lr2021_cad_default_config_frame() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr2021CadConfig())); + const auto &t = lr2021CadParams(hal); + TEST_ASSERT_EQUAL_UINT8(2, t[2]); // nb_symbols: sentinel -> 2 + TEST_ASSERT_EQUAL_UINT8(0x00, t[3]); // pbl_any 0, pnr_delta 0 + TEST_ASSERT_EQUAL_UINT8(0x00, t[4]); // CAD_ONLY + const uint8_t noTimeout[] = {0, 0, 0}; + TEST_ASSERT_EQUAL_UINT8_ARRAY(noTimeout, t.data() + 5, 3); + TEST_ASSERT_EQUAL_UINT8(58, t[8]); // det_peak, SF9 row +} + +// The exit byte is passed through unmasked and unmapped, whatever the caller asks for. 0x02 is the +// byte the LBT branches send today via RADIOLIB_LR2021_CAD_EXIT_MODE_RX, and the one the chip rejects. +static void test_lr2021_cad_exit_byte_sent_verbatim() +{ + const uint8_t modes[] = {0x00, 0x01, 0x10, RADIOLIB_LR2021_CAD_EXIT_MODE_RX}; + for (uint8_t mode : modes) { + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr2021CadConfig(mode))); + TEST_ASSERT_EQUAL_UINT8(mode, lr2021CadParams(hal)[4]); + } +} + +// RadioLib's 2-symbol det_peak row (DS Table 6-19), used when the caller passes the sentinel. +static void test_lr2021_cad_default_det_peak_per_sf() +{ + const uint8_t expected[] = {56, 56, 56, 58, 58, 60, 64, 68}; + for (uint8_t sf = 5; sf <= 12; sf++) { + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, sf); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr2021CadConfig())); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(expected[sf - 5], lr2021CadParams(hal)[8], "det_peak for this SF"); + } +} + +// Pins today: the default det_peak is the 2-symbol value whatever nb_symbols is. Table 6-19 gives +// 51 for 4 symbols at SF7, so a caller scanning more than 2 symbols must pass its own det_peak. +static void test_lr2021_cad_default_det_peak_ignores_symNum() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 7); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr2021CadConfig(RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + RADIOLIB_LR2021_CAD_PARAM_DEFAULT, 4))); + const auto &t = lr2021CadParams(hal); + TEST_ASSERT_EQUAL_UINT8(4, t[2]); + TEST_ASSERT_EQUAL_UINT8(56, t[8]); +} + +// det_peak is 7 bits; 0xFF is the sentinel, so it can never be sent as itself. +static void test_lr2021_cad_explicit_det_peak_sent_masked() +{ + const struct { + uint8_t in, sent; + } cases[] = {{60, 60}, {0x85, 0x05}, {RADIOLIB_LR2021_CAD_PARAM_DEFAULT, 56}}; + for (const auto &c : cases) { + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 7); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, + radio.startChannelScan(lr2021CadConfig(RADIOLIB_LR2021_CAD_PARAM_DEFAULT, c.in))); + TEST_ASSERT_EQUAL_UINT8(c.sent, lr2021CadParams(hal)[8]); + } +} + +// fastCad sets pnr_delta 8; pbl_any (bit 4, "preamble only") stays off either way, which RadioLib +// does on purpose: the datasheet calls it unreliable. +static void test_lr2021_fast_cad_sets_pnr_delta_only() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + radio.fastCad = true; + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr2021CadConfig())); + TEST_ASSERT_EQUAL_UINT8(RADIOLIB_LR2021_LORA_CAD_PNR_DELTA_FAST, lr2021CadParams(hal)[3]); +} + +// Pins RadioLib's arithmetic: timeout_us / 30.52 (32.768 kHz ticks). The timeout only matters under +// CAD_RX / CAD_LBT. DS 2.2 6.3.11 states the unit as "periods of 32 MHz crystal oscillator", which +// this does not obviously match; if this test breaks, check the new unit on hardware before +// updating it. +static void test_lr2021_cad_timeout_in_30_52_us_steps() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, + radio.startChannelScan(lr2021CadConfig(0x01, RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + RADIOLIB_LR2021_CAD_PARAM_DEFAULT, 100000))); + const uint8_t ticks[] = {0x00, 0x0C, 0xCC}; // 3276 + TEST_ASSERT_EQUAL_UINT8_ARRAY(ticks, lr2021CadParams(hal).data() + 5, 3); +} + +// The LoRa CAD pair, never the RSSI-based SetCadParams/SetCad (0x021B/0x021C), in this order. +// The packet-type reads and status checks are opcode-less (NOP) transactions and are skipped. +static void test_lr2021_cad_command_sequence() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr2021CadConfig())); + std::vector ops; + for (const auto &t : hal.transactions) + if (t.size() >= 2 && (t[0] | t[1])) + ops.push_back(static_cast(t[0] << 8 | t[1])); + const uint16_t expected[] = {RADIOLIB_LR2021_CMD_GET_PACKET_TYPE, RADIOLIB_LR2021_CMD_SET_STANDBY, + RADIOLIB_LR2021_CMD_SET_DIO_IRQ_CONFIG, RADIOLIB_LR2021_CMD_CLEAR_IRQ, + RADIOLIB_LR2021_CMD_GET_PACKET_TYPE, RADIOLIB_LR2021_CMD_SET_LORA_CAD_PARAMS, + RADIOLIB_LR2021_CMD_SET_LORA_CAD}; + TEST_ASSERT_EQUAL_UINT32(sizeof(expected) / sizeof(expected[0]), ops.size()); + TEST_ASSERT_EQUAL_HEX16_ARRAY(expected, ops.data(), ops.size()); + TEST_ASSERT_EQUAL_UINT32(0, hal.count(op16(RADIOLIB_LR2021_CMD_SET_CAD_PARAMS))); + TEST_ASSERT_EQUAL_UINT32(0, hal.count(op16(RADIOLIB_LR2021_CMD_SET_CAD))); +} + +// CAD_DONE and CAD_DETECTED routed to DIO5, which is LR2021::irqDioNum's default in RadioLib. The firmware +// sets irqDioNum per variant (LR20x0Interface::init()), so this pins RadioLib's default, not that line. +static void test_lr2021_cad_irq_routed_to_dio() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr2021CadConfig())); + const auto *t = hal.first(op16(RADIOLIB_LR2021_CMD_SET_DIO_IRQ_CONFIG)); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT32(7, t->size()); // opcode(2) + dio + irq(4) + TEST_ASSERT_EQUAL_UINT8(5, (*t)[2]); + const uint32_t irq = (uint32_t)(*t)[3] << 24 | (uint32_t)(*t)[4] << 16 | (uint32_t)(*t)[5] << 8 | (*t)[6]; + TEST_ASSERT_EQUAL_HEX32(RADIOLIB_LR2021_IRQ_CAD_DONE | RADIOLIB_LR2021_IRQ_CAD_DETECTED, irq); +} + +static void test_lr2021_cad_wrong_modem_sends_no_cad() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + hal.replies.clear(); + hal.reply(op16(RADIOLIB_LR2021_CMD_GET_PACKET_TYPE), 0x04, {0x04, RADIOLIB_LR2021_PACKET_TYPE_GFSK}, 1); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_WRONG_MODEM, radio.startChannelScan(lr2021CadConfig())); + TEST_ASSERT_EQUAL_UINT32(0, hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD_PARAMS))); + TEST_ASSERT_EQUAL_UINT32(0, hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD))); +} + +// Answers getChannelScanResult()'s IRQ read with `irq`. That read is an opcode-less 6-byte transfer +// (status(2), irq(4) big-endian), the second after a packet-type request. The packet type is also +// asked before standby and before SetLoraCadParams; those writes get the same bytes, which as a +// status byte (0x04) read as CMD_OK and leave them unaffected. +static void lr2021AnswerIrq(RecordingHal &hal, uint32_t irq) +{ + hal.reply(op16(RADIOLIB_LR2021_CMD_GET_PACKET_TYPE), 0x04, + {0x04, 0x04, (uint8_t)(irq >> 24), (uint8_t)(irq >> 16), (uint8_t)(irq >> 8), (uint8_t)irq}, 2); +} + +// The LR2021 as measured on the bench: SetLoraCadParams takes exit byte 0x00, 0x01 or 0x10 and +// refuses anything else with CMD_PERR (stat1 0x02), which RadioLib reports as -706. +static bool lr2021AcceptsLoraCadExit(uint8_t exitMode) +{ + return exitMode == 0x00 || exitMode == 0x01 || exitMode == 0x10; +} + +static void lr2021ModelCadExitCheck(RecordingHal &hal) +{ + hal.rejectUnless( + op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD_PARAMS), + [](const std::vector &t) { return t.size() > 4 && lr2021AcceptsLoraCadExit(t[4]); }, 0x02); +} + +// A started scan, then getChannelScanResult() with `irq` as the chip's IRQ word. +static int16_t lr2021CadResultFor(uint32_t irq) +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + lr2021AnswerIrq(hal, irq); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr2021CadConfig())); + return radio.getChannelScanResult(); +} + +// Only CAD_DONE without CAD_DETECTED is a free channel; no CAD_DONE at all is an error, not "free". +static void test_lr2021_cad_result_decoding() +{ + TEST_ASSERT_EQUAL_INT16(RADIOLIB_LORA_DETECTED, lr2021CadResultFor(RADIOLIB_LR2021_IRQ_CAD_DETECTED)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_LORA_DETECTED, + lr2021CadResultFor(RADIOLIB_LR2021_IRQ_CAD_DETECTED | RADIOLIB_LR2021_IRQ_CAD_DONE)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_CHANNEL_FREE, lr2021CadResultFor(RADIOLIB_LR2021_IRQ_CAD_DONE)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_UNKNOWN, lr2021CadResultFor(0)); +} + +// The bench failure at the SPI level: against the modelled chip, RadioLib's own RX exit define is +// refused and the scan returns -706. That is neither LORA_DETECTED nor CHANNEL_FREE, so the +// interface has an error to refuse to transmit on. If this starts passing a scan through, RadioLib +// has changed CAD_EXIT_MODE_RX or what startCad() sends; check against DS 2.2 Table 6-18. +static void test_lr2021_cad_rx_exit_define_rejected_returns_spi_cmd_invalid() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + lr2021ModelCadExitCheck(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_SPI_CMD_INVALID, + radio.startChannelScan(lr2021CadConfig(RADIOLIB_LR2021_CAD_EXIT_MODE_RX))); +} + +// A CAD must not start with parameters the chip refused. +static void test_lr2021_cad_not_started_after_rejected_params() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + lr2021ModelCadExitCheck(hal); + (void)radio.startChannelScan(lr2021CadConfig(RADIOLIB_LR2021_CAD_EXIT_MODE_RX)); + TEST_ASSERT_EQUAL_UINT32(1, hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD_PARAMS))); + TEST_ASSERT_EQUAL_UINT32(0, hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD))); +} + +// The exit bytes the chip accepts start a CAD; guards the model itself, so the rejection tests +// above cannot pass because the model refuses everything. +static void test_lr2021_cad_accepted_exit_bytes_start_a_cad() +{ + const uint8_t modes[] = {0x00, 0x01, 0x10}; + for (uint8_t mode : modes) { + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + lr2021ModelCadExitCheck(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startChannelScan(lr2021CadConfig(mode))); + TEST_ASSERT_EQUAL_UINT32(1, hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD))); + } +} + +// The same path with the other failure status: (stat1 & 0x0E) == 0 is CMD_FAIL, -707. +static void test_lr2021_cad_params_failed_returns_spi_cmd_failed() +{ + LR2021_RADIO(hal); + lr2021SetSf(hal, radio, 9); + hal.reply(op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD_PARAMS), 0x04, {0x01}, 1); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_SPI_CMD_FAILED, radio.startChannelScan(lr2021CadConfig())); + TEST_ASSERT_EQUAL_UINT32(0, hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD))); +} + // Grows here - each needs replies scripted per opcode: // begin(): getVersion() check and the calibration sequence // resetDCDCworkaround() via setPacketType(): only begin() reaches it; same one-word write as above // setPaTable(): custom LF table entry chosen per output power (cf. meshtastic/firmware#11980) // readData() / getPacketLength(): a chip-reported length longer than the caller's buffer // getRSSI() / getSNR() / getPacketStatus(): decoding of known reply bytes -// scanChannel(): the CAD parameters sent (LR2021 has two CAD commands) +// LR20x0Interface::isChannelActive() is driven in lr20x0_interface_tests.h static void runLr2021Tests() { @@ -155,4 +443,20 @@ static void runLr2021Tests() RUN_TEST(test_lr2021_coding_rate_long_interleaves_except_4_7); RUN_TEST(test_lr2021_packet_setters_send_packet_params); RUN_TEST(test_lr2021_other_setters_and_modes_succeed); + RUN_TEST(test_lr2021_cad_exit_defines_are_setcadparams_values); + RUN_TEST(test_lr2021_cad_default_config_frame); + RUN_TEST(test_lr2021_cad_exit_byte_sent_verbatim); + RUN_TEST(test_lr2021_cad_default_det_peak_per_sf); + RUN_TEST(test_lr2021_cad_default_det_peak_ignores_symNum); + RUN_TEST(test_lr2021_cad_explicit_det_peak_sent_masked); + RUN_TEST(test_lr2021_fast_cad_sets_pnr_delta_only); + RUN_TEST(test_lr2021_cad_timeout_in_30_52_us_steps); + RUN_TEST(test_lr2021_cad_command_sequence); + RUN_TEST(test_lr2021_cad_irq_routed_to_dio); + RUN_TEST(test_lr2021_cad_wrong_modem_sends_no_cad); + RUN_TEST(test_lr2021_cad_result_decoding); + RUN_TEST(test_lr2021_cad_rx_exit_define_rejected_returns_spi_cmd_invalid); + RUN_TEST(test_lr2021_cad_not_started_after_rejected_params); + RUN_TEST(test_lr2021_cad_accepted_exit_bytes_start_a_cad); + RUN_TEST(test_lr2021_cad_params_failed_returns_spi_cmd_failed); } diff --git a/test/test_radiolib_drivers/lr20x0_interface_tests.h b/test/test_radiolib_drivers/lr20x0_interface_tests.h new file mode 100644 index 0000000000..b1b0fb5c17 --- /dev/null +++ b/test/test_radiolib_drivers/lr20x0_interface_tests.h @@ -0,0 +1,125 @@ +#pragma once + +// LR20x0Interface::isChannelActive() in src/mesh/LR20x0Interface.cpp, run against the modelled LR2021 +// in lr2021_tests.h. The tests there pin what RadioLib sends for a given config; these pin the config +// the firmware builds. A detected preamble must be reported busy, and that needs a CAD the chip +// accepts. develop sends exit byte 0x01 from its own local CAD_EXIT_MODE_RX and is fine; an interface +// that sent RadioLib's RADIOLIB_LR2021_CAD_EXIT_MODE_RX (0x02) instead would have its scan refused +// (-706), read the error as a free channel, and transmit over live frames - 16/16 on the bench, and +// 539 scans without a busy verdict on a Mesh-Tracker X1. These tests fail on that change. + +#include "RecordingHal.h" +#include "TestUtil.h" +#include "lr2021_tests.h" +#include "mesh/LR2021Interface.h" +#include "mesh/MeshRadio.h" +#include "mesh/NodeDB.h" + +// A LockingArduinoHal, as the interface requires, that forwards everything to the suite's RecordingHal. +// spiBeginTransaction() skips the base class, which takes spiLock: nothing here shares the bus. +class RecordingLockingHal : public LockingArduinoHal +{ + public: + explicit RecordingLockingHal(RecordingHal &rec) : LockingArduinoHal(SPI, SPISettings()), rec(rec) {} + + void init() override {} + void term() override {} + void pinMode(uint32_t pin, uint32_t mode) override { rec.pinMode(pin, mode); } + void digitalWrite(uint32_t pin, uint32_t value) override { rec.digitalWrite(pin, value); } + uint32_t digitalRead(uint32_t pin) override { return rec.digitalRead(pin); } + void attachInterrupt(uint32_t num, void (*cb)(void), uint32_t mode) override { rec.attachInterrupt(num, cb, mode); } + void detachInterrupt(uint32_t num) override { rec.detachInterrupt(num); } + void delay(RadioLibTime_t ms) override { rec.delay(ms); } + void delayMicroseconds(RadioLibTime_t us) override { rec.delayMicroseconds(us); } + RadioLibTime_t millis() override { return rec.millis(); } + RadioLibTime_t micros() override { return rec.micros(); } + long pulseIn(uint32_t pin, uint32_t state, RadioLibTime_t timeout) override { return rec.pulseIn(pin, state, timeout); } + void spiBegin() override {} + void spiBeginTransaction() override {} + void spiTransfer(uint8_t *out, size_t len, uint8_t *in) override { rec.spiTransfer(out, len, in); } + void spiEndTransaction() override {} + void spiEnd() override {} + void yield() override {} + uint32_t pinToInterrupt(uint32_t pin) override { return pin; } + + private: + RecordingHal &rec; +}; + +static constexpr uint32_t LR20X0_TEST_IRQ_PIN = 7; + +class TestLr2021Interface : public LR2021Interface +{ + public: + explicit TestLr2021Interface(RecordingLockingHal *hal) + : LR2021Interface(hal, 1, LR20X0_TEST_IRQ_PIN, RADIOLIB_NC, RADIOLIB_NC) + { + } + + using LR20x0Interface::isChannelActive; + + // init() would set the SF via begin(); RadioLib's default det_peak lookup needs one (see lr2021SetSf). + void setSpreadingFactor(uint8_t sf) { TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, lora.setSpreadingFactor(sf)); } +}; + +// One interface for the whole suite, never destroyed: the constructor registers a worker thread and +// sets RadioLibInterface::instance, and a failed assertion would skip a test-local destructor anyway. +// Each test gets the shared HAL reset, the chip modelled, and its IRQ line high so scanChannel() +// returns. +static TestLr2021Interface &lr20x0Interface(RecordingHal &hal) +{ + static RecordingLockingHal lockingHal(hal); + // RadioInterface's constructor computes the slot time from myRegion, which nothing else here sets. + static TestLr2021Interface *iface = [] { + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + return new TestLr2021Interface(&lockingHal); + }(); + hal.reply(op16(RADIOLIB_LR2021_CMD_GET_PACKET_TYPE), 0x04, {0x04, RADIOLIB_LR2021_PACKET_TYPE_LORA}, 1); + lr2021ModelCadExitCheck(hal); + hal.irqPin = LR20X0_TEST_IRQ_PIN; + iface->setSpreadingFactor(9); + hal.transactions.clear(); + return *iface; +} + +// The exit byte the interface sends is one the chip takes, and the CAD it asks for actually starts. +static void test_lr20x0_isChannelActive_starts_a_cad_the_chip_accepts() +{ + RecordingHal &hal = freshHal(); + auto &iface = lr20x0Interface(hal); + lr2021AnswerIrq(hal, RADIOLIB_LR2021_IRQ_CAD_DONE); + (void)iface.isChannelActive(); + const auto &t = lr2021CadParams(hal); + TEST_ASSERT_TRUE_MESSAGE(lr2021AcceptsLoraCadExit(t[4]), "SetLoraCadParams exit byte the LR2021 refuses (0x02 -> -706)"); + TEST_ASSERT_EQUAL_UINT32(1, hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_CAD))); + TEST_ASSERT_EQUAL_UINT32(0, hal.count(op16(RADIOLIB_LR2021_CMD_SET_CAD))); +} + +// The bench failure as a test: a preamble on the air must read as busy, so the caller defers. +static void test_lr20x0_isChannelActive_reports_a_detected_preamble_busy() +{ + RecordingHal &hal = freshHal(); + auto &iface = lr20x0Interface(hal); + lr2021AnswerIrq(hal, RADIOLIB_LR2021_IRQ_CAD_DETECTED | RADIOLIB_LR2021_IRQ_CAD_DONE); + TEST_ASSERT_TRUE_MESSAGE(iface.isChannelActive(), "CAD detected a preamble, interface reported the channel free"); +} + +static void test_lr20x0_isChannelActive_reports_cad_done_free() +{ + RecordingHal &hal = freshHal(); + auto &iface = lr20x0Interface(hal); + lr2021AnswerIrq(hal, RADIOLIB_LR2021_IRQ_CAD_DONE); + TEST_ASSERT_FALSE(iface.isChannelActive()); +} + +// Grows here: +// a scan that returns an error (-706, -707, -705) must not read as a free channel. Today every +// driver's isChannelActive() returns false on it; which verdict replaces that is undecided. + +static void runLr20x0InterfaceTests() +{ + RUN_TEST(test_lr20x0_isChannelActive_starts_a_cad_the_chip_accepts); + RUN_TEST(test_lr20x0_isChannelActive_reports_a_detected_preamble_busy); + RUN_TEST(test_lr20x0_isChannelActive_reports_cad_done_free); +} diff --git a/test/test_radiolib_drivers/test_main.cpp b/test/test_radiolib_drivers/test_main.cpp index b6846c2efd..a0fd0716e4 100644 --- a/test/test_radiolib_drivers/test_main.cpp +++ b/test/test_radiolib_drivers/test_main.cpp @@ -14,7 +14,17 @@ // the LR2021 DC-DC workaround passes sizeof(uint32_t) as a word count, overrunning the stack on // every setRxPath() and LoRa modulation change. The pin here, 7.7.1, predates that workaround, so // the LR2021 DC-DC tests register only on a RadioLib that has it (see lr2021_tests.h). On one that -// also carries the overrun (7.8.0) they abort, so the LR2021 set runs last and the rest report first. +// also carries the overrun (7.8.0) they abort, so the LR2021 set and the LR20x0Interface set, whose +// setup runs the same workaround, run last and the rest report first. +// +// CAD (channel activity detection, the listen-before-talk check) is pinned per family: the command +// bytes, and that a command the chip refuses comes back as an error, never CHANNEL_FREE. The LR2021 +// refuses SetLoraCadParams exit mode 0x02, the value of RadioLib's RADIOLIB_LR2021_CAD_EXIT_MODE_RX +// (a SetCadParams value; jgromes/RadioLib#1882 adds the LoRa ones), and LR20x0Interface reads the +// error as a free channel, so a node sending that define would transmit over live frames. develop +// sends 0x01 from a local constant and does not, which is what is pinned here. lr2021_tests.h +// models that refusal; lr20x0_interface_tests.h runs LR20x0Interface::isChannelActive() against it, +// so a firmware change that sends a refused exit byte fails here. // // Anything that decodes a chip reply (begin(), readData(), getRSSI(), updateFirmware()) needs replies // scripted per opcode; each family's header lists those under "Grows here". @@ -24,6 +34,7 @@ #include "lr11x0_tests.h" #include "lr2021_tests.h" +#include "lr20x0_interface_tests.h" #include "sx126x_tests.h" #include "sx127x_tests.h" #include "sx128x_tests.h" @@ -39,7 +50,8 @@ void setup() runSx127xTests(); runSx128xTests(); runLr11x0Tests(); - runLr2021Tests(); // last: its DC-DC tests abort under ASan on a RadioLib with the #1864 overrun + runLr2021Tests(); // after the rest: its DC-DC tests abort under ASan on a RadioLib with the #1864 overrun + runLr20x0InterfaceTests(); // its setSpreadingFactor() runs that same DC-DC workaround exit(UNITY_END()); } From 162614820f84256ee7e86756ad24755a178d5dd0 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 2 Nov 2026 21:56:50 +0000 Subject: [PATCH 2/3] Pin RadioLib at the #1895 merge (5878f990) (#12277) develop pinned 510e00cf, a commit between the 7.7.1 and 7.8.0 tags and 375 behind RadioLib master. It is where the 7.8.0 bump landed back after being reverted three hours later on 2026-09-28 (11eebcb5, then 8e6a88d0): 7.8.0 carried two buffer overflows, and 7.8.1 is 7.8.0 plus exactly the fixes for them - jgromes/RadioLib#1880 (a sizeof(uint32_t) where a count of 32-bit words was meant, overrunning a stack local by 12 bytes on every setRxPath()) and a writeCommon() buffer-size fix. So 7.8.1 is the first safe revision past the old pin, and this moves past it. 5878f990 is the jgromes/RadioLib#1895 merge. No tag carries it - the newest tag, 7.8.1 (2325a38a), is already 353 commits behind master - so the pin stays a commit hash, as it was before. What the 63-commit jump brings that the tree is waiting on: - LR2021 setRegulatorDCDC()/setRegulatorLDO() and RadioLib's own LR2021 DC-DC sensitivity workaround (#1864), which arrived in 7.8.0. - setStandbyXOSC() on the SX128x, LR11x0, LR2021 and STM32WLx (#1884). The SX126x call already compiled, from #1827, long before the old pin. - LR2021 CAD exit modes (#1882), so the local CAD_EXIT_MODE_RX constant can go. - writeTxBuffer()/clearTxBuffer() on every driver, with stageMode(TX) no longer writing the buffer (#1890). TX is staged by hand here, so nothing regresses. - getMode() on PhysicalLayer and both LR classes (#1895), which replaces reading the chip mode through a spiConfig status-width swap. - The LRxxxx startReceiveDutyCycle overflow fix (#1887), the SX128x blocking-receive timeout fix (#1892), the LRxxxx getStatus read-offset fix, RADIOLIB_LR11X0_STANDBY_XOSC 0x00 -> 0x01, and the SX128x status framing change (#1872), which the driver tests already set spiConfig.statusPos against. Master head is deliberately not the pin. RadioLib merged its 8.0.0-staging branch into master on 2026-10-28 (#1867), and the 312 commits since #1895 rename ChannelScanConfig_t's cad.timeout to rxTimeout and add cad.rxSymbols, remove LR11x0::getFrequencyError() without a PhysicalLayer fallback, and rework the output power offset path across six drivers. The first two do not compile here, and the third sits directly under setOutputPower() and limitPower(). Worth revisiting once 8.0.0 settles: the LR11x0/LR2021 RF switch table restore after a modem switch, and the Module BUSY timeout millis() wrap fix. Every lora.() call site in src/mesh/ resolves at this revision. readChipMode() and readTxFifoLevel() replicate getStatus by hand rather than calling it, so the upstream getStatus offset fix changes nothing here. Claude-Session: https://claude.ai/code/session_011C7D9biGfpxLruq7DFLzsP Co-authored-by: Claude Opus 5 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 053655634b..a9c6bd5f97 100644 --- a/platformio.ini +++ b/platformio.ini @@ -134,7 +134,7 @@ lib_deps = [radiolib_base] lib_deps = # renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib - https://github.com/jgromes/RadioLib/archive/510e00cfb05bbc3c2b7b524262785454944adb6e.zip + https://github.com/jgromes/RadioLib/archive/5878f990bdb4c95c75494a21562c0c5a49659c6f.zip [device-ui_base] lib_deps = From 4da58c3c90d9fbc55da17e56065d8e4f91777c95 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 3 Nov 2026 02:47:31 +0000 Subject: [PATCH 3/3] fix(test): tune the modelled LR2021 before the interface suite sets its SF (#12284) test_radiolib_drivers has been red on develop since the RadioLib pin moved to 5878f990 (#12277): the three lr20x0_interface_tests cases added by #12036 all fail at the same assertion with -12, RADIOLIB_ERR_INVALID_FREQUENCY. Nothing in the firmware is wrong. At the new pin LR2021::setLoRaModulationParams() ends in setDCDCworkaround() (RadioLib#1864), whose last act is setFrequency(this->freqMHz, true). The suite builds the interface without calling init(), so begin() never runs and freqMHz is still 0, and the range check in setFrequency() rejects it. Every setter that reaches the modulation params now needs a frequency first, where before the jump it did not. lr2021_tests.h already handles this: LR2021_RADIO() calls lr2021Tune() for exactly this reason. The interface suite builds its radio by hand and skipped that step, so give it the same tune before it writes the SF. Claude-Session: https://claude.ai/code/session_011C7D9biGfpxLruq7DFLzsP Co-authored-by: Claude Opus 5 --- test/test_radiolib_drivers/lr20x0_interface_tests.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_radiolib_drivers/lr20x0_interface_tests.h b/test/test_radiolib_drivers/lr20x0_interface_tests.h index b1b0fb5c17..bf4211f883 100644 --- a/test/test_radiolib_drivers/lr20x0_interface_tests.h +++ b/test/test_radiolib_drivers/lr20x0_interface_tests.h @@ -60,6 +60,11 @@ class TestLr2021Interface : public LR2021Interface // init() would set the SF via begin(); RadioLib's default det_peak lookup needs one (see lr2021SetSf). void setSpreadingFactor(uint8_t sf) { TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, lora.setSpreadingFactor(sf)); } + + // begin() also tunes before it writes the SF, and that ordering is load-bearing: the SF write ends + // in the DC-DC workaround's closing setFrequency(freqMHz), so without a frequency it fails with + // INVALID_FREQUENCY (see lr2021Tune). + void tune(RecordingHal &hal) { lr2021Tune(hal, lora); } }; // One interface for the whole suite, never destroyed: the constructor registers a worker thread and @@ -78,6 +83,7 @@ static TestLr2021Interface &lr20x0Interface(RecordingHal &hal) hal.reply(op16(RADIOLIB_LR2021_CMD_GET_PACKET_TYPE), 0x04, {0x04, RADIOLIB_LR2021_PACKET_TYPE_LORA}, 1); lr2021ModelCadExitCheck(hal); hal.irqPin = LR20X0_TEST_IRQ_PIN; + iface->tune(hal); iface->setSpreadingFactor(9); hal.transactions.clear(); return *iface;