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 = 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..bf4211f883 --- /dev/null +++ b/test/test_radiolib_drivers/lr20x0_interface_tests.h @@ -0,0 +1,131 @@ +#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)); } + + // 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 +// 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->tune(hal); + 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()); }