From 7fe3176a40d14ee68ad8428264eaaf8cd2ad4c23 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:09:08 +0000 Subject: [PATCH] Radiolib recording hal tests (#11997) * test: exercise RadioLib's drivers through a recording HAL, pinned to RadioLib 7.8.0 test_radiolib_drivers drives the RadioLib calls Meshtastic's radio interfaces make against RecordingHal, which logs every SPI transaction and answers with a success status, a few scripted replies (the packet type the LoRa setters check) and, for SX127x, a register file. One set per chip family - SX126x, SX127x, SX128x, LR11x0, LR2021 - each ending in a list of the reply-dependent tests still to write. SX126x and SX128x get the SPI framing their begin() would set. RadioLib is pinned to 7.8.0 to show the failure: the LR2021 DC-DC workaround (jgromes/RadioLib#1864) passes sizeof(uint32_t) as a word count, and under [env:coverage] ASan aborts the LR2021 set with a stack-buffer-overflow in readRegMem32. The other four sets pass. * test: pin RadioLib to the DC-DC word-count fix NomDeTom/radiolib@3509dbc8e (branch lr2021-dcdc-regmem32-len, on RadioLib master after 7.8.0) passes 1 as the word count at the three DC-DC workaround call sites. The overrun is gone and the LR2021 set runs to completion. test_lr2021_dcdc_freq_lf_write_sends_one_word still fails, on a second RadioLib bug: LRxxxx::writeCommon() sends 4 + 4*len bytes whatever the address width, so every LR2021 WriteRegMem32 (24-bit address) carries one uninitialised trailing byte - 10 bytes where 9 are meant. * test: keep RecordingHal reachable across a failed assertion A failed TEST_ASSERT longjmps out of the test, skipping destructors, so each test's local RecordingHal leaked its transaction log. LeakSanitizer then reported it at exit, and under [env:coverage] a plain assertion failure came out as an ERRORED suite (run 36412456860: 26 Tests 1 Failures, then a 384-byte leak from RecordingHal::spiTransfer). One static HAL, reset at the start of each test, stays reachable, so a failure reports as FAILED. * test: run the RadioLib driver suite against develop's RadioLib pin Revert the pin to develop's 510e00cfb (RadioLib 7.7.1). That RadioLib predates the LR2021 DC-DC workaround, so its three tests would only fail on their own premise there. They now register only when LR2021 has the public setRegulatorDCDC() that jgromes/RadioLib#1864 added with the workaround: any RadioLib carrying it runs them, and none is left ignored. Against 7.8.0 the suite shows the DC-DC overrun (ASan, run 36412379592). Against the DC-DC fix (NomDeTom/radiolib@3509dbc8e) it shows a second bug: every LR2021 WriteRegMem32 sends one byte too many, since LRxxxx::writeCommon() still sizes the frame for a 4-byte address after jgromes/RadioLib@e3af85867 made it 3 on the LR2021 (run 36412456860). Neither is reachable at this pin. * test: pin the coding-rate byte the radio interfaces send LR11x0Interface, LR20x0Interface and SX128xInterface all call setCodingRate(cr, cr != 7), so 4/5 goes out with the long interleaver. The modulation tests used the driver default (short), and only counted frames. Pass the flag the firmware passes, and add a test per chip that reads the coding-rate field back from the last modulation-params frame: 5 for 4/5 long, 3 for 4/7 short (RadioLib 7.7.1's encoding). Also correct test_main.cpp: at the 7.7.1 pin the LR2021 DC-DC tests are not registered, so the set does not fail; the 7.8.0 overrun is now stated as conditional on a RadioLib that carries it. --- test/test_radiolib_drivers/RecordingHal.h | 153 +++++++++++++++++++++ test/test_radiolib_drivers/lr11x0_tests.h | 89 ++++++++++++ test/test_radiolib_drivers/lr2021_tests.h | 158 ++++++++++++++++++++++ test/test_radiolib_drivers/sx126x_tests.h | 114 ++++++++++++++++ test/test_radiolib_drivers/sx127x_tests.h | 73 ++++++++++ test/test_radiolib_drivers/sx128x_tests.h | 99 ++++++++++++++ test/test_radiolib_drivers/test_main.cpp | 46 +++++++ 7 files changed, 732 insertions(+) create mode 100644 test/test_radiolib_drivers/RecordingHal.h create mode 100644 test/test_radiolib_drivers/lr11x0_tests.h create mode 100644 test/test_radiolib_drivers/lr2021_tests.h create mode 100644 test/test_radiolib_drivers/sx126x_tests.h create mode 100644 test/test_radiolib_drivers/sx127x_tests.h create mode 100644 test/test_radiolib_drivers/sx128x_tests.h create mode 100644 test/test_radiolib_drivers/test_main.cpp diff --git a/test/test_radiolib_drivers/RecordingHal.h b/test/test_radiolib_drivers/RecordingHal.h new file mode 100644 index 0000000000..46004a6382 --- /dev/null +++ b/test/test_radiolib_drivers/RecordingHal.h @@ -0,0 +1,153 @@ +#pragma once + +// A stand-in for the radio chip: records every SPI transaction and answers with scripted bytes. +// Shared by every chip family's tests in this suite; see test_main.cpp for why it is enough. + +#include + +#include +#include +#include + +class RecordingHal : public RadioLibHal +{ + public: + // 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 + std::vector head; + uint8_t fill; // every byte after head + }; + + // 0x04 decodes as success on every status-byte family: LRxxxx CMD_OK, SX126x DATA_AVAILABLE, + // SX128x CMD_PROCESSED, and is neither 0x00 nor 0xFF (CHIP_NOT_FOUND). + explicit RecordingHal(uint8_t defaultFill = 0x04) : RadioLibHal(0, 1, 0, 1, 2, 3), defaultFill(defaultFill) {} + + // Back to a fresh chip, keeping the allocation: see freshHal(). + void reset(uint8_t fill = 0x04) + { + transactions.clear(); + replies.clear(); + registerEcho = false; + memset(registers, 0, sizeof(registers)); + defaultFill = fill; + pending = nullptr; + nowUs = 0; + } + + std::vector> transactions; + std::vector replies; + + // SX127x mode: no status byte, and RADIOLIB_SPI_PARANOID reads every register write back, so + // writes are stored and reads answered from the store. + bool registerEcho = false; + uint8_t registers[128] = {}; + + void reply(std::vector prefix, uint8_t fill, std::vector head = {}, bool nextTransaction = false) + { + replies.push_back({std::move(prefix), nextTransaction, std::move(head), fill}); + } + + // Transactions whose leading bytes are `prefix`. + size_t count(const std::vector &prefix) const + { + size_t n = 0; + for (const auto &t : transactions) + n += startsWith(t.data(), t.size(), prefix); + return n; + } + + const std::vector *first(const std::vector &prefix) const + { + for (const auto &t : transactions) + if (startsWith(t.data(), t.size(), prefix)) + return &t; + return nullptr; + } + + const std::vector *last(const std::vector &prefix) const + { + for (auto it = transactions.rbegin(); it != transactions.rend(); ++it) + if (startsWith(it->data(), it->size(), prefix)) + return &*it; + return nullptr; + } + + void pinMode(uint32_t, uint32_t) override {} + void digitalWrite(uint32_t, uint32_t) override {} + uint32_t digitalRead(uint32_t) override { return 0; } // BUSY low + void attachInterrupt(uint32_t, void (*)(void), uint32_t) override {} + void detachInterrupt(uint32_t) override {} + void delay(RadioLibTime_t ms) override { nowUs += ms * 1000; } + void delayMicroseconds(RadioLibTime_t us) override { nowUs += us; } + // Advances on every read, so a RadioLib wait loop always reaches its timeout instead of spinning. + RadioLibTime_t millis() override { return (nowUs += 1000) / 1000; } + RadioLibTime_t micros() override { return nowUs += 1000; } + long pulseIn(uint32_t, uint32_t, RadioLibTime_t) override { return 0; } + void spiBegin() override {} + void spiBeginTransaction() override {} + void spiEndTransaction() override {} + void spiEnd() override {} + + void spiTransfer(uint8_t *out, size_t len, uint8_t *in) override + { + transactions.emplace_back(out, out + len); + if (registerEcho) { + echoRegisters(out, len, in); + return; + } + const Reply *r = pending; + pending = nullptr; + for (const auto &c : replies) { + if (!startsWith(out, len, c.prefix)) + continue; + if (c.nextTransaction) + pending = &c; + else if (!r) + r = &c; + } + 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; + RadioLibTime_t nowUs = 0; + + static bool startsWith(const uint8_t *data, size_t len, const std::vector &prefix) + { + return len >= prefix.size() && std::equal(prefix.begin(), prefix.end(), data); + } + + // SX127x framing: first byte is the address, bit 7 set for a write; the rest is the burst. + void echoRegisters(const uint8_t *out, size_t len, uint8_t *in) + { + const uint8_t addr = out[0] & 0x7F; + in[0] = 0; + for (size_t i = 1; i < len; i++) { + uint8_t ® = registers[(addr + i - 1) & 0x7F]; + if (out[0] & 0x80) + reg = out[i], in[i] = 0; + else + in[i] = reg; + } + } +}; + +// Big-endian opcode bytes, for the 16-bit LRxxxx command set. +inline std::vector op16(uint16_t opcode) +{ + return {static_cast(opcode >> 8), static_cast(opcode & 0xFF)}; +} + +// A failed TEST_ASSERT longjmps out of the test, skipping destructors, so a test-local HAL would leak +// its transaction log and LeakSanitizer would turn every assertion failure into a crash. One static +// instance, reset per test, stays reachable. +inline RecordingHal &freshHal() +{ + static RecordingHal hal; + hal.reset(); + return hal; +} diff --git a/test/test_radiolib_drivers/lr11x0_tests.h b/test/test_radiolib_drivers/lr11x0_tests.h new file mode 100644 index 0000000000..64d311d727 --- /dev/null +++ b/test/test_radiolib_drivers/lr11x0_tests.h @@ -0,0 +1,89 @@ +#pragma once + +// LR11x0 (LR1121): the calls LR11x0Interface makes, minus begin() and updateFirmware(). + +#include "RecordingHal.h" +#include "TestUtil.h" + +// LRxxxx reads answer in a second transaction: status (0x04 = CMD_OK), then data. The LoRa setters +// ask for the packet type first; 0x02 is LoRa, which as a status byte would decode as CMD_PERR, +// 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); \ + Module mod(&hal, 1, RADIOLIB_NC, RADIOLIB_NC, 2); \ + LR1121 radio(&mod) + +static void test_lr11x0_setFrequency_sends_hertz() +{ + LR11X0_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setFrequency(915.0)); + const auto *t = hal.first(op16(RADIOLIB_LR11X0_CMD_SET_RF_FREQUENCY)); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT32(6, t->size()); + const uint8_t hz[] = {0x36, 0x89, 0xCA, 0xC0}; // 915000000 + TEST_ASSERT_EQUAL_UINT8_ARRAY(hz, t->data() + 2, 4); +} + +static void test_lr11x0_lora_modulation_setters_send_modulation_params() +{ + LR11X0_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSpreadingFactor(9)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setBandwidth(250.0)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(5, true)); + TEST_ASSERT_EQUAL_UINT32(3, hal.count(op16(RADIOLIB_LR11X0_CMD_SET_MODULATION_PARAMS))); +} + +// LR11x0Interface passes cr != 7 as the long-interleave flag: 4/7 has no long-interleaver code. +static void test_lr11x0_coding_rate_long_interleaves_except_4_7() +{ + LR11X0_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(5, true)); + const auto *t = hal.last(op16(RADIOLIB_LR11X0_CMD_SET_MODULATION_PARAMS)); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT32(6, t->size()); // opcode(2) + sf, bw, cr, ldro + TEST_ASSERT_EQUAL_UINT8(5, (*t)[4]); // 4/5, long interleaver + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(7, false)); + t = hal.last(op16(RADIOLIB_LR11X0_CMD_SET_MODULATION_PARAMS)); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT8(3, (*t)[4]); // 4/7, short interleaver +} + +static void test_lr11x0_packet_setters_send_packet_params() +{ + LR11X0_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setPreambleLength(16)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCRC(2)); + TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR11X0_CMD_SET_PACKET_PARAMS)) >= 2); +} + +static void test_lr11x0_other_setters_and_modes_succeed() +{ + LR11X0_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSyncWord(0x2B)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setOutputPower(22)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.standby()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startReceive()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.sleep()); + TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR11X0_CMD_SET_LORA_SYNC_WORD)) >= 1); + TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR11X0_CMD_SET_TX_PARAMS)) >= 1); + TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR11X0_CMD_SET_RX)) >= 1); +} + +// 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 +// image word written exactly once (an image that is an exact multiple of 64 words loses its last +// 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() +{ + RUN_TEST(test_lr11x0_setFrequency_sends_hertz); + RUN_TEST(test_lr11x0_lora_modulation_setters_send_modulation_params); + 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); +} diff --git a/test/test_radiolib_drivers/lr2021_tests.h b/test/test_radiolib_drivers/lr2021_tests.h new file mode 100644 index 0000000000..ee8dd23403 --- /dev/null +++ b/test/test_radiolib_drivers/lr2021_tests.h @@ -0,0 +1,158 @@ +#pragma once + +// LR2021: the calls LR20x0Interface makes, minus begin(). The DC-DC workaround tests come first: +// under ASan the first overrun aborts the program, and it should abort on the test that names it. + +#include "RecordingHal.h" +#include "TestUtil.h" +#include + +#include +#include + +// jgromes/RadioLib#1864 added the DC-DC workaround together with a public setRegulatorDCDC(), so that +// method marks a RadioLib the DC-DC tests apply to. Earlier pins (7.7.1 and before) have neither. +template struct HasDcdcWorkaround : std::false_type { +}; +template +struct HasDcdcWorkaround().setRegulatorDCDC())>> : std::true_type { +}; + +// begin() sets a frequency before anything else; without one the DC-DC workaround's closing +// setFrequency(freqMHz) fails with INVALID_FREQUENCY. The log starts after it. +static void lr2021Tune(RecordingHal &hal, LR2021 &radio) +{ + (void)radio.setFrequency(915.0); + hal.transactions.clear(); +} + +// 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); \ + Module mod(&hal, 1, RADIOLIB_NC, RADIOLIB_NC, 2); \ + LR2021 radio(&mod); \ + lr2021Tune(hal, radio) + +// The first transaction carrying `opcode` for register `addr`: 16-bit opcode, then a 24-bit address. +static const std::vector *lr2021RegMemAccess(const RecordingHal &hal, uint16_t opcode, uint32_t addr) +{ + return hal.first({static_cast(opcode >> 8), static_cast(opcode & 0xFF), + static_cast((addr >> 16) & 0xFF), static_cast((addr >> 8) & 0xFF), + static_cast(addr & 0xFF)}); +} + +// setRxBoostedGainMode() -> setRxPath() -> setDCDCworkaround(), the route LR20x0Interface takes. +static void lr2021RunDcdcWorkaround(RecordingHal &hal, LR2021 &radio) +{ + (void)hal; + (void)radio.setRxBoostedGainMode(0); +} + +static void test_lr2021_setRxPath_runs_the_dcdc_workaround() +{ + LR2021_RADIO(hal); + lr2021RunDcdcWorkaround(hal, radio); + // Guards the premise: without these the two word-count tests below would pass vacuously. + TEST_ASSERT_NOT_NULL(lr2021RegMemAccess(hal, RADIOLIB_LR2021_CMD_READ_REG_MEM_32, RADIOLIB_LR2021_REG_DCDC_ADC_CTRL)); + TEST_ASSERT_NOT_NULL(lr2021RegMemAccess(hal, RADIOLIB_LR2021_CMD_WRITE_REG_MEM_32, RADIOLIB_LR2021_REG_DCDC_FREQ_LF)); +} + +static void test_lr2021_dcdc_adc_ctrl_read_asks_for_one_word() +{ + LR2021_RADIO(hal); + lr2021RunDcdcWorkaround(hal, radio); + const auto *req = lr2021RegMemAccess(hal, RADIOLIB_LR2021_CMD_READ_REG_MEM_32, RADIOLIB_LR2021_REG_DCDC_ADC_CTRL); + TEST_ASSERT_NOT_NULL(req); + // READ_REG_MEM_32 request: opcode(2) + address(3) + word count(1) + TEST_ASSERT_EQUAL_UINT32(6, req->size()); + TEST_ASSERT_EQUAL_UINT8(1, (*req)[5]); +} + +static void test_lr2021_dcdc_freq_lf_write_sends_one_word() +{ + LR2021_RADIO(hal); + lr2021RunDcdcWorkaround(hal, radio); + const auto *wr = lr2021RegMemAccess(hal, RADIOLIB_LR2021_CMD_WRITE_REG_MEM_32, RADIOLIB_LR2021_REG_DCDC_FREQ_LF); + TEST_ASSERT_NOT_NULL(wr); + // WRITE_REG_MEM_32: opcode(2) + address(3) + one data word(4); four words is 21 bytes + TEST_ASSERT_EQUAL_UINT32(2 + 3 + 4, wr->size()); +} + +static void test_lr2021_setFrequency_sends_hertz() +{ + LR2021_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setFrequency(915.0)); + const auto *t = hal.first(op16(RADIOLIB_LR2021_CMD_SET_RF_FREQUENCY)); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT32(6, t->size()); + const uint8_t hz[] = {0x36, 0x89, 0xCA, 0xC0}; // 915000000 + TEST_ASSERT_EQUAL_UINT8_ARRAY(hz, t->data() + 2, 4); +} + +static void test_lr2021_lora_modulation_setters_send_modulation_params() +{ + LR2021_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSpreadingFactor(9)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setBandwidth(250.0)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(5, true)); + TEST_ASSERT_EQUAL_UINT32(3, hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_MODULATION_PARAMS))); +} + +// LR20x0Interface passes cr != 7 as the long-interleave flag: 4/7 has no long-interleaver code. +static void test_lr2021_coding_rate_long_interleaves_except_4_7() +{ + LR2021_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(5, true)); + const auto *t = hal.last(op16(RADIOLIB_LR2021_CMD_SET_LORA_MODULATION_PARAMS)); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT32(4, t->size()); // opcode(2) + sf|bw, cr|ldro + TEST_ASSERT_EQUAL_UINT8(5, (*t)[3] >> 4); // 4/5, long interleaver + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(7, false)); + t = hal.last(op16(RADIOLIB_LR2021_CMD_SET_LORA_MODULATION_PARAMS)); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT8(3, (*t)[3] >> 4); // 4/7, short interleaver +} + +static void test_lr2021_packet_setters_send_packet_params() +{ + LR2021_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setPreambleLength(16)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCRC(2)); + TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_PACKET_PARAMS)) >= 2); +} + +static void test_lr2021_other_setters_and_modes_succeed() +{ + LR2021_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSyncWord(0x2B)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setOutputPower(22)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.standby()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startReceive()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.sleep()); + TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR2021_CMD_SET_LORA_SYNCWORD)) >= 1); + TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR2021_CMD_SET_TX_PARAMS)) >= 1); + TEST_ASSERT_TRUE(hal.count(op16(RADIOLIB_LR2021_CMD_SET_RX)) >= 1); +} + +// 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) + +static void runLr2021Tests() +{ + if constexpr (HasDcdcWorkaround::value) { + RUN_TEST(test_lr2021_setRxPath_runs_the_dcdc_workaround); + RUN_TEST(test_lr2021_dcdc_adc_ctrl_read_asks_for_one_word); + RUN_TEST(test_lr2021_dcdc_freq_lf_write_sends_one_word); + } + RUN_TEST(test_lr2021_setFrequency_sends_hertz); + RUN_TEST(test_lr2021_lora_modulation_setters_send_modulation_params); + 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); +} diff --git a/test/test_radiolib_drivers/sx126x_tests.h b/test/test_radiolib_drivers/sx126x_tests.h new file mode 100644 index 0000000000..3b8fca2868 --- /dev/null +++ b/test/test_radiolib_drivers/sx126x_tests.h @@ -0,0 +1,114 @@ +#pragma once + +// SX126x (SX1262): the calls SX126xInterface makes, minus begin(). + +#include "RecordingHal.h" +#include "TestUtil.h" + +// The LoRa setters ask the chip for its packet type first. 0x01 is LoRa, and as a status byte it +// matches no SX126x error code, so the whole reply can be 0x01. +static void sx126xAnswerLora(RecordingHal &hal) +{ + hal.reply({RADIOLIB_SX126X_CMD_GET_PACKET_TYPE}, RADIOLIB_SX126X_PACKET_TYPE_LORA); +} + +// Exposes RadioLib's own status parser, which is protected in SX126x. +struct TestSX1262 : public SX1262 { + using SX1262::SX1262; + static int16_t parseStatus(uint8_t in) { return SPIparseStatus(in); } +}; + +// SX126x sets its SPI framing in begin() (modSetup()), not its constructor, and begin() cannot run +// without a chip. This is the same framing, as of RadioLib 7.8.0. +static void sx126xFraming(Module &mod) +{ + mod.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_ADDR] = Module::BITS_16; + mod.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_CMD] = Module::BITS_8; + mod.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS] = Module::BITS_8; + mod.spiConfig.statusPos = 1; + mod.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_READ] = RADIOLIB_SX126X_CMD_READ_REGISTER; + mod.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_WRITE] = RADIOLIB_SX126X_CMD_WRITE_REGISTER; + mod.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_NOP] = RADIOLIB_SX126X_CMD_NOP; + mod.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_STATUS] = RADIOLIB_SX126X_CMD_GET_STATUS; + mod.spiConfig.stream = true; + mod.spiConfig.parseStatusCb = TestSX1262::parseStatus; +} + +#define SX126X_RADIO(hal) \ + RecordingHal &hal = freshHal(); \ + sx126xAnswerLora(hal); \ + Module mod(&hal, 1, RADIOLIB_NC, RADIOLIB_NC, 2); \ + TestSX1262 radio(&mod); \ + sx126xFraming(mod) + +static void test_sx126x_setFrequency_sends_the_frf_word() +{ + SX126X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setFrequency(915.0)); + // frf = 915 MHz * 2^25 / 32 MHz = 0x39300000 + const auto *t = hal.first({RADIOLIB_SX126X_CMD_SET_RF_FREQUENCY}); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT32(5, t->size()); + const uint8_t frf[] = {0x39, 0x30, 0x00, 0x00}; + TEST_ASSERT_EQUAL_UINT8_ARRAY(frf, t->data() + 1, 4); +} + +static void test_sx126x_lora_modulation_setters_send_modulation_params() +{ + SX126X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSpreadingFactor(9)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setBandwidth(250.0)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(5)); + TEST_ASSERT_EQUAL_UINT32(3, hal.count({RADIOLIB_SX126X_CMD_SET_MODULATION_PARAMS})); +} + +static void test_sx126x_packet_setters_send_packet_params() +{ + SX126X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setPreambleLength(16)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCRC(2)); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX126X_CMD_SET_PACKET_PARAMS}) >= 2); +} + +static void test_sx126x_setSyncWord_writes_a_register() +{ + SX126X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSyncWord(0x2B)); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX126X_CMD_WRITE_REGISTER}) >= 1); +} + +static void test_sx126x_setOutputPower_sends_pa_config_and_tx_params() +{ + SX126X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setOutputPower(22)); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX126X_CMD_SET_PA_CONFIG}) >= 1); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX126X_CMD_SET_TX_PARAMS}) >= 1); +} + +static void test_sx126x_mode_commands_reach_the_chip() +{ + SX126X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.standby()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startReceive()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.sleep()); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX126X_CMD_SET_STANDBY}) >= 1); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX126X_CMD_SET_RX}) >= 1); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX126X_CMD_SET_SLEEP}) >= 1); +} + +// Grows here - each needs replies scripted per opcode, not just a status byte: +// begin(): version string check (SX126X_REG_VERSION_STRING) +// 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, per the part's symNum encoding +// startReceiveDutyCycleAuto(): the RX and sleep periods derived from preamble length + +static void runSx126xTests() +{ + RUN_TEST(test_sx126x_setFrequency_sends_the_frf_word); + RUN_TEST(test_sx126x_lora_modulation_setters_send_modulation_params); + RUN_TEST(test_sx126x_packet_setters_send_packet_params); + RUN_TEST(test_sx126x_setSyncWord_writes_a_register); + RUN_TEST(test_sx126x_setOutputPower_sends_pa_config_and_tx_params); + RUN_TEST(test_sx126x_mode_commands_reach_the_chip); +} diff --git a/test/test_radiolib_drivers/sx127x_tests.h b/test/test_radiolib_drivers/sx127x_tests.h new file mode 100644 index 0000000000..9c0ce7b7d7 --- /dev/null +++ b/test/test_radiolib_drivers/sx127x_tests.h @@ -0,0 +1,73 @@ +#pragma once + +// SX127x (SX1276): the calls RF95Interface makes, minus begin(). Register-mapped, so the HAL runs in +// echo mode and the assertions read the resulting register values. + +#include "RecordingHal.h" +#include "TestUtil.h" + +// The setters check REG_OP_MODE bit 7 for LoRa mode; begin() would have set it. +#define SX127X_RADIO(hal) \ + RecordingHal &hal = freshHal(); \ + hal.registerEcho = true; \ + hal.registers[RADIOLIB_SX127X_REG_OP_MODE] = RADIOLIB_SX127X_LORA | RADIOLIB_SX127X_STANDBY; \ + Module mod(&hal, 1, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC); \ + SX1276 radio(&mod) + +static void test_sx127x_setFrequency_writes_the_frf_registers() +{ + SX127X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setFrequency(915.0)); + // frf = 915 MHz * 2^19 / 32 MHz = 0xE4C000 + TEST_ASSERT_EQUAL_HEX8(0xE4, hal.registers[RADIOLIB_SX127X_REG_FRF_MSB]); + TEST_ASSERT_EQUAL_HEX8(0xC0, hal.registers[RADIOLIB_SX127X_REG_FRF_MID]); + TEST_ASSERT_EQUAL_HEX8(0x00, hal.registers[RADIOLIB_SX127X_REG_FRF_LSB]); +} + +static void test_sx127x_setSpreadingFactor_writes_modem_config_2() +{ + SX127X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSpreadingFactor(9)); + TEST_ASSERT_EQUAL_HEX8(9, hal.registers[RADIOLIB_SX127X_REG_MODEM_CONFIG_2] >> 4); +} + +static void test_sx127x_setSyncWord_writes_the_sync_register() +{ + SX127X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSyncWord(0x2B)); + TEST_ASSERT_EQUAL_HEX8(0x2B, hal.registers[RADIOLIB_SX127X_REG_SYNC_WORD]); +} + +static void test_sx127x_setPreambleLength_writes_both_bytes() +{ + SX127X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setPreambleLength(16)); + TEST_ASSERT_EQUAL_HEX8(0x00, hal.registers[RADIOLIB_SX127X_REG_PREAMBLE_MSB]); + TEST_ASSERT_EQUAL_HEX8(16, hal.registers[RADIOLIB_SX127X_REG_PREAMBLE_LSB]); +} + +static void test_sx127x_other_setters_and_modes_succeed() +{ + SX127X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setBandwidth(250.0)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(5)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setOutputPower(17)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.standby()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startReceive()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.sleep()); +} + +// Grows here - each needs more than an echoing register file: +// begin(): REG_VERSION check and the reset sequence +// readData() / getPacketLength(): REG_RX_NB_BYTES larger than the caller's buffer +// getRSSI() / getSNR(): decoding of REG_PKT_RSSI_VALUE / REG_PKT_SNR_VALUE +// scanChannel(): CAD done/detected flags in REG_IRQ_FLAGS + +static void runSx127xTests() +{ + RUN_TEST(test_sx127x_setFrequency_writes_the_frf_registers); + RUN_TEST(test_sx127x_setSpreadingFactor_writes_modem_config_2); + RUN_TEST(test_sx127x_setSyncWord_writes_the_sync_register); + RUN_TEST(test_sx127x_setPreambleLength_writes_both_bytes); + RUN_TEST(test_sx127x_other_setters_and_modes_succeed); +} diff --git a/test/test_radiolib_drivers/sx128x_tests.h b/test/test_radiolib_drivers/sx128x_tests.h new file mode 100644 index 0000000000..a960ea11ea --- /dev/null +++ b/test/test_radiolib_drivers/sx128x_tests.h @@ -0,0 +1,99 @@ +#pragma once + +// SX128x (SX1280): the calls SX128xInterface makes, minus begin(). + +#include "RecordingHal.h" +#include "TestUtil.h" + +// SX128x sets its SPI framing in begin() (modSetup()), not its constructor, and begin() cannot run +// without a chip. This is the same framing, as of RadioLib 7.8.0, minus the status parser: it is +// private in SX128x, and the HAL never answers with an error status anyway. +static void sx128xFraming(Module &mod) +{ + mod.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_ADDR] = Module::BITS_16; + mod.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_CMD] = Module::BITS_8; + mod.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS] = Module::BITS_8; + mod.spiConfig.statusPos = 0; + mod.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_READ] = RADIOLIB_SX128X_CMD_READ_REGISTER; + mod.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_WRITE] = RADIOLIB_SX128X_CMD_WRITE_REGISTER; + mod.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_NOP] = RADIOLIB_SX128X_CMD_NOP; + mod.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_STATUS] = RADIOLIB_SX128X_CMD_GET_STATUS; + mod.spiConfig.stream = true; + mod.spiConfig.parseStatusCb = nullptr; +} + +// The LoRa setters ask for the packet type first. 0x01 is LoRa and matches no SX128x error code. +#define SX128X_RADIO(hal) \ + RecordingHal &hal = freshHal(); \ + hal.reply({RADIOLIB_SX128X_CMD_GET_PACKET_TYPE}, RADIOLIB_SX128X_PACKET_TYPE_LORA); \ + Module mod(&hal, 1, RADIOLIB_NC, RADIOLIB_NC, 2); \ + SX1280 radio(&mod); \ + sx128xFraming(mod) + +static void test_sx128x_setFrequency_sends_rf_frequency() +{ + SX128X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setFrequency(2440.0)); + const auto *t = hal.first({RADIOLIB_SX128X_CMD_SET_RF_FREQUENCY}); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT32(4, t->size()); // opcode + 24-bit frf +} + +static void test_sx128x_lora_modulation_setters_send_modulation_params() +{ + SX128X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSpreadingFactor(9)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setBandwidth(812.5)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(5, true)); + TEST_ASSERT_EQUAL_UINT32(3, hal.count({RADIOLIB_SX128X_CMD_SET_MODULATION_PARAMS})); +} + +// SX128xInterface passes cr != 7 as the long-interleave flag: 4/7 has no long-interleaver code. +static void test_sx128x_coding_rate_long_interleaves_except_4_7() +{ + SX128X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(5, true)); + const auto *t = hal.last({RADIOLIB_SX128X_CMD_SET_MODULATION_PARAMS}); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT32(4, t->size()); // opcode + sf, bw, cr + TEST_ASSERT_EQUAL_UINT8(5, (*t)[3]); // 4/5, long interleaver + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCodingRate(7, false)); + t = hal.last({RADIOLIB_SX128X_CMD_SET_MODULATION_PARAMS}); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_UINT8(3, (*t)[3]); // 4/7, short interleaver +} + +static void test_sx128x_packet_setters_send_packet_params() +{ + SX128X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setPreambleLength(16)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setCRC(2)); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX128X_CMD_SET_PACKET_PARAMS}) >= 2); +} + +static void test_sx128x_other_setters_and_modes_succeed() +{ + SX128X_RADIO(hal); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setSyncWord(0x12)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.setOutputPower(10)); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.standby()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.startReceive()); + TEST_ASSERT_EQUAL_INT16(RADIOLIB_ERR_NONE, radio.sleep()); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX128X_CMD_SET_TX_PARAMS}) >= 1); + TEST_ASSERT_TRUE(hal.count({RADIOLIB_SX128X_CMD_SET_RX}) >= 1); +} + +// Grows here - each needs replies scripted per opcode: +// begin(): version string check +// readData() / getPacketLength(): a chip-reported length longer than the caller's buffer +// getRSSI() / getSNR() / getPacketStatus(): decoding of known reply bytes +// scanChannel(): CAD parameters and the CAD-done IRQ + +static void runSx128xTests() +{ + RUN_TEST(test_sx128x_setFrequency_sends_rf_frequency); + RUN_TEST(test_sx128x_lora_modulation_setters_send_modulation_params); + RUN_TEST(test_sx128x_coding_rate_long_interleaves_except_4_7); + RUN_TEST(test_sx128x_packet_setters_send_packet_params); + RUN_TEST(test_sx128x_other_setters_and_modes_succeed); +} diff --git a/test/test_radiolib_drivers/test_main.cpp b/test/test_radiolib_drivers/test_main.cpp new file mode 100644 index 0000000000..b6846c2efd --- /dev/null +++ b/test/test_radiolib_drivers/test_main.cpp @@ -0,0 +1,46 @@ +// Unit tests for the RadioLib driver calls Meshtastic's radio interfaces make, one set per chip +// family: SX126x (sx126x_tests.h), SX127x (sx127x_tests.h), SX128x (sx128x_tests.h), LR11x0 +// (lr11x0_tests.h) and LR2021 (lr2021_tests.h), against the RadioLib pinned in platformio.ini. +// +// No chip is needed. RecordingHal (RecordingHal.h) logs every SPI transaction and answers with a +// status byte every status-byte family reads as success, plus the few scripted replies the setters +// check before acting (the packet type); for SX127x it keeps a register file instead. That reaches +// every setter and mode command the interfaces use, and the tests pin what reaches the chip: the +// opcode, the frame length, and where the value is fixed, the payload. +// +// The regressions guarded are driver changes that send the wrong bytes, or read or write past a +// buffer, on paths no hardware-free test reached before. Under [env:coverage] (-fsanitize=address) +// an out-of-bounds access aborts the program at the call. The first such bug is jgromes/RadioLib#1864: +// 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. +// +// Anything that decodes a chip reply (begin(), readData(), getRSSI(), updateFirmware()) needs replies +// scripted per opcode; each family's header lists those under "Grows here". +#include "TestUtil.h" +#include +#include + +#include "lr11x0_tests.h" +#include "lr2021_tests.h" +#include "sx126x_tests.h" +#include "sx127x_tests.h" +#include "sx128x_tests.h" + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + runSx126xTests(); + runSx127xTests(); + runSx128xTests(); + runLr11x0Tests(); + runLr2021Tests(); // last: its DC-DC tests abort under ASan on a RadioLib with the #1864 overrun + exit(UNITY_END()); +} + +void loop() {}