From ace68647982d712217566b3c77553307edc1c182 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 18 Sep 2026 21:48:39 +0100 Subject: [PATCH 1/3] enforce the IRQ mask, and make a scan cost what a scan costs Two things the model accepted and then ignored, both on the path a firmware uses to decide whether to transmit. IrqMask was parsed by SetDioIrqParams and never read again, so the status register answered every event whatever the firmware had enabled. That is forgiving in the direction that hides a fault: a driver waiting on an interrupt it forgot to enable waits for ever on silicon and works perfectly here. Every flag now goes through raiseIrq(), and what the mask keeps out is counted in irq_suppressed, because a masked event is silence and silence looks exactly like a quiet mesh. SetCadParams was not decoded at all, so a scan ran with the reset defaults however it had been configured, answered inside the SPI command, and always came back to standby. A firmware could therefore scan the channel and transmit in the same millisecond, which is listen before talk with the listening taken out: the dwell is the entire price of the technique, 1 to 16 symbols, most of a second at SF12. The scan now holds the chip for that dwell, samples the carrier across it rather than at its ends, and ends in standby. detPeak and detMin are recorded and not applied. They threshold a correlator peak and this model is told whether a carrier is present, not how strong it is. Thresholding a boolean would be the chip inventing a signal level and deciding reception from it, which belongs to the simulator. SetRx's timeout came with it, because enforcing a mask over a bit that can never be set is half a job. Continuous receive and single shot stay distinct from a real deadline. vsx_state and vsx_counters gain fields at the end only; VSX_ABI_MINOR 3 -> 4. No host needs rebuilding to keep working, but a host that never programmed IrqMask now sees an empty register, so the pins want moving with a board booted behind them rather than on green tests here. --- build.sh | 4 +- include/virtual_sx1262.h | 22 +++++ src/VirtualSX1262.cpp | 70 ++++++++++---- src/VirtualSX1262.h | 63 ++++++++++++- src/abi.cpp | 9 +- src/cad.cpp | 75 +++++++++++++++ src/registers.h | 22 +++++ src/spi.cpp | 13 ++- test/harness.h | 3 + test/test_cad.cpp | 195 +++++++++++++++++++++++++++++++++++++++ test/test_interrupts.cpp | 97 +++++++++++++++++++ 11 files changed, 551 insertions(+), 22 deletions(-) create mode 100644 src/cad.cpp create mode 100644 test/test_cad.cpp diff --git a/build.sh b/build.sh index ba338d9..0584200 100755 --- a/build.sh +++ b/build.sh @@ -35,7 +35,7 @@ CXXFLAGS="-std=c++17 -O2 -Iinclude -Isrc $WARN ${CXXFLAGS}" CFLAGS_C="-std=c11 -O2 -Iinclude -Wall -Wextra -Wpedantic ${CFLAGS}" [ -n "$STRICT" ] && CFLAGS_C="$CFLAGS_C -Werror" -SRC="src/VirtualSX1262.cpp src/spi.cpp src/abi.cpp" +SRC="src/VirtualSX1262.cpp src/cad.cpp src/spi.cpp src/abi.cpp" # SHARED_RUNTIME is the toolchain runtime the shared build links against. # @@ -115,7 +115,7 @@ check_shared_imports() { # One binary per subject rather than one big one, because the house limit on # file length applies to tests too and a single file had already outgrown it. # They share test/harness.h and nothing else, so each counts its own failures. -TESTS="test_reception test_interrupts test_spi" +TESTS="test_reception test_interrupts test_spi test_cad" build_tests() { for t in $TESTS; do diff --git a/include/virtual_sx1262.h b/include/virtual_sx1262.h index a84df19..1c506f1 100644 --- a/include/virtual_sx1262.h +++ b/include/virtual_sx1262.h @@ -128,6 +128,18 @@ typedef struct { * than irq_mask: that one says what reaches the status register. Appended at * the end, per the ABI rule, so a host built against 1.0 is unaffected. */ uint16_t dio1_mask; + /* What SetCadParams programmed, reported because a scan's cost and its answer + * both come from here and neither is visible any other way. cad_symbol_num is + * the register value, so the dwell is 1 << it symbols; cad_exit_mode is the + * programmed byte, recorded only: every scan ends in standby. The two + * thresholds are recorded and not applied: + * they threshold a correlator peak, and this model is told whether a carrier + * is present rather than how strong it is, so a host that does know the level + * is the only thing that can honestly act on them. */ + uint8_t cad_symbol_num; + uint8_t cad_det_peak; + uint8_t cad_det_min; + uint8_t cad_exit_mode; } vsx_state; void vsx_get_state(const vsx_chip* chip, vsx_state* out); @@ -139,6 +151,16 @@ typedef struct { uint32_t spurious_raises; uint32_t preamble_raises; uint32_t frames_dropped; /* handed over while deaf, past the grace */ + /* Events the chip had and the firmware never saw, because IrqMask had them + * masked off. A firmware waiting on an interrupt it did not enable looks + * exactly like a quiet mesh, and this is the only thing that tells the two + * apart. */ + uint32_t irq_suppressed; + /* Scans run, and scans that found a carrier. A host comparing a firmware that + * scans against one that does not needs to know the scans happened and what + * they cost, which is cad_runs multiplied by the dwell in vsx_state. */ + uint32_t cad_runs; + uint32_t cad_detections; } vsx_counters; void vsx_get_counters(const vsx_chip* chip, vsx_counters* out); diff --git a/src/VirtualSX1262.cpp b/src/VirtualSX1262.cpp index e34b476..be3f0ed 100644 --- a/src/VirtualSX1262.cpp +++ b/src/VirtualSX1262.cpp @@ -44,6 +44,23 @@ VirtualSX1262::VirtualSX1262() { #endif } +void VirtualSX1262::raiseIrq(uint16_t bits) { + const uint16_t allowed = (uint16_t)(bits & irqMask_); + if (allowed != bits) { + irqSuppressed_++; + } + irq_ |= allowed; +} + +// One symbol, in milliseconds, at whatever the firmware has programmed. +// +// Shared rather than recomputed because three things now time themselves in +// symbols - preamble detection, header detection and a CAD scan - and a second +// copy of this is a second place for the bandwidth to be forgotten. +double VirtualSX1262::symbolMs() const { + return (double)(1u << sf_) / (bwKHz_ > 0 ? bwKHz_ : 250.0); +} + void VirtualSX1262::tick(uint64_t nowMs) { // Time with the busy flags up, which is what the firmware is reacting to. if ((irq_ & (kIrqPreambleDetected | kIrqHeaderValid)) && nowMs > lastBusyTickMs_) { @@ -71,27 +88,42 @@ void VirtualSX1262::tick(uint64_t nowMs) { // That is the difference between MeshCore 1.16 and 1.17, and it cannot be // seen on a chip that never lies. if (stuckIrqMs_ > 0 && mode_ == 1 && nowMs_ >= nextSpuriousMs_) { - irq_ |= kIrqPreambleDetected; + raiseIrq(kIrqPreambleDetected); spuriousRaises_++; nextSpuriousMs_ = nowMs_ + stuckIrqMs_; } settleInbox(); + // A scan in progress is the chip's own business and runs before the receive + // path below bows out, because CAD is not receive mode and would otherwise + // never be looked at again once it started. + tickCad(); + + // The receive deadline, which exists only for a firmware that asked for one. + // Reaching it is a Timeout and a return to standby, exactly as a completed + // reception would be, because a receiver that stops listening has stopped + // listening whichever way it got there. + if (mode_ == 1 && rxDeadlineArmed_ && nowMs_ >= rxDeadlineMs_) { + rxDeadlineArmed_ = false; + mode_ = 0; + raiseIrq(kIrqTimeout); + } + // Preamble and header detection, in receive mode only. A node cannot hear // anything while its own transmitter is keyed - that is half duplex, and the // engine already reports the channel as clear to a node that is transmitting. if (mode_ != 1) return; - const double symbolMs = (double)(1u << sf_) / (bwKHz_ > 0 ? bwKHz_ : 250.0); + const double symMs = symbolMs(); if (channelBusy_) { const double sinceMs = (double)(nowMs_ - busySinceMs_); - if (!preambleRaised_ && sinceMs >= kPreambleSymbols * symbolMs) { - irq_ |= kIrqPreambleDetected; + if (!preambleRaised_ && sinceMs >= kPreambleSymbols * symMs) { + raiseIrq(kIrqPreambleDetected); preambleRaised_ = true; preambleRaises_++; } - if (!headerRaised_ && sinceMs >= kHeaderSymbols * symbolMs) { - irq_ |= kIrqHeaderValid | kIrqSyncWordValid; + if (!headerRaised_ && sinceMs >= kHeaderSymbols * symMs) { + raiseIrq(kIrqHeaderValid | kIrqSyncWordValid); headerRaised_ = true; } } @@ -125,7 +157,7 @@ void VirtualSX1262::setChannelBusy(bool busy) { void VirtualSX1262::transmitFinished() { if (mode_ == 2) { - irq_ |= kIrqTxDone; + raiseIrq(kIrqTxDone); mode_ = 0; } } @@ -184,7 +216,7 @@ void VirtualSX1262::deliverPending() { rxLen_ = (uint8_t)(f.size() > 255 ? 255 : f.size()); memcpy(&buffer_[rxBase_], f.data(), rxLen_); inbox.pop_front(); - irq_ |= kIrqRxDone; + raiseIrq(kIrqRxDone); // Deliberately only RxDone. The detection flags say "a carrier is present", // and by the time a frame is delivered the carrier has ended - raising them // here asserted the presence of a signal at the moment it stopped, which is @@ -213,8 +245,20 @@ void VirtualSX1262::refreshNoise() { noiseNow_ = (uint32_t)z; } -void VirtualSX1262::startRx() { +void VirtualSX1262::startRx(uint32_t timeoutRaw) { mode_ = 1; + + // SetRx carries a 24-bit timeout, and two of its values are not durations. + // All ones is continuous receive and zero is single-shot with no deadline, so + // only what lies between them arms anything. A driver asking for continuous + // and a driver asking for the longest finite timeout write different things + // and mean different things, and collapsing the two is how a receiver that + // should never stop acquires a deadline. + rxDeadlineArmed_ = timeoutRaw != 0 && timeoutRaw != kRxContinuous; + if (rxDeadlineArmed_) { + rxDeadlineMs_ = nowMs_ + (uint64_t)((double)timeoutRaw * kTimeoutStepMs + 0.5); + } + // Arming the receiver is itself a delivery point, not just a change of mode. // // Delivery used to happen only on a tick, which quietly made every reception @@ -238,14 +282,6 @@ void VirtualSX1262::startTx() { hasPendingTx = true; } -void VirtualSX1262::startCad() { - mode_ = 3; - // CAD answers in one go: the chip listens for a couple of symbols and reports. - irq_ |= kIrqCadDone; - if (channelBusy_) irq_ |= kIrqCadDetected; - mode_ = 0; -} - uint32_t VirtualSX1262::estAirtimeMs(int lenBytes) const { // Semtech's own airtime formula, from the parameters the firmware programmed. const double bwHz = bwKHz_ * 1000.0; diff --git a/src/VirtualSX1262.h b/src/VirtualSX1262.h index dea84c8..c4aeb7f 100644 --- a/src/VirtualSX1262.h +++ b/src/VirtualSX1262.h @@ -179,6 +179,28 @@ class VirtualSX1262 { uint32_t preambleRaises() const { return preambleRaises_; } uint32_t spuriousRaises() const { return spuriousRaises_; } + // Events the chip had and the firmware never saw, because IrqMask had them + // masked off. + // + // A masked event is silence, and silence is the one symptom that looks the + // same as nothing having happened. A firmware that forgets to enable the + // interrupt it waits on is indistinguishable from a mesh with nothing on it, + // which is the kind of fault that gets diagnosed as the air being quiet. This + // counter is the difference between those two, and it is the reason enforcing + // the mask is safe to do at all. + uint32_t irqSuppressed() const { return irqSuppressed_; } + + // Channel activity detection, as the firmware asked for it and as it turned + // out. Counted because CAD is a decision the firmware pays for in dwell time, + // and a host comparing a run with it against a run without it needs to know + // the scans actually happened. + uint32_t cadRuns() const { return cadRuns_; } + uint32_t cadDetections() const { return cadDetections_; } + uint8_t cadSymbolNum() const { return cadSymbolNum_; } + uint8_t cadDetPeak() const { return cadDetPeak_; } + uint8_t cadDetMin() const { return cadDetMin_; } + uint8_t cadExitMode() const { return cadExitMode_; } + // Latch a flag once raised, as a misbehaving chip does. // // This is the fault MeshCore 1.17 exists to survive: a preamble or header @@ -215,9 +237,24 @@ class VirtualSX1262 { void runCommand(const uint8_t* out, size_t len, uint8_t* in); void applyModulation(const uint8_t* p); void applyPacketParams(const uint8_t* p); - void startRx(); + void applyCadParams(const uint8_t* p); + void startRx(uint32_t timeoutRaw); void startTx(); void startCad(); + void tickCad(); + void finishCad(); + double symbolMs() const; + + // Record an event in the IRQ status register, if the firmware asked to be + // told about it. + // + // Every flag goes through here rather than touching irq_ directly, because + // IrqMask is not advisory: SetDioIrqParams says what reaches the status + // register, and a chip that records an event the firmware masked off is + // answering a question nobody asked. Modelled because the alternative hides a + // real firmware fault - a driver that waits on an interrupt it never enabled + // waits for ever on silicon and works perfectly here. + void raiseIrq(uint16_t bits); uint64_t inboxGraceMs() const; void settleInbox(); void deliverPending(); @@ -288,11 +325,35 @@ class VirtualSX1262 { uint8_t noiseBits(int bits) const { return (uint8_t)(noiseNow_ & ((1u << bits) - 1)); } uint64_t nowMs_ = 0; + // Receive timeout, from SetRx. + // + // Zero is not a short timeout, it is single-shot receive with no deadline at + // all, and all ones is continuous. Only the values between the two are a + // deadline, which is why this is a flag and an instant rather than a duration. + bool rxDeadlineArmed_ = false; + uint64_t rxDeadlineMs_ = 0; + + // Channel activity detection, as SetCadParams programmed it. + // + // The defaults are the reset values: one symbol, CAD_ONLY, and thresholds of + // zero. A firmware that runs CAD without programming it first gets the + // shortest scan the part offers, which is what the silicon does and is not a + // kindness worth inventing. + uint8_t cadSymbolNum_ = 0; + uint8_t cadDetPeak_ = 0; + uint8_t cadDetMin_ = 0; + uint8_t cadExitMode_ = 0; + uint64_t cadEndMs_ = 0; + bool cadSawCarrier_ = false; + // Instrumentation. uint32_t irqReads_ = 0; uint32_t busyReads_ = 0; uint32_t busyMs_ = 0; uint32_t preambleRaises_ = 0; + uint32_t irqSuppressed_ = 0; + uint32_t cadRuns_ = 0; + uint32_t cadDetections_ = 0; uint64_t lastBusyTickMs_ = 0; // Fault injection: how long a raised flag refuses to clear. 0 is a chip that diff --git a/src/abi.cpp b/src/abi.cpp index c140de3..929d1a2 100644 --- a/src/abi.cpp +++ b/src/abi.cpp @@ -19,7 +19,7 @@ #include #define VSX_ABI_MAJOR 1 -#define VSX_ABI_MINOR 3 +#define VSX_ABI_MINOR 4 struct vsx_chip { VirtualSX1262 chip; @@ -201,6 +201,10 @@ void vsx_get_state(const vsx_chip* chip, vsx_state* out) { /* Three states, because "has not transmitted" is not "transmitted with the * module out". */ out->fem_at_tx = !c.hasTransmitted() ? 0 : (c.femAtTx() ? 2 : 1); + out->cad_symbol_num = c.cadSymbolNum(); + out->cad_det_peak = c.cadDetPeak(); + out->cad_det_min = c.cadDetMin(); + out->cad_exit_mode = c.cadExitMode(); } void vsx_get_counters(const vsx_chip* chip, vsx_counters* out) { @@ -214,6 +218,9 @@ void vsx_get_counters(const vsx_chip* chip, vsx_counters* out) { out->spurious_raises = c.spuriousRaises(); out->preamble_raises = c.preambleRaises(); out->frames_dropped = c.framesDropped(); + out->irq_suppressed = c.irqSuppressed(); + out->cad_runs = c.cadRuns(); + out->cad_detections = c.cadDetections(); } uint32_t vsx_est_airtime_ms(const vsx_chip* chip, int len_bytes) { diff --git a/src/cad.cpp b/src/cad.cpp new file mode 100644 index 0000000..88eba97 --- /dev/null +++ b/src/cad.cpp @@ -0,0 +1,75 @@ +/* Channel activity detection: the other half of listen before talk. + * + * Its own file rather than another few functions in VirtualSX1262.cpp, for the + * reason that file and spi.cpp are already apart: a scan is a thing the part + * does over time, with its own programmed parameters and its own way out, and + * it is changed for different reasons from the receive path it sits beside. + * + * What makes CAD worth modelling properly is that it costs time. A driver runs + * it to find out whether to transmit, and what it pays for the answer is the + * dwell: 1 to 16 symbols, which at SF12 is most of a second and at SF7 is + * nothing. A scan that answers instantly is not a cheaper CAD, it is a + * different radio, and a comparison between a firmware that scans and one that + * does not then measures nothing at all. + */ +#include "VirtualSX1262.h" + +#include "registers.h" + +// SetCadParams: [symbolNum][detPeak][detMin][exitMode][timeout 23:16..7:0]. +// +// exitMode and the timeout are recorded or ignored, not acted on: every scan +// ends in standby. +// +// detPeak and detMin are recorded and not acted on, which is a deliberate stop +// rather than an unfinished one. They are thresholds against a correlator peak, +// and this model is not handed a correlator peak: the simulator tells it +// whether a carrier is present, full stop. Thresholding a boolean would be the +// chip inventing a signal level and then deciding reception from it, which is +// the simulator's job and not ours. Reported instead, so a host that does know +// the signal level can apply them, and so a firmware setting a threshold no +// real part would detect at is visible rather than silently fine. +void VirtualSX1262::applyCadParams(const uint8_t* p) { + cadSymbolNum_ = p[0] > kCadSymbolNumMax ? kCadSymbolNumMax : p[0]; + cadDetPeak_ = p[1]; + cadDetMin_ = p[2]; + cadExitMode_ = p[3]; +} + +// SetCad. The scan starts here and finishes on a later tick. +// +// Nothing is reported at this point, because at this point nothing is known. +// The old model answered inside the command, which meant a firmware could scan +// the channel, get its answer and transmit without a millisecond having passed +// - so listen before talk cost exactly nothing and every arm that used it was +// flattered by however long the scan should have taken. +void VirtualSX1262::startCad() { + mode_ = 3; + cadRuns_++; + // The carrier is sampled across the whole dwell rather than at either end of + // it, so a transmission that starts or stops mid scan is still found. Seeded + // with the state at the instant the scan begins, which is the one sample a + // zero-length dwell would get. + cadSawCarrier_ = channelBusy_; + cadEndMs_ = nowMs_ + (uint64_t)((double)(1u << cadSymbolNum_) * symbolMs() + 0.5); +} + +void VirtualSX1262::tickCad() { + if (mode_ != 3) return; + if (channelBusy_) cadSawCarrier_ = true; + if (nowMs_ < cadEndMs_) return; + finishCad(); +} + +// The end of a scan, and where it leaves the chip. +// +// CadDone always; CadDetected only if a carrier was there to find. Then standby, +// whatever the answer and whatever exit mode was programmed. +void VirtualSX1262::finishCad() { + raiseIrq(kIrqCadDone); + if (cadSawCarrier_) { + cadDetections_++; + raiseIrq(kIrqCadDetected); + } + mode_ = 0; +} diff --git a/src/registers.h b/src/registers.h index 0e40a84..ab5a85c 100644 --- a/src/registers.h +++ b/src/registers.h @@ -57,6 +57,28 @@ namespace { [[maybe_unused]] constexpr uint16_t kIrqCadDetected = 1 << 8; [[maybe_unused]] constexpr uint16_t kIrqTimeout = 1 << 9; +// Untested. +// // CAD exit modes, from SetCadParams. CAD_ONLY drops back to standby whatever it +// // found; CAD_RX goes straight on into receive when it found something, which is +// // the mode a driver uses to avoid re-arming between the scan and the packet it +// // scanned for. +// [[maybe_unused]] constexpr uint8_t kCadExitOnly = 0x00; +// [[maybe_unused]] constexpr uint8_t kCadExitRx = 0x01; + +// The symbol counts SetCadParams selects between, as 1 << cadSymbolNum: the +// register holds 0 to 4 and the scan lasts 1, 2, 4, 8 or 16 symbols. Anything +// above 4 is not a longer scan, it is a value the part does not define, so it is +// clamped rather than shifted with. +[[maybe_unused]] constexpr uint8_t kCadSymbolNumMax = 4; + +// The timeout register's unit, shared by SetRx, SetTx and SetCadParams: one +// count is 15.625 us, which is 1/64 of a millisecond. +[[maybe_unused]] constexpr double kTimeoutStepMs = 1.0 / 64.0; + +// A 24-bit timeout of all ones is not a very long timeout, it is continuous +// receive, and a driver that means "listen until I say otherwise" writes this. +[[maybe_unused]] constexpr uint32_t kRxContinuous = 0xFFFFFF; + // How far into a transmission a receiver locks onto the preamble, and how much // later the header is demodulated. Both are in symbols and become milliseconds // through the current modem settings, because that is what makes them behave diff --git a/src/spi.cpp b/src/spi.cpp index e779281..f6da574 100644 --- a/src/spi.cpp +++ b/src/spi.cpp @@ -116,7 +116,11 @@ void VirtualSX1262::runCommand(const uint8_t* out, size_t len, uint8_t* in) { mode_ = 0; break; case kSetRx: - startRx(); + // The timeout is the three bytes after the opcode. A command that arrives + // without them is a firmware asking for single-shot receive, which is what + // all-zero means, so a short command and an explicit zero agree. + startRx(len >= 4 ? (((uint32_t)out[1] << 16) | ((uint32_t)out[2] << 8) | out[3]) + : 0); break; case kSetTx: startTx(); @@ -167,6 +171,13 @@ void VirtualSX1262::runCommand(const uint8_t* out, size_t len, uint8_t* in) { case kSetPacketParams: if (len >= 7) applyPacketParams(&out[1]); break; + + // The scan's parameters, which decide what a later SetCad costs and where + // it leaves the chip. Ignored until now, so every scan ran with whatever + // the reset defaults were however the firmware had configured it. + case kSetCadParams: + if (len >= 8) applyCadParams(&out[1]); + break; case kSetBufferBase: if (len >= 3) { txBase_ = out[1]; diff --git a/test/harness.h b/test/harness.h index 996f8b8..ee88af7 100644 --- a/test/harness.h +++ b/test/harness.h @@ -41,6 +41,9 @@ enum { IRQ_PREAMBLE = 1u << 2, IRQ_SYNC_WORD = 1u << 3, IRQ_HEADER = 1u << 4, + IRQ_CAD_DONE = 1u << 7, + IRQ_CAD_DETECTED = 1u << 8, + IRQ_TIMEOUT = 1u << 9, }; /* Put the chip in a state a driver would recognise: LoRa at SF8/62.5 kHz, every diff --git a/test/test_cad.cpp b/test/test_cad.cpp new file mode 100644 index 0000000..64e4507 --- /dev/null +++ b/test/test_cad.cpp @@ -0,0 +1,195 @@ +/* Channel activity detection: what a scan costs, what it finds, and where it + * leaves the chip. + * + * Every case here failed before CAD was modelled, and most of them failed the + * same way: the old scan answered inside the SPI command, so it cost nothing, + * held the chip for no time and always came back to standby. A firmware doing + * listen before talk with it paid none of the dwell a real part charges, which + * is the entire price of the technique. + * + * Timed at SF12 and 125 kHz throughout, where one symbol is 32.768 ms. That is + * slow enough that a dwell cannot be confused with a rounding error, which at + * SF7 it can. + */ +#include "harness.h" + +namespace { + +/* SF12, 125 kHz, CR 4/8, every interrupt unmasked onto DIO1. Not bring_up(): + * that one picks its own modulation, and every assertion below is a time in + * symbols. */ +void bring_up_slow(vsx_chip* c) { + const uint8_t mod[] = {0x8B, 12, 0x04, 4, 0}; + vsx_spi_transaction(c, mod, nullptr, sizeof(mod)); + const uint8_t dio[] = {0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0}; + vsx_spi_transaction(c, dio, nullptr, sizeof(dio)); +} + +/* SetCadParams: symbolNum, detPeak, detMin, exitMode, timeout 23:16..7:0. */ +void set_cad_params(vsx_chip* c, uint8_t symbols, uint8_t exit_mode) { + const uint8_t p[] = {0x88, symbols, 22, 10, exit_mode, 0, 0, 0}; + vsx_spi_transaction(c, p, nullptr, sizeof(p)); +} + +void set_cad(vsx_chip* c) { + const uint8_t cad[] = {0xC5}; + vsx_spi_transaction(c, cad, nullptr, sizeof(cad)); +} + +uint32_t cad_runs(vsx_chip* c) { + vsx_counters k; + vsx_get_counters(c, &k); + return k.cad_runs; +} + +} // namespace + +int main() { + std::printf("virtual-sx1262"); + + /* ---------------------------------------------------------------- */ + CASE("a scan holds the chip for the dwell its symbol count asks for"); + /* The old model set CadDone inside the command and returned to standby + * before it came back, so a firmware could scan and transmit in the same + * millisecond. Four symbols at SF12/125 kHz is 131 ms, and the answer is not + * available until they have passed. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 100; + bring_up_slow(c); + set_cad_params(c, 2, 0x00); /* 1 << 2 symbols */ + vsx_tick(c, now); + set_cad(c); + + check(mode_of(c) == 3, "the chip is in CAD as soon as the command lands"); + check((irq_flags(c) & (IRQ_CAD_DONE | IRQ_CAD_DETECTED)) == 0, + "and has answered nothing yet"); + + vsx_tick(c, now + 130); + check(mode_of(c) == 3, "one millisecond short of the dwell it is still scanning"); + check((irq_flags(c) & IRQ_CAD_DONE) == 0, "and still silent"); + + vsx_tick(c, now + 131); + check((irq_flags(c) & IRQ_CAD_DONE) != 0, "at the end of the dwell it reports"); + check(cad_runs(c) == 1, "and the scan is counted"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a longer symbol count is a longer scan"); + /* The parameter was discarded entirely, so every scan cost the same nothing + * whatever the firmware asked for. */ + { + vsx_chip* c = vsx_create(); + bring_up_slow(c); + set_cad_params(c, 4, 0x00); /* 16 symbols, 524 ms */ + vsx_tick(c, 100); + set_cad(c); + + vsx_tick(c, 100 + 131); + check(mode_of(c) == 3, "four symbols in, a sixteen symbol scan is not finished"); + vsx_tick(c, 100 + 524); + check((irq_flags(c) & IRQ_CAD_DONE) != 0, "sixteen symbols in, it is"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a carrier that comes and goes inside the dwell is still found"); + /* Sampling at either end of the scan instead of across it would miss exactly + * the transmission CAD exists to catch: a short one, starting after the scan + * began and ending before it finished. */ + { + vsx_chip* c = vsx_create(); + bring_up_slow(c); + set_cad_params(c, 2, 0x00); + vsx_tick(c, 100); + set_cad(c); + + vsx_set_channel_busy(c, 1); + vsx_tick(c, 150); + vsx_set_channel_busy(c, 0); + vsx_tick(c, 180); + vsx_tick(c, 231); + + check((irq_flags(c) & IRQ_CAD_DONE) != 0, "the scan finished"); + check((irq_flags(c) & IRQ_CAD_DETECTED) != 0, + "and it found the carrier it overlapped"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a quiet channel is scanned and reported quiet"); + { + vsx_chip* c = vsx_create(); + bring_up_slow(c); + set_cad_params(c, 2, 0x00); + vsx_tick(c, 100); + set_cad(c); + vsx_tick(c, 231); + + check((irq_flags(c) & IRQ_CAD_DONE) != 0, "CadDone is raised either way"); + check((irq_flags(c) & IRQ_CAD_DETECTED) == 0, "CadDetected is not"); + check(mode_of(c) == 0, "and CAD_ONLY leaves the chip in standby"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("the thresholds the firmware programmed are reported, not applied"); + /* detPeak and detMin threshold a correlator peak, and this model is told + * whether a carrier is present rather than how strong it is. Recording them + * is what lets a host that does know the level act on them, and what makes a + * firmware setting an implausible threshold visible at all. */ + { + vsx_chip* c = vsx_create(); + bring_up_slow(c); + set_cad_params(c, 3, 0x01); + + vsx_state s; + vsx_get_state(c, &s); + check(s.cad_symbol_num == 3, "the symbol count is readable"); + check(s.cad_det_peak == 22 && s.cad_det_min == 10, "so are both thresholds"); + check(s.cad_exit_mode == 0x01, "and the exit mode"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a symbol count the part does not define is clamped, not shifted with"); + /* 1 << 9 symbols is not a scan, it is undefined behaviour in the dwell + * arithmetic and a dwell no firmware asked for. */ + { + vsx_chip* c = vsx_create(); + bring_up_slow(c); + set_cad_params(c, 9, 0x00); + + vsx_state s; + vsx_get_state(c, &s); + check(s.cad_symbol_num == 4, "clamped to the longest scan the part defines"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a scan the firmware masked off runs, and is not reported"); + /* The mask governs the status register, not the radio. The scan still costs + * its dwell and the host still sees it happened; the firmware does not. */ + { + vsx_chip* c = vsx_create(); + const uint8_t mod[] = {0x8B, 12, 0x04, 4, 0}; + vsx_spi_transaction(c, mod, nullptr, sizeof(mod)); + /* Everything except the two CAD bits. */ + const uint8_t dio[] = {0x08, 0xFE, 0x7F, 0xFF, 0xFF, 0, 0, 0, 0}; + vsx_spi_transaction(c, dio, nullptr, sizeof(dio)); + set_cad_params(c, 2, 0x00); + vsx_tick(c, 100); + vsx_set_channel_busy(c, 1); + set_cad(c); + vsx_tick(c, 231); + + check((irq_flags(c) & (IRQ_CAD_DONE | IRQ_CAD_DETECTED)) == 0, + "neither CAD flag reaches the status register"); + check(cad_runs(c) == 1, "but the scan ran"); + vsx_destroy(c); + } + + std::printf("\n%s: %d failure(s)\n", g_failures ? "FAILED" : "PASSED", g_failures); + return g_failures ? 1 : 0; +} diff --git a/test/test_interrupts.cpp b/test/test_interrupts.cpp index 01e593b..3b4c0ac 100644 --- a/test/test_interrupts.cpp +++ b/test/test_interrupts.cpp @@ -138,5 +138,102 @@ int main() { vsx_destroy(c); } + /* ---------------------------------------------------------------- */ + CASE("an interrupt the firmware masked off is not in the status register"); + /* IrqMask was parsed and then never used, so the register answered every + * event whatever the firmware had enabled. That is forgiving in the direction + * that hides a fault: a driver waiting on an interrupt it forgot to enable + * waits for ever on silicon and works perfectly here, and the difference only + * shows up on a board. + * + * The pair sent here is RadioLib's own for a receive that wants only RxDone: + * enabled in the register, routed to the pin, and nothing else in either. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + const uint8_t mod[] = {0x8B, 8, 0x1A, 4, 0}; + vsx_spi_transaction(c, mod, nullptr, sizeof(mod)); + const uint8_t dio[] = {0x08, 0x00, 0x02, 0x00, 0x02, 0, 0, 0, 0}; + vsx_spi_transaction(c, dio, nullptr, sizeof(dio)); + const uint8_t rx[] = {0x82, 0xFF, 0xFF, 0xFF}; + vsx_spi_transaction(c, rx, nullptr, sizeof(rx)); + + vsx_set_channel_busy(c, 1); + for (int i = 0; i < 100; ++i) { + vsx_tick(c, ++now); + } + check((irq_flags(c) & (IRQ_PREAMBLE | IRQ_HEADER)) == 0, + "the detection flags are masked off, so the register does not carry them"); + + vsx_counters k; + vsx_get_counters(c, &k); + check(k.preamble_raises > 0, "the carrier was detected all the same"); + check(k.irq_suppressed > 0, "and the chip counts what it did not report"); + + const uint8_t frame[] = {1, 2, 3, 4}; + vsx_deliver_frame(c, frame, sizeof(frame)); + vsx_tick(c, ++now); + check((irq_flags(c) & IRQ_RX_DONE) != 0, "the one interrupt it did enable arrives"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a masked interrupt cannot raise DIO1 either"); + /* DIO1 selects from what the register carries. Routing a bit to the pin that + * the mask keeps out of the register is a driver asking for something the + * part cannot do, and answering it would put a level on the pin with nothing + * behind it to read. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + const uint8_t mod[] = {0x8B, 8, 0x1A, 4, 0}; + vsx_spi_transaction(c, mod, nullptr, sizeof(mod)); + /* Nothing enabled, everything routed. */ + const uint8_t dio[] = {0x08, 0x00, 0x00, 0xFF, 0xFF, 0, 0, 0, 0}; + vsx_spi_transaction(c, dio, nullptr, sizeof(dio)); + const uint8_t rx[] = {0x82, 0xFF, 0xFF, 0xFF}; + vsx_spi_transaction(c, rx, nullptr, sizeof(rx)); + + vsx_set_channel_busy(c, 1); + for (int i = 0; i < 100; ++i) { + vsx_tick(c, ++now); + } + check(vsx_dio1_asserted(c) == 0, "the pin stays low"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a receive timeout expires into Timeout and standby"); + /* SetRx carried a timeout that was discarded, so the bit could never be set + * and a bounded receive never ended. Continuous receive is all ones and is + * the case that must keep working, because it is the one MeshCore uses. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + /* 6400 counts of 15.625 us is 100 ms. */ + const uint8_t rx[] = {0x82, 0x00, 0x19, 0x00}; + vsx_spi_transaction(c, rx, nullptr, sizeof(rx)); + + vsx_tick(c, now + 99); + check(mode_of(c) == 1, "before the deadline it is still listening"); + check((irq_flags(c) & IRQ_TIMEOUT) == 0, "and has not timed out"); + + vsx_tick(c, now + 100); + check((irq_flags(c) & IRQ_TIMEOUT) != 0, "at the deadline Timeout is raised"); + check(mode_of(c) == 0, "and the receiver has stopped"); + vsx_destroy(c); + + c = vsx_create(); + now = 0; + bring_up(c, &now); + for (int i = 0; i < 500; ++i) { + vsx_tick(c, ++now); + } + check(mode_of(c) == 1, "continuous receive has no deadline to reach"); + check((irq_flags(c) & IRQ_TIMEOUT) == 0, "and never times out"); + vsx_destroy(c); + } + return report(""); } From 5faabfed74d19f27d8432a8ff1911f68695be162 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 18 Sep 2026 21:55:44 +0100 Subject: [PATCH 2/3] check what the modem was programmed to, and who the chip is listening to Four things, all of which present the same way: a node configured wrong that reports nothing wrong. SetModulationParams took every field on trust. A spreading factor, bandwidth code or coding rate the part does not define was adopted or quietly ignored, and the modem stayed on whatever it had. This repository's own harness had been sending bandwidth 0x1A, which is not a code at all, so every timing assertion in it ran at the default 250 kHz while its comment said 62.5 and nothing failed. Each field is now checked, a refused one leaves the setting alone and is counted in params_rejected, and the harness sends 0x03. Low data rate optimisation is the field the firmware programs, not something to infer from the spreading factor. The threshold is a symbol duration, so SF11 is over it at 125 kHz and under it at 250, and inferring it from SF alone was wrong at every bandwidth but one. It was wrong in the figure the firmware asks this chip for to time its own CSMA: at SF11 and 250 kHz a 50 byte frame was quoted at 723 ms against the 641 ms the part would take. The programmed field now wins, and a firmware that never sent one falls back to the symbol duration, which is the rule the engine's own airtime already used. The sync word is written through two registers rather than a command, so it landed in the register array and nothing read it back. Two meshes sharing a frequency under different words therefore heard each other perfectly, which is the opposite of the property the word exists to provide. The chip now comes up on the private word the part resets to, reports what it holds, and refuses a frame sent under another one. vsx_deliver_frame_from carries the transmitter's word; vsx_deliver_frame is unchanged and still delivers, because a host that makes no claim about the transmitter has given no grounds to refuse and every host that has not been taught about the word must go on working. SetTx's timeout, which was discarded like SetRx's. There is no continuous case for a transmission, so any nonzero value is a deadline, and reaching it means the waveform never finished: Timeout, and back to standby. TxDone disarms it, or a chip that transmitted perfectly would report a fault a hundred milliseconds later. vsx_state and vsx_counters gain fields at the end only; VSX_ABI_MINOR 4 -> 5. --- build.sh | 2 +- include/virtual_sx1262.h | 31 ++++++ src/VirtualSX1262.cpp | 152 ++++++++++++++++++++++------ src/VirtualSX1262.h | 49 ++++++++- src/abi.cpp | 14 ++- src/registers.h | 25 +++++ src/spi.cpp | 8 +- test/harness.h | 11 +- test/test_c_abi.c | 18 ++++ test/test_interrupts.cpp | 58 +++++++++++ test/test_modem.cpp | 213 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 543 insertions(+), 38 deletions(-) create mode 100644 test/test_modem.cpp diff --git a/build.sh b/build.sh index 0584200..de50a3f 100755 --- a/build.sh +++ b/build.sh @@ -115,7 +115,7 @@ check_shared_imports() { # One binary per subject rather than one big one, because the house limit on # file length applies to tests too and a single file had already outgrown it. # They share test/harness.h and nothing else, so each counts its own failures. -TESTS="test_reception test_interrupts test_spi test_cad" +TESTS="test_reception test_interrupts test_spi test_cad test_modem" build_tests() { for t in $TESTS; do diff --git a/include/virtual_sx1262.h b/include/virtual_sx1262.h index 1c506f1..8a80bed 100644 --- a/include/virtual_sx1262.h +++ b/include/virtual_sx1262.h @@ -93,6 +93,21 @@ void vsx_set_channel_busy(vsx_chip* chip, int busy); * packet cannot arrive after the signal that carried it has gone. */ void vsx_deliver_frame(vsx_chip* chip, const uint8_t* frame, size_t len); +/* The same, from a transmitter whose sync word the caller knows. + * + * A LoRa receiver uses the sync word to decide a frame was not meant for it, + * and a frame whose word does not match this chip's is refused and counted + * rather than delivered. The firmware sets its own word through two registers + * rather than a command, so nothing above the chip can see it except through + * vsx_state.sync_word. + * + * Separate from vsx_deliver_frame rather than an extra argument to it, because + * a host that does not model sync words must go on working unchanged: making no + * claim about the transmitter is not the same as claiming it matched, and the + * older entry point makes no claim. */ +void vsx_deliver_frame_from(vsx_chip* chip, const uint8_t* frame, size_t len, + uint16_t sync_word); + /* The waveform the chip started has finished on the air. The chip cannot know * this: how long a transmission occupied the channel is a property of the * samples the simulator generated. */ @@ -140,6 +155,16 @@ typedef struct { uint8_t cad_det_peak; uint8_t cad_det_min; uint8_t cad_exit_mode; + /* The LoRa sync word the firmware programmed, as the two registers hold it: + * 0x1424 is the private network default this chip comes up on and 0x3444 is + * the public one. Two meshes sharing a frequency are told apart by this and + * by nothing else the chip can see. */ + uint16_t sync_word; + /* Whether the modem is running low data rate optimisation, from the field + * SetModulationParams programmed. Reported because it costs two bits a symbol + * and so changes every airtime this chip quotes, and because a firmware that + * never programs it is a different case from one that programs it off. */ + uint8_t low_data_rate_optimize; } vsx_state; void vsx_get_state(const vsx_chip* chip, vsx_state* out); @@ -161,6 +186,12 @@ typedef struct { * they cost, which is cad_runs multiplied by the dwell in vsx_state. */ uint32_t cad_runs; uint32_t cad_detections; + /* Frames refused because they were sent under another sync word, and + * modulation fields refused because the part does not define them. Both are + * faults that otherwise present as a quiet network: a node on the wrong sync + * word receives nothing and reports nothing wrong. */ + uint32_t sync_mismatches; + uint32_t params_rejected; } vsx_counters; void vsx_get_counters(const vsx_chip* chip, vsx_counters* out); diff --git a/src/VirtualSX1262.cpp b/src/VirtualSX1262.cpp index be3f0ed..2838da8 100644 --- a/src/VirtualSX1262.cpp +++ b/src/VirtualSX1262.cpp @@ -30,6 +30,13 @@ VirtualSX1262::VirtualSX1262() { // on. Power-on default is the non-boosted value. regs_[0x08AC] = 0x94; + // The sync word the part comes up holding. A firmware that never writes one + // is on the private network word, and a model that came up on zero would + // have every node agreeing with every other node about a value none of them + // chose. + regs_[kRegSyncWordMsb] = (uint8_t)(kSyncWordPrivate >> 8); + regs_[kRegSyncWordLsb] = (uint8_t)(kSyncWordPrivate & 0xFF); + #ifdef VIRTUAL_SX1262_STUCK_IRQ_MS // A deliberately misbehaving chip, built as its own firmware variant. // @@ -109,6 +116,15 @@ void VirtualSX1262::tick(uint64_t nowMs) { raiseIrq(kIrqTimeout); } + // The transmit deadline, which the part enforces against a waveform that + // never finished. The frame stays queued: the chip gave up on it, and whether + // any of it reached the air is the simulator's to say, not ours. + if (mode_ == 2 && txDeadlineArmed_ && nowMs_ >= txDeadlineMs_) { + txDeadlineArmed_ = false; + mode_ = 0; + raiseIrq(kIrqTimeout); + } + // Preamble and header detection, in receive mode only. A node cannot hear // anything while its own transmitter is keyed - that is half duplex, and the // engine already reports the channel as clear to a node that is transmitting. @@ -129,6 +145,32 @@ void VirtualSX1262::tick(uint64_t nowMs) { } } +uint16_t VirtualSX1262::syncWord() const { + return (uint16_t)(((uint16_t)regs_[kRegSyncWordMsb] << 8) | regs_[kRegSyncWordLsb]); +} + +// A frame arriving from a transmitter whose sync word the caller knows. +// +// The sync word is what a LoRa receiver uses to decide a frame is not meant for +// it, and it was the one piece of the header this model could not act on: the +// firmware writes it through two registers rather than a command, so it landed +// in the register array and nothing ever read it back. Two meshes on one +// frequency with different words therefore heard each other perfectly, which is +// the opposite of the property the word exists to provide. +// +// Kept separate from deliverFrame() rather than folded into it, because a host +// that does not model sync words at all must go on working: no claim about the +// transmitter means no grounds to refuse, and the old entry point makes no +// claim. A host that does know says so here. +void VirtualSX1262::deliverFrameFrom(const uint8_t* frame, size_t len, + uint16_t syncWordSent) { + if (syncWordSent != syncWord()) { + syncMismatches_++; + return; + } + inbox.emplace_back(frame, frame + len); +} + void VirtualSX1262::setChannelBusy(bool busy) { if (busy && !channelBusy_) busySinceMs_ = nowMs_; if (!busy) { @@ -157,6 +199,7 @@ void VirtualSX1262::setChannelBusy(bool busy) { void VirtualSX1262::transmitFinished() { if (mode_ == 2) { + txDeadlineArmed_ = false; raiseIrq(kIrqTxDone); mode_ = 0; } @@ -271,8 +314,19 @@ void VirtualSX1262::startRx(uint32_t timeoutRaw) { settleInbox(); } -void VirtualSX1262::startTx() { +void VirtualSX1262::startTx(uint32_t timeoutRaw) { mode_ = 2; + + // SetTx's timeout, which unlike SetRx's has no continuous case: a + // transmission ends. Zero disables it and is what a driver content to wait + // for TxDone sends; anything else is the part promising to give up. Reaching + // it means the waveform never finished, which from inside the chip is + // indistinguishable from a host that forgot to say it had. + txDeadlineArmed_ = timeoutRaw != 0; + if (txDeadlineArmed_) { + txDeadlineMs_ = nowMs_ + (uint64_t)((double)timeoutRaw * kTimeoutStepMs + 0.5); + } + // Latched here because this is the instant it decides anything. RadioLib // raises the RF switch into transmit before issuing SetTx, so by now the line // carries the answer to "does this transmission reach the antenna". @@ -286,7 +340,7 @@ uint32_t VirtualSX1262::estAirtimeMs(int lenBytes) const { // Semtech's own airtime formula, from the parameters the firmware programmed. const double bwHz = bwKHz_ * 1000.0; const double tSym = (double)(1u << sf_) / bwHz; - const int de = (sf_ >= 11) ? 1 : 0; + const int de = lowDataRateOptimize() ? 1 : 0; const int crc = 1, header = 0, crDen = cr_; double num = 8.0 * lenBytes - 4.0 * sf_ + 28 + 16 * crc - 20 * header; double den = 4.0 * (sf_ - 2 * de); @@ -295,46 +349,84 @@ uint32_t VirtualSX1262::estAirtimeMs(int lenBytes) const { return (uint32_t)(t * 1000.0 + 0.5); } -void VirtualSX1262::applyModulation(const uint8_t* p) { - sf_ = p[0]; - // Bandwidth is an index in the datasheet's table; only the values MeshCore - // uses are mapped, and anything else keeps the current setting rather than - // silently becoming zero and making airtime infinite. - switch (p[1]) { +// The datasheet's LoRa bandwidth table, whole. Returns 0 for a code the part +// does not define, which is not the same as a bandwidth of zero and must not be +// adopted as one. +float VirtualSX1262::bandwidthForCode(uint8_t code) { + switch (code) { case 0x00: - bwKHz_ = 7.81f; - break; + return 7.81f; case 0x08: - bwKHz_ = 10.42f; - break; + return 10.42f; case 0x01: - bwKHz_ = 15.63f; - break; + return 15.63f; case 0x09: - bwKHz_ = 20.83f; - break; + return 20.83f; case 0x02: - bwKHz_ = 31.25f; - break; + return 31.25f; case 0x0A: - bwKHz_ = 41.67f; - break; + return 41.67f; case 0x03: - bwKHz_ = 62.5f; - break; + return 62.5f; case 0x04: - bwKHz_ = 125.0f; - break; + return 125.0f; case 0x05: - bwKHz_ = 250.0f; - break; + return 250.0f; case 0x06: - bwKHz_ = 500.0f; - break; + return 500.0f; default: - break; + return 0.0f; + } +} + +// SetModulationParams: [sf][bw][cr][ldro]. +// +// Every field is checked, and a field the part does not define is counted and +// left alone rather than adopted. Keeping the old value is the safe half of +// that and was already here; the counter is the half that was missing, because +// a rejected parameter used to be indistinguishable from one that was never +// sent. This model's own test harness had been programming bandwidth 0x1A, +// which is not a code, and quietly running every timing assertion at the +// default 250 kHz while its comment said 62.5. +void VirtualSX1262::applyModulation(const uint8_t* p, size_t n) { + if (p[0] >= kSfMin && p[0] <= kSfMax) { + sf_ = p[0]; + } else { + paramsRejected_++; + } + + const float bw = bandwidthForCode(p[1]); + if (bw > 0.0f) { + bwKHz_ = bw; + } else { + paramsRejected_++; } - cr_ = p[2] ? (4 + p[2]) : cr_; + + if (p[2] >= kCrMin && p[2] <= kCrMax) { + cr_ = 4 + p[2]; + } else { + paramsRejected_++; + } + + // Low data rate optimisation is a field the firmware programs, not something + // to infer. Inferring it was wrong at every bandwidth but 125 kHz, and wrong + // in the figure the firmware asks this chip for to time its own CSMA: at + // SF11 and 250 kHz it added two bits per symbol the part would not have + // added, and quoted an airtime about an eighth too long. + if (n >= 4) { + ldroProgrammed_ = true; + ldro_ = p[3] != 0; + } +} + +// Whether the modem is spending two bits a symbol on low data rate +// optimisation. +// +// The programmed field wins, because that is the one the part obeys. Only a +// firmware that never sent the field falls back to the symbol duration rule, +// which is the rule the chip would have applied to itself. +bool VirtualSX1262::lowDataRateOptimize() const { + return ldroProgrammed_ ? ldro_ : (symbolMs() >= kLowDataRateSymbolMs); } void VirtualSX1262::applyPacketParams(const uint8_t* p) { diff --git a/src/VirtualSX1262.h b/src/VirtualSX1262.h index c4aeb7f..a22c964 100644 --- a/src/VirtualSX1262.h +++ b/src/VirtualSX1262.h @@ -196,6 +196,33 @@ class VirtualSX1262 { // the scans actually happened. uint32_t cadRuns() const { return cadRuns_; } uint32_t cadDetections() const { return cadDetections_; } + + // Frames refused because they were sent under a different sync word, and + // modulation fields refused because the part does not define them. + // + // Both are silent otherwise, and both are firmware faults that look like a + // quiet network: a node on the wrong sync word receives nothing and reports + // nothing wrong, and a bandwidth code that is not a code leaves the modem on + // whatever it was set to last. + uint32_t syncMismatches() const { return syncMismatches_; } + uint32_t paramsRejected() const { return paramsRejected_; } + + // The LoRa sync word, from the two registers the firmware writes it into. + // + // Read out of the register array rather than shadowed, because the firmware + // sets it with WriteRegister and a shadow copy is a second thing to keep in + // step with the array that is already the answer. Defined next door, where + // the datasheet's addresses live. + uint16_t syncWord() const; + + // Hand over a frame whose transmitter's sync word is known, so the chip can + // refuse one that was not meant for this network. + void deliverFrameFrom(const uint8_t* frame, size_t len, uint16_t syncWordSent); + + // Whether the modem is spending two bits a symbol on low data rate + // optimisation, from what the firmware programmed rather than from the + // spreading factor alone. + bool lowDataRateOptimize() const; uint8_t cadSymbolNum() const { return cadSymbolNum_; } uint8_t cadDetPeak() const { return cadDetPeak_; } uint8_t cadDetMin() const { return cadDetMin_; } @@ -235,11 +262,12 @@ class VirtualSX1262 { private: void runCommand(const uint8_t* out, size_t len, uint8_t* in); - void applyModulation(const uint8_t* p); + static float bandwidthForCode(uint8_t code); + void applyModulation(const uint8_t* p, size_t n); void applyPacketParams(const uint8_t* p); void applyCadParams(const uint8_t* p); void startRx(uint32_t timeoutRaw); - void startTx(); + void startTx(uint32_t timeoutRaw); void startCad(); void tickCad(); void finishCad(); @@ -333,6 +361,21 @@ class VirtualSX1262 { bool rxDeadlineArmed_ = false; uint64_t rxDeadlineMs_ = 0; + // Transmit timeout, from SetTx, on the same 15.625 us unit. + // + // Zero disables it and is what a driver waiting for TxDone sends. Anything + // else is a deadline the part enforces against its own transmission, so + // reaching it means the waveform never finished: Timeout, and back to + // standby. There is no continuous case here, because a transmission ends. + bool txDeadlineArmed_ = false; + uint64_t txDeadlineMs_ = 0; + + // Low data rate optimisation as the firmware programmed it, and whether it + // ever did. Two fields rather than one, because "not programmed" is a real + // state with its own answer and is not the same as "programmed off". + bool ldro_ = false; + bool ldroProgrammed_ = false; + // Channel activity detection, as SetCadParams programmed it. // // The defaults are the reset values: one symbol, CAD_ONLY, and thresholds of @@ -354,6 +397,8 @@ class VirtualSX1262 { uint32_t irqSuppressed_ = 0; uint32_t cadRuns_ = 0; uint32_t cadDetections_ = 0; + uint32_t syncMismatches_ = 0; + uint32_t paramsRejected_ = 0; uint64_t lastBusyTickMs_ = 0; // Fault injection: how long a raised flag refuses to clear. 0 is a chip that diff --git a/src/abi.cpp b/src/abi.cpp index 929d1a2..ceba73b 100644 --- a/src/abi.cpp +++ b/src/abi.cpp @@ -19,7 +19,7 @@ #include #define VSX_ABI_MAJOR 1 -#define VSX_ABI_MINOR 4 +#define VSX_ABI_MINOR 5 struct vsx_chip { VirtualSX1262 chip; @@ -149,6 +149,14 @@ void vsx_deliver_frame(vsx_chip* chip, const uint8_t* frame, size_t len) { * on arrival would let a packet in while the chip was transmitting. */ } +void vsx_deliver_frame_from(vsx_chip* chip, const uint8_t* frame, size_t len, + uint16_t sync_word) { + if (chip && frame) { + chip->chip.deliverFrameFrom(frame, len, sync_word); + settle_dio1(chip); + } +} + void vsx_transmit_finished(vsx_chip* chip) { if (!chip) { return; @@ -205,6 +213,8 @@ void vsx_get_state(const vsx_chip* chip, vsx_state* out) { out->cad_det_peak = c.cadDetPeak(); out->cad_det_min = c.cadDetMin(); out->cad_exit_mode = c.cadExitMode(); + out->sync_word = c.syncWord(); + out->low_data_rate_optimize = c.lowDataRateOptimize() ? 1 : 0; } void vsx_get_counters(const vsx_chip* chip, vsx_counters* out) { @@ -221,6 +231,8 @@ void vsx_get_counters(const vsx_chip* chip, vsx_counters* out) { out->irq_suppressed = c.irqSuppressed(); out->cad_runs = c.cadRuns(); out->cad_detections = c.cadDetections(); + out->sync_mismatches = c.syncMismatches(); + out->params_rejected = c.paramsRejected(); } uint32_t vsx_est_airtime_ms(const vsx_chip* chip, int len_bytes) { diff --git a/src/registers.h b/src/registers.h index ab5a85c..ff37b9e 100644 --- a/src/registers.h +++ b/src/registers.h @@ -57,6 +57,31 @@ namespace { [[maybe_unused]] constexpr uint16_t kIrqCadDetected = 1 << 8; [[maybe_unused]] constexpr uint16_t kIrqTimeout = 1 << 9; +// The LoRa sync word, which lives in two registers rather than a command. +// +// RadioLib writes it as a pair through WriteRegister, so nothing in the opcode +// table names it and a model that only watches commands never sees it change. +// The reset value is the private network word, 0x1424, which is what the part +// comes up holding and what a firmware that never sets one is using. +[[maybe_unused]] constexpr uint16_t kRegSyncWordMsb = 0x0740; +[[maybe_unused]] constexpr uint16_t kRegSyncWordLsb = 0x0741; +[[maybe_unused]] constexpr uint16_t kSyncWordPrivate = 0x1424; +[[maybe_unused]] constexpr uint16_t kSyncWordPublic = 0x3444; + +// What SetModulationParams is allowed to say. Outside these the firmware has +// programmed something the part does not define, which is a fault to report +// rather than a value to adopt. +[[maybe_unused]] constexpr uint8_t kSfMin = 5; +[[maybe_unused]] constexpr uint8_t kSfMax = 12; +[[maybe_unused]] constexpr uint8_t kCrMin = 1; +[[maybe_unused]] constexpr uint8_t kCrMax = 4; + +// The symbol duration past which the part wants low data rate optimisation, in +// milliseconds. It is a property of the symbol and not of the spreading factor: +// SF11 crosses it at 125 kHz and does not at 250 kHz, so a rule written in SF +// alone is wrong at every bandwidth but one. +[[maybe_unused]] constexpr double kLowDataRateSymbolMs = 16.0; + // Untested. // // CAD exit modes, from SetCadParams. CAD_ONLY drops back to standby whatever it // // found; CAD_RX goes straight on into receive when it found something, which is diff --git a/src/spi.cpp b/src/spi.cpp index f6da574..aee32e9 100644 --- a/src/spi.cpp +++ b/src/spi.cpp @@ -123,7 +123,11 @@ void VirtualSX1262::runCommand(const uint8_t* out, size_t len, uint8_t* in) { : 0); break; case kSetTx: - startTx(); + // The same three timeout bytes SetRx carries, on the same unit. All-zero + // is a driver that will wait for TxDone however long it takes, which is + // also what a command too short to carry the field means. + startTx(len >= 4 ? (((uint32_t)out[1] << 16) | ((uint32_t)out[2] << 8) | out[3]) + : 0); break; case kSetCad: startCad(); @@ -166,7 +170,7 @@ void VirtualSX1262::runCommand(const uint8_t* out, size_t len, uint8_t* in) { break; case kSetModulationParams: - if (len >= 4) applyModulation(&out[1]); + if (len >= 4) applyModulation(&out[1], len - 1); break; case kSetPacketParams: if (len >= 7) applyPacketParams(&out[1]); diff --git a/test/harness.h b/test/harness.h index ee88af7..f475265 100644 --- a/test/harness.h +++ b/test/harness.h @@ -55,8 +55,15 @@ enum { * in SetDioIrqParams passed every test here for months, so the case named "only * what is routed to DIO1 raises DIO1" sends the real pair instead. */ static inline void bring_up(vsx_chip* c, uint64_t* now) { - /* SetModulationParams: sf, bw, cr, ldro. 0x1A is the 62.5 kHz code. */ - const uint8_t mod[] = {0x8B, 8, 0x1A, 4, 0}; + /* SetModulationParams: sf, bw, cr, ldro. 0x03 is the 62.5 kHz code. + * + * It said 0x1A for a long time, which is not a code at all: the model kept + * whatever bandwidth it already had, so every case here that talks about + * symbols was silently timed at the 250 kHz default while the comment said + * 62.5. Nothing failed, because nothing asserted a number that only holds at + * one of the two. The model now counts a parameter it had to refuse, which is + * what makes the next one of these visible. */ + const uint8_t mod[] = {0x8B, 8, 0x03, 4, 0}; vsx_spi_transaction(c, mod, nullptr, sizeof(mod)); /* SetDioIrqParams: mask, dio1, dio2, dio3 - unmask everything onto DIO1. */ diff --git a/test/test_c_abi.c b/test/test_c_abi.c index 14078af..ddce2e5 100644 --- a/test/test_c_abi.c +++ b/test/test_c_abi.c @@ -166,6 +166,23 @@ int main(void) { vsx_transmit_finished(chip); check(1, "vsx_transmit_finished"); + /* The sync word, and a frame from a network this chip is not on. Checked + * from C as well as C++ because the refusal is the whole point of the entry + * point, and a host that gets an uint16_t argument wrong here would deliver + * every frame instead of none. */ + { + vsx_state s; + vsx_get_state(chip, &s); + check(s.sync_word == 0x1424, "vsx_get_state reports the sync word"); + } + dio1_edges = 0; + vsx_deliver_frame_from(chip, frame, sizeof(frame), 0x3444); + { + vsx_counters k; + vsx_get_counters(chip, &k); + check(k.sync_mismatches == 1, "vsx_deliver_frame_from refuses another network"); + } + check(vsx_take_tx(chip, tx, sizeof(tx)) == 0, "vsx_take_tx with nothing to send"); vsx_set_fem_enabled(chip, 1); @@ -195,6 +212,7 @@ int main(void) { vsx_tick(NULL, 1); vsx_set_channel_busy(NULL, 1); vsx_deliver_frame(NULL, frame, sizeof(frame)); + vsx_deliver_frame_from(NULL, frame, sizeof(frame), 0x1424); vsx_transmit_finished(NULL); vsx_set_fem_enabled(NULL, 1); vsx_set_last_signal(NULL, 0.0f, 0.0f); diff --git a/test/test_interrupts.cpp b/test/test_interrupts.cpp index 3b4c0ac..4e97abe 100644 --- a/test/test_interrupts.cpp +++ b/test/test_interrupts.cpp @@ -235,5 +235,63 @@ int main() { vsx_destroy(c); } + /* ---------------------------------------------------------------- */ + CASE("a transmit timeout expires into Timeout and standby"); + /* SetTx carries the same three bytes SetRx does, and they were discarded the + * same way. Unlike receive there is no continuous case: a transmission ends, + * so a deadline reached means the waveform never finished. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + /* 6400 counts of 15.625 us is 100 ms. */ + const uint8_t tx[] = {0x83, 0x00, 0x19, 0x00}; + vsx_spi_transaction(c, tx, nullptr, sizeof(tx)); + check(mode_of(c) == 2, "the chip is transmitting"); + + vsx_tick(c, now + 99); + check((irq_flags(c) & IRQ_TIMEOUT) == 0, "before the deadline, nothing"); + vsx_tick(c, now + 100); + check((irq_flags(c) & IRQ_TIMEOUT) != 0, "at it, Timeout"); + check(mode_of(c) == 0, "and the transmitter has stopped"); + check((irq_flags(c) & IRQ_TX_DONE) == 0, "a transmission that timed out is not done"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a transmission that finishes in time does not also time out"); + /* The deadline has to be disarmed by the thing it was waiting for, or a chip + * that transmitted perfectly reports a fault a hundred milliseconds later. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + const uint8_t tx[] = {0x83, 0x00, 0x19, 0x00}; + vsx_spi_transaction(c, tx, nullptr, sizeof(tx)); + + vsx_tick(c, now + 10); + vsx_transmit_finished(c); + check((irq_flags(c) & IRQ_TX_DONE) != 0, "TxDone arrives"); + + vsx_tick(c, now + 500); + check((irq_flags(c) & IRQ_TIMEOUT) == 0, "and the deadline it beat never fires"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a transmit timeout of zero is a driver that will wait"); + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + const uint8_t tx[] = {0x83, 0x00, 0x00, 0x00}; + vsx_spi_transaction(c, tx, nullptr, sizeof(tx)); + + vsx_tick(c, now + 5000); + check(mode_of(c) == 2, "still transmitting five seconds later"); + check((irq_flags(c) & IRQ_TIMEOUT) == 0, "with no deadline to reach"); + vsx_destroy(c); + } + return report(""); } diff --git a/test/test_modem.cpp b/test/test_modem.cpp new file mode 100644 index 0000000..bbac62f --- /dev/null +++ b/test/test_modem.cpp @@ -0,0 +1,213 @@ +/* What SetModulationParams programmed, and who the chip will listen to. + * + * Two kinds of fault share this file because they present the same way: a node + * that is configured wrong and reports nothing wrong. A bandwidth code the part + * does not define used to leave the modem on its previous setting in silence, + * and a sync word the firmware set was written into a register that nothing + * ever read back, so two meshes on one frequency heard each other perfectly. + */ +#include "harness.h" + +namespace { + +void set_modulation(vsx_chip* c, uint8_t sf, uint8_t bw, uint8_t cr, uint8_t ldro) { + const uint8_t mod[] = {0x8B, sf, bw, cr, ldro}; + vsx_spi_transaction(c, mod, nullptr, sizeof(mod)); +} + +void write_sync_word(vsx_chip* c, uint16_t word) { + const uint8_t w[] = {0x0D, 0x07, 0x40, (uint8_t)(word >> 8), (uint8_t)(word & 0xFF)}; + vsx_spi_transaction(c, w, nullptr, sizeof(w)); +} + +vsx_state state_of(vsx_chip* c) { + vsx_state s; + vsx_get_state(c, &s); + return s; +} + +vsx_counters counters_of(vsx_chip* c) { + vsx_counters k; + vsx_get_counters(c, &k); + return k; +} + +void arm_receiver(vsx_chip* c) { + const uint8_t dio[] = {0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0}; + vsx_spi_transaction(c, dio, nullptr, sizeof(dio)); + const uint8_t rx[] = {0x82, 0xFF, 0xFF, 0xFF}; + vsx_spi_transaction(c, rx, nullptr, sizeof(rx)); +} + +} // namespace + +int main() { + std::printf("virtual-sx1262 modem"); + + /* ---------------------------------------------------------------- */ + CASE("every bandwidth in the table is a bandwidth"); + { + vsx_chip* c = vsx_create(); + const uint8_t codes[] = {0x00, 0x08, 0x01, 0x09, 0x02, 0x0A, 0x03, 0x04, 0x05, 0x06}; + const uint32_t hz[] = {7810, 10420, 15630, 20830, 31250, + 41670, 62500, 125000, 250000, 500000}; + bool all = true; + for (unsigned i = 0; i < sizeof(codes); ++i) { + set_modulation(c, 9, codes[i], 1, 0); + if (state_of(c).bandwidth_hz != hz[i]) { + all = false; + } + } + check(all, "all ten codes decode to the datasheet's bandwidths"); + check(counters_of(c).params_rejected == 0, "and none of them is refused"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a bandwidth code the part does not define is refused and counted"); + /* This file's own harness had been sending 0x1A, which is not a code. The + * modem kept its previous setting, so every timing assertion ran at a + * bandwidth nobody had chosen and nothing said so. */ + { + vsx_chip* c = vsx_create(); + set_modulation(c, 9, 0x04, 1, 0); + check(state_of(c).bandwidth_hz == 125000, "125 kHz to begin with"); + + set_modulation(c, 9, 0x1A, 1, 0); + check(state_of(c).bandwidth_hz == 125000, + "a code that is not a code changes nothing"); + check(counters_of(c).params_rejected == 1, "and is counted as refused"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a spreading factor or coding rate outside the part is refused too"); + { + vsx_chip* c = vsx_create(); + set_modulation(c, 9, 0x04, 1, 0); + + set_modulation(c, 20, 0x04, 1, 0); + check(state_of(c).spreading_factor == 9, "SF20 does not become the setting"); + set_modulation(c, 9, 0x04, 7, 0); + check(state_of(c).coding_rate == 5, "nor does a coding rate of 4/11"); + check(counters_of(c).params_rejected == 2, "both are counted"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("low data rate optimisation is the field the firmware programmed"); + /* It used to be inferred from the spreading factor alone, which is the one + * thing it does not depend on: the threshold is a symbol duration, so SF11 + * crosses it at 125 kHz and does not at 250. The airtime this chip quotes is + * the figure the firmware times its own CSMA on, so inferring it wrong + * desynchronised the chip from the air it was supposed to be describing. */ + { + vsx_chip* c = vsx_create(); + + set_modulation(c, 11, 0x05, 1, 0); /* SF11, 250 kHz, 4/5, LDRO off */ + check(state_of(c).low_data_rate_optimize == 0, "off is reported off"); + const uint32_t without = vsx_est_airtime_ms(c, 50); + check(without >= 640 && without <= 642, "a 50 byte frame is about 641 ms"); + + set_modulation(c, 11, 0x05, 1, 1); /* the same, LDRO on */ + check(state_of(c).low_data_rate_optimize == 1, "on is reported on"); + const uint32_t with = vsx_est_airtime_ms(c, 50); + check(with >= 722 && with <= 724, "with it on the same frame is about 723 ms"); + check(with > without, "two bits a symbol is not free"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a firmware that never programmed it falls back to the symbol duration"); + /* Not to the spreading factor. SF11 is over the threshold at 125 kHz and + * under it at 250, and the fallback has to say so. */ + { + vsx_chip* c = vsx_create(); + const uint8_t narrow[] = {0x8B, 11, 0x04, 1}; /* no LDRO byte at all */ + vsx_spi_transaction(c, narrow, nullptr, sizeof(narrow)); + check(state_of(c).low_data_rate_optimize == 1, + "SF11 at 125 kHz is over the threshold"); + + vsx_chip* d = vsx_create(); + const uint8_t wide[] = {0x8B, 11, 0x05, 1}; + vsx_spi_transaction(d, wide, nullptr, sizeof(wide)); + check(state_of(d).low_data_rate_optimize == 0, "SF11 at 250 kHz is under it"); + vsx_destroy(c); + vsx_destroy(d); + } + + /* ---------------------------------------------------------------- */ + CASE("the chip comes up on the private sync word"); + /* Zero would have every node agreeing with every other node about a value + * none of them chose, which is the same bug as the shared keypair: a default + * that is not the part's default is a default that hides a missing setting. */ + { + vsx_chip* c = vsx_create(); + check(state_of(c).sync_word == 0x1424, "0x1424, as the part resets to"); + write_sync_word(c, 0x3444); + check(state_of(c).sync_word == 0x3444, "and the firmware can set the public one"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a frame sent under another sync word is not this node's to hear"); + /* The word is written through two registers rather than a command, so it + * landed in the register array and nothing read it back: two meshes sharing a + * frequency heard each other perfectly, which is the opposite of what the + * word is for. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + write_sync_word(c, 0x1424); + + const uint8_t frame[] = {1, 2, 3, 4}; + vsx_deliver_frame_from(c, frame, sizeof(frame), 0x3444); + vsx_tick(c, ++now); + check((irq_flags(c) & IRQ_RX_DONE) == 0, "the other network's frame is refused"); + check(counters_of(c).sync_mismatches == 1, "and counted, not silently lost"); + + vsx_deliver_frame_from(c, frame, sizeof(frame), 0x1424); + vsx_tick(c, ++now); + check((irq_flags(c) & IRQ_RX_DONE) != 0, "its own network's frame arrives"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a host that does not model sync words is unaffected"); + /* vsx_deliver_frame makes no claim about the transmitter, and no claim is not + * a claim that it matched. Refusing on it would break every host that has not + * been taught about the word yet. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + write_sync_word(c, 0x3444); + + const uint8_t frame[] = {9, 9}; + vsx_deliver_frame(c, frame, sizeof(frame)); + vsx_tick(c, ++now); + check((irq_flags(c) & IRQ_RX_DONE) != 0, "the older entry point still delivers"); + check(counters_of(c).sync_mismatches == 0, "and refuses nothing"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a refused frame does not occupy the receiver either"); + /* Dropping it into the inbox and then refusing it later would make it a frame + * the chip was deaf to, which is a different counter and a different fault. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + arm_receiver(c); + const uint8_t frame[] = {1}; + vsx_deliver_frame_from(c, frame, sizeof(frame), 0x3444); + vsx_tick(c, ++now); + check(counters_of(c).frames_dropped == 0, "it was never handed over to be dropped"); + check(counters_of(c).sync_mismatches == 1, "it was refused at the door"); + vsx_destroy(c); + } + + std::printf("\n%s: %d failure(s)\n", g_failures ? "FAILED" : "PASSED", g_failures); + return g_failures ? 1 : 0; +} From 8fb962356946561588c644d287b73899d568fe68 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 18 Sep 2026 23:12:53 +0100 Subject: [PATCH 3/3] decode the rest of the command set, and say which parts are only recorded Every opcode outside MeshCore's driver used to fall through to "acknowledged and ignored": the firmware issued it, got a status byte, and carried on with the chip in a state it had not asked for. They are decoded now, in commands.cpp, and each one says in its comment which of three things it is - acted on, recorded, or a placeholder - because a model that quietly does nothing is worse than one that does nothing loudly. Acted on. SetPacketType, so GetPacketType stops answering a hardcoded LoRa; only LoRa has a data path, and a firmware that switches is reported switched so a host can refuse to believe the run. StopTimerOnPreamble, which is the setting that stops a bounded receive cutting off a frame it is in the middle of. SetLoRaSymbNumTimeout, the same deadline counted in symbols. SetFs. GetStats and ResetStats. And GetStatus, which was the constant 0x22: it answered standby whatever the part was doing, so a firmware polling it to find out whether its receiver was still armed was told no by a chip that was receiving. Recorded. SetPaConfig, which is half of what sets output power and the board owns the other half. SetRegulatorMode, SetDIO2AsRfSwitchCtrl, SetDIO3AsTcxoCtrl, CalibrateImage, and the two test modes. Recording is not a lesser answer: the fault this model exists to catch is a node configured differently from how its operator believes, and a value nobody can read back is a value nobody can check. Placeholders, each named in the code. SetRxDutyCycle records its periods and leaves the receiver on, because a receiver deaf for a scheduled fraction of the time changes which frames arrive and the engine already decides that. SetRxTxFallbackMode is recorded and FS collapses into standby, because the difference between the three fallbacks is start-up latency and current draw and this model has neither. vsx_deliver_frame_failed is the entry point CrcErr and HeaderErr need to be raisable at all, and nothing calls it: the chip cannot know a CRC failed, and no host is wired to tell it. SetPacketParams reads all six fields. Header type, CRC and invert-IQ were discarded and estAirtimeMs assumed two of them, which is the same shape as the low data rate optimisation bug and in the same figure the firmware times its CSMA on: an implicit header is twenty symbols this model charged for and the part did not. deliverFrame() is now the one path a frame reaches the chip by. inbox stays public because hosts push into it and making it private breaks them, which leaves the hole open: a host that pushes walks around the sync-word check and will walk around the next one too. vsx_state and vsx_counters gain fields at the end only; VSX_ABI_MINOR 5 -> 6. --- build.sh | 4 +- include/virtual_sx1262.h | 41 ++++++ src/VirtualSX1262.cpp | 55 ++++++-- src/VirtualSX1262.h | 103 ++++++++++++++ src/abi.cpp | 30 +++- src/cad.cpp | 2 +- src/commands.cpp | 263 +++++++++++++++++++++++++++++++++++ src/registers.h | 51 +++++++ src/spi.cpp | 19 ++- test/harness.h | 2 + test/test_c_abi.c | 1 + test/test_commands.cpp | 289 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 840 insertions(+), 20 deletions(-) create mode 100644 src/commands.cpp create mode 100644 test/test_commands.cpp diff --git a/build.sh b/build.sh index de50a3f..2cebc7c 100755 --- a/build.sh +++ b/build.sh @@ -35,7 +35,7 @@ CXXFLAGS="-std=c++17 -O2 -Iinclude -Isrc $WARN ${CXXFLAGS}" CFLAGS_C="-std=c11 -O2 -Iinclude -Wall -Wextra -Wpedantic ${CFLAGS}" [ -n "$STRICT" ] && CFLAGS_C="$CFLAGS_C -Werror" -SRC="src/VirtualSX1262.cpp src/cad.cpp src/spi.cpp src/abi.cpp" +SRC="src/VirtualSX1262.cpp src/cad.cpp src/commands.cpp src/spi.cpp src/abi.cpp" # SHARED_RUNTIME is the toolchain runtime the shared build links against. # @@ -115,7 +115,7 @@ check_shared_imports() { # One binary per subject rather than one big one, because the house limit on # file length applies to tests too and a single file had already outgrown it. # They share test/harness.h and nothing else, so each counts its own failures. -TESTS="test_reception test_interrupts test_spi test_cad test_modem" +TESTS="test_reception test_interrupts test_spi test_cad test_modem test_commands" build_tests() { for t in $TESTS; do diff --git a/include/virtual_sx1262.h b/include/virtual_sx1262.h index 8a80bed..d412f97 100644 --- a/include/virtual_sx1262.h +++ b/include/virtual_sx1262.h @@ -108,6 +108,20 @@ void vsx_deliver_frame(vsx_chip* chip, const uint8_t* frame, size_t len); void vsx_deliver_frame_from(vsx_chip* chip, const uint8_t* frame, size_t len, uint16_t sync_word); +/* A frame the receiver got and could not trust. `failure` is 1 for a CRC error + * and 2 for a header error. + * + * PLACEHOLDER INTERFACE, and the only way CrcErr or HeaderErr can be raised at + * all. The chip cannot decide this: whether a frame's CRC checks depends on + * what arrived at the antenna, which the simulator owns. Nothing calls this + * yet, so both flags stay unreachable in practice and the firmware's error + * paths stay unexercised: under calculated RF a frame arrives whole or not at + * all and nothing ever will, and under waveform RF the demodulator genuinely + * can fail a CRC but nothing is wired to say so. A CRC failure delivers the + * corrupt payload and raises RxDone beside CrcErr, as the part does. */ +void vsx_deliver_frame_failed(vsx_chip* chip, const uint8_t* frame, size_t len, + uint8_t failure); + /* The waveform the chip started has finished on the air. The chip cannot know * this: how long a transmission occupied the channel is a property of the * samples the simulator generated. */ @@ -165,6 +179,27 @@ typedef struct { * and so changes every airtime this chip quotes, and because a firmware that * never programs it is a different case from one that programs it off. */ uint8_t low_data_rate_optimize; + /* The rest of the command set, as the firmware programmed it. Most of these + * are recorded rather than acted on; src/commands.cpp says which is which for + * each one, and a host reading a value here is reading what was asked for, + * not a claim that the model obeyed it. */ + uint8_t packet_type; /* 0 GFSK, 1 LoRa, 3 LR-FHSS; only LoRa runs */ + uint8_t fallback_mode; /* 0x20 STDBY_RC, 0x30 STDBY_XOSC, 0x40 FS */ + uint8_t regulator_mode; /* recorded only; this model has no current */ + uint8_t dio2_as_rf_switch; /* recorded; the FEM line is host-driven here */ + uint8_t dio3_as_tcxo; /* recorded only */ + uint8_t pa_duty_cycle; /* recorded; with pa_hp_max this is half of */ + uint8_t pa_hp_max; /* what sets output power on silicon, and the */ + uint8_t pa_device_sel; /* board owns the other half */ + uint8_t symb_num_timeout; /* a receive deadline counted in symbols */ + uint8_t stop_timer_on_preamble; + uint8_t tx_continuous_wave; /* a bare carrier; the host must put it on air */ + uint8_t tx_infinite_preamble; + uint8_t header_implicit; /* the three SetPacketParams fields that were */ + uint8_t crc_on; /* discarded, all of which change airtime */ + uint8_t invert_iq; + uint32_t rx_duty_rx_period; /* recorded; the receiver does not sleep */ + uint32_t rx_duty_sleep_period; } vsx_state; void vsx_get_state(const vsx_chip* chip, vsx_state* out); @@ -192,6 +227,12 @@ typedef struct { * word receives nothing and reports nothing wrong. */ uint32_t sync_mismatches; uint32_t params_rejected; + /* GetStats' three counters, as the firmware reads them. rx_packets is real; + * the other two can only move through vsx_deliver_frame_failed, which nothing + * calls, so a firmware polling them sees zero however bad the channel is. */ + uint16_t stat_rx_packets; + uint16_t stat_crc_errors; + uint16_t stat_header_errors; } vsx_counters; void vsx_get_counters(const vsx_chip* chip, vsx_counters* out); diff --git a/src/VirtualSX1262.cpp b/src/VirtualSX1262.cpp index 2838da8..80ce0a3 100644 --- a/src/VirtualSX1262.cpp +++ b/src/VirtualSX1262.cpp @@ -94,7 +94,7 @@ void VirtualSX1262::tick(uint64_t nowMs) { // trusts it believes the channel is busy for ever and stops transmitting. // That is the difference between MeshCore 1.16 and 1.17, and it cannot be // seen on a chip that never lies. - if (stuckIrqMs_ > 0 && mode_ == 1 && nowMs_ >= nextSpuriousMs_) { + if (stuckIrqMs_ > 0 && mode_ == kModeRx && nowMs_ >= nextSpuriousMs_) { raiseIrq(kIrqPreambleDetected); spuriousRaises_++; nextSpuriousMs_ = nowMs_ + stuckIrqMs_; @@ -110,25 +110,27 @@ void VirtualSX1262::tick(uint64_t nowMs) { // Reaching it is a Timeout and a return to standby, exactly as a completed // reception would be, because a receiver that stops listening has stopped // listening whichever way it got there. - if (mode_ == 1 && rxDeadlineArmed_ && nowMs_ >= rxDeadlineMs_) { + if (mode_ == kModeRx && rxDeadlineArmed_ && nowMs_ >= rxDeadlineMs_) { rxDeadlineArmed_ = false; - mode_ = 0; + mode_ = kModeStandby; raiseIrq(kIrqTimeout); } // The transmit deadline, which the part enforces against a waveform that // never finished. The frame stays queued: the chip gave up on it, and whether // any of it reached the air is the simulator's to say, not ours. - if (mode_ == 2 && txDeadlineArmed_ && nowMs_ >= txDeadlineMs_) { + // A timeout goes to STDBY_RC whatever SetRxTxFallbackMode asked for: the + // fallback is where a transmission that finished goes, and this one did not. + if (mode_ == kModeTx && txDeadlineArmed_ && nowMs_ >= txDeadlineMs_) { txDeadlineArmed_ = false; - mode_ = 0; + mode_ = kModeStandby; raiseIrq(kIrqTimeout); } // Preamble and header detection, in receive mode only. A node cannot hear // anything while its own transmitter is keyed - that is half duplex, and the // engine already reports the channel as clear to a node that is transmitting. - if (mode_ != 1) return; + if (mode_ != kModeRx) return; const double symMs = symbolMs(); if (channelBusy_) { @@ -137,6 +139,12 @@ void VirtualSX1262::tick(uint64_t nowMs) { raiseIrq(kIrqPreambleDetected); preambleRaised_ = true; preambleRaises_++; + // StopTimerOnPreamble: a receiver that has heard something is no longer + // on a deadline, so a bounded receive cannot cut off the frame it is in + // the middle of. + if (stopTimerOnPreamble_) { + rxDeadlineArmed_ = false; + } } if (!headerRaised_ && sinceMs >= kHeaderSymbols * symMs) { raiseIrq(kIrqHeaderValid | kIrqSyncWordValid); @@ -168,7 +176,7 @@ void VirtualSX1262::deliverFrameFrom(const uint8_t* frame, size_t len, syncMismatches_++; return; } - inbox.emplace_back(frame, frame + len); + deliverFrame(frame, len); } void VirtualSX1262::setChannelBusy(bool busy) { @@ -198,10 +206,12 @@ void VirtualSX1262::setChannelBusy(bool busy) { } void VirtualSX1262::transmitFinished() { - if (mode_ == 2) { + if (mode_ == kModeTx) { txDeadlineArmed_ = false; + txContinuousWave_ = false; + txInfinitePreamble_ = false; raiseIrq(kIrqTxDone); - mode_ = 0; + applyFallback(); } } @@ -243,7 +253,7 @@ uint64_t VirtualSX1262::inboxGraceMs() const { // deafness and the grace exists to cover exactly that. void VirtualSX1262::settleInbox() { if (inbox.empty()) return; - if (mode_ == 1) { + if (mode_ == kModeRx) { deliverPending(); return; } @@ -259,6 +269,7 @@ void VirtualSX1262::deliverPending() { rxLen_ = (uint8_t)(f.size() > 255 ? 255 : f.size()); memcpy(&buffer_[rxBase_], f.data(), rxLen_); inbox.pop_front(); + statRxPackets_++; raiseIrq(kIrqRxDone); // Deliberately only RxDone. The detection flags say "a carrier is present", // and by the time a frame is delivered the carrier has ended - raising them @@ -300,6 +311,13 @@ void VirtualSX1262::startRx(uint32_t timeoutRaw) { rxDeadlineArmed_ = timeoutRaw != 0 && timeoutRaw != kRxContinuous; if (rxDeadlineArmed_) { rxDeadlineMs_ = nowMs_ + (uint64_t)((double)timeoutRaw * kTimeoutStepMs + 0.5); + } else if (timeoutRaw == 0 && symbNumTimeout_ > 0) { + // SetLoRaSymbNumTimeout, which is the same deadline counted in symbols. It + // only gets a look in when SetRx did not bring one of its own: the part + // runs a single timer, and which of the two wins is the placeholder noted + // in commands.cpp. + rxDeadlineArmed_ = true; + rxDeadlineMs_ = nowMs_ + (uint64_t)((double)symbNumTimeout_ * symbolMs() + 0.5); } // Arming the receiver is itself a delivery point, not just a change of mode. @@ -341,7 +359,10 @@ uint32_t VirtualSX1262::estAirtimeMs(int lenBytes) const { const double bwHz = bwKHz_ * 1000.0; const double tSym = (double)(1u << sf_) / bwHz; const int de = lowDataRateOptimize() ? 1 : 0; - const int crc = 1, header = 0, crDen = cr_; + // IH is 1 when the header is implicit, which is the header being absent, so + // it subtracts its twenty symbols. Both of these were constants standing in + // for fields SetPacketParams carries. + const int crc = crcOn_ ? 1 : 0, header = headerImplicit_ ? 1 : 0, crDen = cr_; double num = 8.0 * lenBytes - 4.0 * sf_ + 28 + 16 * crc - 20 * header; double den = 4.0 * (sf_ - 2 * de); double payloadSyms = 8 + fmax(ceil(num / den) * crDen, 0.0); @@ -429,7 +450,19 @@ bool VirtualSX1262::lowDataRateOptimize() const { return ldroProgrammed_ ? ldro_ : (symbolMs() >= kLowDataRateSymbolMs); } +// SetPacketParams: [preamble 15:8][preamble 7:0][headerType][payloadLen][crcOn] +// [invertIq]. +// +// Three of the six used to be discarded, and estAirtimeMs assumed values for +// two of them. That is the same shape as the low data rate optimisation bug: +// a field the firmware programs, replaced by an assumption, in the figure the +// firmware times its own CSMA on. An implicit header is twenty symbols of +// payload the model was charging for and the part was not, and CRC off is +// sixteen bits it was charging for too. void VirtualSX1262::applyPacketParams(const uint8_t* p) { preambleSyms_ = ((uint32_t)p[0] << 8) | p[1]; + headerImplicit_ = p[2] != 0; txLenForSend_ = p[3]; + crcOn_ = p[4] != 0; + invertIq_ = p[5] != 0; } diff --git a/src/VirtualSX1262.h b/src/VirtualSX1262.h index a22c964..bfaeab4 100644 --- a/src/VirtualSX1262.h +++ b/src/VirtualSX1262.h @@ -207,6 +207,40 @@ class VirtualSX1262 { uint32_t syncMismatches() const { return syncMismatches_; } uint32_t paramsRejected() const { return paramsRejected_; } + // ---- what the rest of the command set was told ---- + // + // Most of these are recorded rather than acted on, and each says which it is + // where it is applied. Recording is not a lesser answer: the fault this model + // exists to catch is a firmware configured differently from how its operator + // believes, and a value nobody can read back is a value nobody can check. + + uint8_t packetType() const { return packetType_; } + uint8_t fallbackMode() const { return fallbackMode_; } + uint8_t regulatorMode() const { return regulatorMode_; } + bool dio2AsRfSwitch() const { return dio2AsRfSwitch_; } + bool dio3AsTcxo() const { return dio3AsTcxo_; } + uint8_t paDutyCycle() const { return paDutyCycle_; } + uint8_t paHpMax() const { return paHpMax_; } + uint8_t paDeviceSel() const { return paDeviceSel_; } + uint8_t symbNumTimeout() const { return symbNumTimeout_; } + bool stopTimerOnPreamble() const { return stopTimerOnPreamble_; } + uint32_t rxDutyRxPeriod() const { return rxDutyRxPeriod_; } + uint32_t rxDutySleepPeriod() const { return rxDutySleepPeriod_; } + bool txContinuousWave() const { return txContinuousWave_; } + bool txInfinitePreamble() const { return txInfinitePreamble_; } + + // Packet parameters beyond the two that were already read. All three change + // airtime, and all three were assumed rather than read. + bool headerImplicit() const { return headerImplicit_; } + bool crcOn() const { return crcOn_; } + bool invertIq() const { return invertIq_; } + + // GetStats' three counters. Received is real; the other two can only move + // through deliverFrameFailed(), which nothing calls yet. + uint16_t statRxPackets() const { return statRxPackets_; } + uint16_t statCrcErrors() const { return statCrcErrors_; } + uint16_t statHeaderErrors() const { return statHeaderErrors_; } + // The LoRa sync word, from the two registers the firmware writes it into. // // Read out of the register array rather than shadowed, because the firmware @@ -219,6 +253,26 @@ class VirtualSX1262 { // refuse one that was not meant for this network. void deliverFrameFrom(const uint8_t* frame, size_t len, uint16_t syncWordSent); + // Hand over a frame the receiver got but could not trust. + // + // PLACEHOLDER INTERFACE. The chip cannot decide this: whether a frame's CRC + // checks depends on what actually arrived at the antenna, which is the + // engine's business. So the caller says, and this is the entry point that was + // missing for CrcErr and HeaderErr to be raisable at all. What is not settled + // is who calls it: under calculated RF a frame arrives whole or not at all + // and nothing ever will, and under waveform RF the demodulator genuinely can + // fail a CRC but nothing in MeshBench is wired to say so. Until a host calls + // it, these two flags remain unreachable in practice and the firmware's error + // paths stay unexercised. + void deliverFrameFailed(const uint8_t* frame, size_t len, uint8_t failure); + + // Every path a frame reaches the chip by, in one place. + // + // inbox is still public because hosts push into it directly and making it + // private breaks them. That is the hole: a host that pushes walks around the + // sync-word check and will walk around the next one too. New callers use this. + void deliverFrame(const uint8_t* frame, size_t len); + // Whether the modem is spending two bits a symbol on low data rate // optimisation, from what the firmware programmed rather than from the // spreading factor alone. @@ -266,6 +320,12 @@ class VirtualSX1262 { void applyModulation(const uint8_t* p, size_t n); void applyPacketParams(const uint8_t* p); void applyCadParams(const uint8_t* p); + // The commands that were missing, decoded in commands.cpp. + bool runExtraCommand(uint8_t op, const uint8_t* out, size_t len, uint8_t* in); + void applyPaConfig(const uint8_t* p); + void applyRxDutyCycle(const uint8_t* p); + void applyFallback(); + uint8_t statusByte() const; void startRx(uint32_t timeoutRaw); void startTx(uint32_t timeoutRaw); void startCad(); @@ -399,6 +459,49 @@ class VirtualSX1262 { uint32_t cadDetections_ = 0; uint32_t syncMismatches_ = 0; uint32_t paramsRejected_ = 0; + + // ---- the rest of the command set ---- + // + // Reset values throughout, so a chip nobody has configured reads as the part + // does rather than as zero. Zero is a legitimate setting for several of these + // and inventing it hides a firmware that never set one. + uint8_t packetType_ = 0x01; // LoRa; the only type with a data path here + uint8_t fallbackMode_ = 0x20; // STDBY_RC, the part's reset value + uint8_t regulatorMode_ = 0x00; // LDO + bool dio2AsRfSwitch_ = false; + bool dio3AsTcxo_ = false; + uint8_t paDutyCycle_ = 0; + uint8_t paHpMax_ = 0; + uint8_t paDeviceSel_ = 0; + uint8_t paLut_ = 1; + uint8_t symbNumTimeout_ = 0; + bool stopTimerOnPreamble_ = false; + uint32_t rxDutyRxPeriod_ = 0; + uint32_t rxDutySleepPeriod_ = 0; + bool txContinuousWave_ = false; + bool txInfinitePreamble_ = false; + + // SetPacketParams' remaining fields. Defaults are RadioLib's: explicit + // header, CRC on, IQ not inverted. + bool headerImplicit_ = false; + bool crcOn_ = true; + bool invertIq_ = false; + + // GetStats. uint16 because that is the width the command answers in, and a + // counter that wraps the way the part's does is a counter a firmware can be + // tested against. + uint16_t statRxPackets_ = 0; + uint16_t statCrcErrors_ = 0; + uint16_t statHeaderErrors_ = 0; + + // GetDeviceErrors' word. + // + // PLACEHOLDER. Nothing sets it, because every error it can report is an + // analogue or calibration fault this model does not have: PLL lock, image + // calibration, XOSC start, PA ramp. A host that wants to exercise the + // firmware's handling of one needs a way to inject it, and that entry point + // is not here because nothing has asked for it yet. + uint16_t deviceErrors_ = 0; uint64_t lastBusyTickMs_ = 0; // Fault injection: how long a raised flag refuses to clear. 0 is a chip that diff --git a/src/abi.cpp b/src/abi.cpp index ceba73b..dea66bb 100644 --- a/src/abi.cpp +++ b/src/abi.cpp @@ -19,7 +19,7 @@ #include #define VSX_ABI_MAJOR 1 -#define VSX_ABI_MINOR 5 +#define VSX_ABI_MINOR 6 struct vsx_chip { VirtualSX1262 chip; @@ -157,6 +157,14 @@ void vsx_deliver_frame_from(vsx_chip* chip, const uint8_t* frame, size_t len, } } +void vsx_deliver_frame_failed(vsx_chip* chip, const uint8_t* frame, size_t len, + uint8_t failure) { + if (chip && frame) { + chip->chip.deliverFrameFailed(frame, len, failure); + settle_dio1(chip); + } +} + void vsx_transmit_finished(vsx_chip* chip) { if (!chip) { return; @@ -215,6 +223,23 @@ void vsx_get_state(const vsx_chip* chip, vsx_state* out) { out->cad_exit_mode = c.cadExitMode(); out->sync_word = c.syncWord(); out->low_data_rate_optimize = c.lowDataRateOptimize() ? 1 : 0; + out->packet_type = c.packetType(); + out->fallback_mode = c.fallbackMode(); + out->regulator_mode = c.regulatorMode(); + out->dio2_as_rf_switch = c.dio2AsRfSwitch() ? 1 : 0; + out->dio3_as_tcxo = c.dio3AsTcxo() ? 1 : 0; + out->pa_duty_cycle = c.paDutyCycle(); + out->pa_hp_max = c.paHpMax(); + out->pa_device_sel = c.paDeviceSel(); + out->symb_num_timeout = c.symbNumTimeout(); + out->stop_timer_on_preamble = c.stopTimerOnPreamble() ? 1 : 0; + out->tx_continuous_wave = c.txContinuousWave() ? 1 : 0; + out->tx_infinite_preamble = c.txInfinitePreamble() ? 1 : 0; + out->header_implicit = c.headerImplicit() ? 1 : 0; + out->crc_on = c.crcOn() ? 1 : 0; + out->invert_iq = c.invertIq() ? 1 : 0; + out->rx_duty_rx_period = c.rxDutyRxPeriod(); + out->rx_duty_sleep_period = c.rxDutySleepPeriod(); } void vsx_get_counters(const vsx_chip* chip, vsx_counters* out) { @@ -233,6 +258,9 @@ void vsx_get_counters(const vsx_chip* chip, vsx_counters* out) { out->cad_detections = c.cadDetections(); out->sync_mismatches = c.syncMismatches(); out->params_rejected = c.paramsRejected(); + out->stat_rx_packets = c.statRxPackets(); + out->stat_crc_errors = c.statCrcErrors(); + out->stat_header_errors = c.statHeaderErrors(); } uint32_t vsx_est_airtime_ms(const vsx_chip* chip, int len_bytes) { diff --git a/src/cad.cpp b/src/cad.cpp index 88eba97..b052822 100644 --- a/src/cad.cpp +++ b/src/cad.cpp @@ -71,5 +71,5 @@ void VirtualSX1262::finishCad() { cadDetections_++; raiseIrq(kIrqCadDetected); } - mode_ = 0; + mode_ = kModeStandby; } diff --git a/src/commands.cpp b/src/commands.cpp new file mode 100644 index 0000000..9868faf --- /dev/null +++ b/src/commands.cpp @@ -0,0 +1,263 @@ +/* The rest of the command set: the commands MeshCore never issues. + * + * Its own file because the reason all of these were missing is the same, and it + * is not a good reason: the model grew to the shape of one firmware's driver, + * so a command nothing in that path sends was a command that silently did + * nothing. A host would issue it, get a status byte back, and carry on with the + * chip in a state it had not asked for. + * + * Three kinds of thing live here, and each one says which it is: + * + * ACTED ON the command changes what the model does. + * RECORDED the command's parameters are stored and reported, and nothing + * here acts on them, because acting would mean inventing physics + * this model is not given. Recording is still the point: the + * fault worth catching is a firmware configured differently from + * how its operator believes, and a value nobody can read back is + * a value nobody can check. + * PLACEHOLDER the command is decoded but its effect is not modelled, and the + * comment says what would have to exist first. + * + * Nothing here is a stub that pretends. A command that is RECORDED does not + * report success at something it did not do. + */ +#include "VirtualSX1262.h" + +#include "registers.h" + +// SetPaConfig. RECORDED. +// +// This is half of what sets output power on silicon, the other half being +// SetTxParams, and the two are a pair: the datasheet gives four configurations +// for +14, +17, +20 and +22 dBm and the duty cycle must not be raised beyond +// the one for the power asked for. Turning the pair into an actual figure at +// the antenna is a board question as much as a chip one, so it belongs to +// whatever knows the board. Reported so that it can be. +void VirtualSX1262::applyPaConfig(const uint8_t* p) { + paDutyCycle_ = p[0]; + paHpMax_ = p[1]; + paDeviceSel_ = p[2]; + paLut_ = p[3]; +} + +// SetRxDutyCycle. PLACEHOLDER. +// +// The part alternates receive and sleep on its own timers, waking to listen for +// rxPeriod and sleeping for sleepPeriod, and a preamble seen during a listening +// window holds it awake. Modelling it means a receiver that is deaf for a +// scheduled fraction of the time, which changes which frames arrive at all, and +// that is a decision about what a node hears: the engine already owns that, and +// two things deciding it would be worse than one thing deciding it badly. +// +// So the periods are recorded and the chip stays in continuous receive. A +// firmware using duty-cycled receive will therefore appear to hear everything, +// which is the optimistic direction and must not be read as a result about +// power saving. +void VirtualSX1262::applyRxDutyCycle(const uint8_t* p) { + rxDutyRxPeriod_ = ((uint32_t)p[0] << 16) | ((uint32_t)p[1] << 8) | p[2]; + rxDutySleepPeriod_ = ((uint32_t)p[3] << 16) | ((uint32_t)p[4] << 8) | p[5]; + startRx(0); +} + +// Where the part goes when a transmission or reception finishes. ACTED ON, as +// far as this model has modes to go to. +// +// PLACEHOLDER for FS. The part distinguishes STDBY_RC, STDBY_XOSC and FS, and +// this model has one standby: the difference between them is start-up latency +// and current draw, neither of which it represents. A firmware that asks to +// fall back to FS gets standby, and the mode it asked for is readable through +// fallbackMode() so a host can tell that is what happened. +void VirtualSX1262::applyFallback() { + mode_ = kModeStandby; +} + +// GetStatus' byte. ACTED ON, where it used to be the constant 0x22. +// +// Bits 6:4 are the chip mode and 3:1 the command status. It answered STBY_RC +// whatever the part was doing, so a firmware polling this to find out whether +// its receiver was still armed was told "standby" by a chip in receive. The +// command status is the weaker half: this model has no failing commands, so it +// reports data available when there is a frame waiting and completed otherwise, +// which is true but narrower than the five values the part can return. +uint8_t VirtualSX1262::statusByte() const { + uint8_t chipMode = kStatusModeStandbyRc; + switch (mode_) { + case kModeRx: + chipMode = kStatusModeRx; + break; + case kModeTx: + chipMode = kStatusModeTx; + break; + case kModeCad: + // CAD is not one of the modes the status byte names. The part is + // receiving while it scans, and that is the honest answer of the ones + // available. + chipMode = kStatusModeRx; + break; + default: + chipMode = kStatusModeStandbyRc; + break; + } + const uint8_t cmd = rxLen_ > 0 ? kStatusCmdDataAvailable : kStatusCmdCompleted; + return (uint8_t)((chipMode << 4) | (cmd << 1)); +} + +// A frame the receiver got and could not trust. See the header for why the +// interface is a placeholder: the chip cannot decide this, and nothing calls it +// yet. +void VirtualSX1262::deliverFrameFailed(const uint8_t* frame, size_t len, + uint8_t failure) { + (void)frame; + (void)len; + if (mode_ != kModeRx) { + return; + } + if (failure == kReceiveHeaderError) { + statHeaderErrors_++; + raiseIrq(kIrqHeaderErr); + return; + } + // A CRC failure is a frame that arrived: the payload is in the buffer and + // RxDone is raised beside CrcErr, because that is what the part does and a + // driver that reads the buffer on RxDone must find the corrupt bytes there + // rather than the last good frame's. + statCrcErrors_++; + rxLen_ = (uint8_t)(len > 255 ? 255 : len); + memcpy(&buffer_[rxBase_], frame, rxLen_); + raiseIrq(kIrqRxDone | kIrqCrcErr); +} + +void VirtualSX1262::deliverFrame(const uint8_t* frame, size_t len) { + inbox.emplace_back(frame, frame + len); +} + +// The decoder for everything above, returning false for an opcode that is still +// nobody's. Split from runCommand()'s switch so that the commands a real driver +// issues stay readable as one table. +bool VirtualSX1262::runExtraCommand(uint8_t op, const uint8_t* out, size_t len, + uint8_t* in) { + switch (op) { + // ACTED ON. The chip is one packet type at a time, and GetPacketType now + // answers what was set rather than always LoRa. Only LoRa has a data path + // here: a firmware that switches to GFSK or LR-FHSS will find the type + // reported back correctly and the modem still behaving as LoRa, which is + // recorded in packetType() so a host can refuse to believe the run. + case kSetPacketType: + if (len >= 2) packetType_ = out[1]; + return true; + + // RECORDED. + case kSetPaConfig: + if (len >= 5) applyPaConfig(&out[1]); + return true; + + // ACTED ON at the end of a transmission or reception, with FS collapsed + // into standby. See applyFallback(). + case kSetRxTxFallbackMode: + if (len >= 2) fallbackMode_ = out[1]; + return true; + + // ACTED ON. A symbol-count timeout is the same deadline SetRx's millisecond + // one is, measured in the unit the firmware chose to think in. Zero + // disables it, which is the reset value. + // + // PLACEHOLDER for the interaction with SetRx's own timeout: the part runs + // one timer, and which of the two wins when a firmware sets both is not + // something this model has been able to check against silicon. Here the + // millisecond timeout wins if it is armed, because that is the one + // RadioLib actually sends. + case kSetLoRaSymbNumTimeout: + if (len >= 2) symbNumTimeout_ = out[1]; + return true; + + // ACTED ON. With the timer stopped on preamble, a receiver that has heard + // something is no longer on a deadline: it waits for the frame. This is the + // setting that stops a bounded receive from cutting off a packet it is in + // the middle of. + case kStopTimerOnPreamble: + if (len >= 2) stopTimerOnPreamble_ = out[1] != 0; + return true; + + // PLACEHOLDER. See applyRxDutyCycle(). + case kSetRxDutyCycle: + if (len >= 7) applyRxDutyCycle(&out[1]); + return true; + + // ACTED ON, as far as there is anything to act on: frequency synthesis is + // not receiving and not transmitting, so it is this model's standby. The + // part distinguishes them by start-up latency, which is not represented. + case kSetFs: + mode_ = kModeStandby; + return true; + + // RECORDED, and deliberately not acted on. + // + // Both of these key the transmitter with no frame behind it: a bare carrier + // and an endless preamble, which exist for regulatory measurement. They are + // a transmission this model has no way to hand to the engine, because + // pendingTx carries frames and there is no frame. A host wanting to model an + // interferer reads txContinuousWave() and puts a carrier on the air itself. + case kSetTxContinuousWave: + txContinuousWave_ = true; + mode_ = kModeTx; + return true; + case kSetTxInfinitePreamble: + txInfinitePreamble_ = true; + mode_ = kModeTx; + return true; + + // RECORDED. DC-DC against LDO is a current-draw decision, and this model + // has no current. + case kSetRegulatorMode: + if (len >= 2) regulatorMode_ = out[1]; + return true; + + // RECORDED, and the first one matters more than it looks. + // + // With DIO2 driving the RF switch, the chip raises the transmit line itself + // rather than the firmware doing it as a GPIO. This model has the line the + // other way round, through setFemEnabled(), because that is how MeshCore + // drives it. A firmware that hands the job to DIO2 would therefore look like + // one that never raises the line at all, and femAtTx() would dock it for a + // fault it does not have. + // + // PLACEHOLDER: nothing reconciles the two. dio2AsRfSwitch() is reported so a + // host can notice the case, and the honest fix is for the chip to drive + // femAtTx_ itself when this is set, which needs a host that does not also + // drive it. + case kSetDio2AsRfSwitchCtrl: + if (len >= 2) dio2AsRfSwitch_ = out[1] != 0; + return true; + case kSetDio3AsTcxoCtrl: + dio3AsTcxo_ = true; + return true; + + // RECORDED as a no-op. Image calibration is an analogue procedure with no + // register this model keeps, unlike Calibrate, which has one. + case kCalibrateImage: + return true; + + // ACTED ON. [op][nop][status][pktRx 15:8][pktRx 7:0][crcErr..][hdrErr..] + // + // Two of the three can only move through deliverFrameFailed(), which + // nothing calls, so a firmware reading this will see received packets climb + // and both error counts sit at zero however bad the channel is. + case kGetStats: + if (len >= 4) in[2] = (uint8_t)(statRxPackets_ >> 8); + if (len >= 5) in[3] = (uint8_t)(statRxPackets_ & 0xFF); + if (len >= 6) in[4] = (uint8_t)(statCrcErrors_ >> 8); + if (len >= 7) in[5] = (uint8_t)(statCrcErrors_ & 0xFF); + if (len >= 8) in[6] = (uint8_t)(statHeaderErrors_ >> 8); + if (len >= 9) in[7] = (uint8_t)(statHeaderErrors_ & 0xFF); + return true; + + case kResetStats: + statRxPackets_ = 0; + statCrcErrors_ = 0; + statHeaderErrors_ = 0; + return true; + + default: + return false; + } +} diff --git a/src/registers.h b/src/registers.h index ff37b9e..27682cc 100644 --- a/src/registers.h +++ b/src/registers.h @@ -57,6 +57,57 @@ namespace { [[maybe_unused]] constexpr uint16_t kIrqCadDetected = 1 << 8; [[maybe_unused]] constexpr uint16_t kIrqTimeout = 1 << 9; +// The rest of the command set. Declared together because the reason each one +// was missing is the same: nothing in MeshCore's path issues it, so the model +// grew only what it was asked for. What each one now does is in commands.cpp, +// and several of them record rather than act. That is marked there. +[[maybe_unused]] constexpr uint8_t kSetFs = 0xC1; +[[maybe_unused]] constexpr uint8_t kSetTxContinuousWave = 0xD1; +[[maybe_unused]] constexpr uint8_t kSetTxInfinitePreamble = 0xD2; +[[maybe_unused]] constexpr uint8_t kSetRegulatorMode = 0x96; +[[maybe_unused]] constexpr uint8_t kCalibrateImage = 0x98; +[[maybe_unused]] constexpr uint8_t kSetPaConfig = 0x95; +[[maybe_unused]] constexpr uint8_t kSetRxTxFallbackMode = 0x93; +[[maybe_unused]] constexpr uint8_t kSetRxDutyCycle = 0x94; +[[maybe_unused]] constexpr uint8_t kStopTimerOnPreamble = 0x9F; +[[maybe_unused]] constexpr uint8_t kSetLoRaSymbNumTimeout = 0xA0; +[[maybe_unused]] constexpr uint8_t kSetDio2AsRfSwitchCtrl = 0x9D; +[[maybe_unused]] constexpr uint8_t kSetDio3AsTcxoCtrl = 0x97; +[[maybe_unused]] constexpr uint8_t kGetStats = 0x10; +[[maybe_unused]] constexpr uint8_t kResetStats = 0x00; + +// Packet types, from SetPacketType. Only LoRa has a data path in this model. +[[maybe_unused]] constexpr uint8_t kPacketTypeGfsk = 0x00; +[[maybe_unused]] constexpr uint8_t kPacketTypeLora = 0x01; +[[maybe_unused]] constexpr uint8_t kPacketTypeLrFhss = 0x03; + +// Where the part goes when a transmission or reception ends, from +// SetRxTxFallbackMode. +[[maybe_unused]] constexpr uint8_t kFallbackFs = 0x40; +[[maybe_unused]] constexpr uint8_t kFallbackStandbyXosc = 0x30; +[[maybe_unused]] constexpr uint8_t kFallbackStandbyRc = 0x20; + +// The chip modes this model distinguishes, which are fewer than the part has. +// See the note on kModeFs in VirtualSX1262.h. +[[maybe_unused]] constexpr uint8_t kModeStandby = 0; +[[maybe_unused]] constexpr uint8_t kModeRx = 1; +[[maybe_unused]] constexpr uint8_t kModeTx = 2; +[[maybe_unused]] constexpr uint8_t kModeCad = 3; + +// GetStatus packs the chip mode into bits 6:4 and the command status into 3:1. +[[maybe_unused]] constexpr uint8_t kStatusModeStandbyRc = 0x2; +[[maybe_unused]] constexpr uint8_t kStatusModeStandbyXosc = 0x3; +[[maybe_unused]] constexpr uint8_t kStatusModeFs = 0x4; +[[maybe_unused]] constexpr uint8_t kStatusModeRx = 0x5; +[[maybe_unused]] constexpr uint8_t kStatusModeTx = 0x6; +[[maybe_unused]] constexpr uint8_t kStatusCmdDataAvailable = 0x2; +[[maybe_unused]] constexpr uint8_t kStatusCmdCompleted = 0x6; + +// How a reception failed, for the deliberately corrupt delivery path. +[[maybe_unused]] constexpr uint8_t kReceiveOk = 0; +[[maybe_unused]] constexpr uint8_t kReceiveCrcError = 1; +[[maybe_unused]] constexpr uint8_t kReceiveHeaderError = 2; + // The LoRa sync word, which lives in two registers rather than a command. // // RadioLib writes it as a pair through WriteRegister, so nothing in the opcode diff --git a/src/spi.cpp b/src/spi.cpp index aee32e9..d34b32a 100644 --- a/src/spi.cpp +++ b/src/spi.cpp @@ -39,6 +39,7 @@ static bool returnsData(uint8_t op) { case kGetStatus: case kGetDeviceErrors: case kGetPacketType: + case kGetStats: return true; default: return false; @@ -75,7 +76,7 @@ uint8_t VirtualSX1262::transferByte(uint8_t out) { } // Everything else answers with the status byte in position 1 and nothing // else, exactly as the buffer path does. - return i == 1 ? 0x22 : 0x00; + return i == 1 ? statusByte() : 0x00; } void VirtualSX1262::endTransaction() { @@ -106,7 +107,7 @@ void VirtualSX1262::runCommand(const uint8_t* out, size_t len, uint8_t* in) { auto status = [&](uint8_t v) { if (len > 1) in[1] = v; }; - status(0x22); // standby, command completed + status(statusByte()); switch (op) { case kSetStandby: @@ -308,15 +309,23 @@ void VirtualSX1262::runCommand(const uint8_t* out, size_t len, uint8_t* in) { break; case kGetStatus: + // The status byte is the reply, and status() above has already put it in + // in[1]; a bare GetStatus is the one command whose whole answer it is. break; case kGetPacketType: - if (len >= 3) in[2] = 0x01; - break; // LoRa + if (len >= 3) in[2] = packetType_; + break; case kGetDeviceErrors: + if (len >= 4) in[2] = (uint8_t)(deviceErrors_ >> 8); + if (len >= 5) in[3] = (uint8_t)(deviceErrors_ & 0xFF); break; case kClearDeviceErrors: break; default: - break; // acknowledged and ignored + // Everything the driver this model grew around never sends. A command + // that is still nobody's lands here and is acknowledged, which is what + // silicon does with an opcode it does not implement. + runExtraCommand(op, out, len, in); + break; } } diff --git a/test/harness.h b/test/harness.h index f475265..267eb51 100644 --- a/test/harness.h +++ b/test/harness.h @@ -41,6 +41,8 @@ enum { IRQ_PREAMBLE = 1u << 2, IRQ_SYNC_WORD = 1u << 3, IRQ_HEADER = 1u << 4, + IRQ_HEADER_ERR = 1u << 5, + IRQ_CRC_ERR = 1u << 6, IRQ_CAD_DONE = 1u << 7, IRQ_CAD_DETECTED = 1u << 8, IRQ_TIMEOUT = 1u << 9, diff --git a/test/test_c_abi.c b/test/test_c_abi.c index ddce2e5..7f5608e 100644 --- a/test/test_c_abi.c +++ b/test/test_c_abi.c @@ -213,6 +213,7 @@ int main(void) { vsx_set_channel_busy(NULL, 1); vsx_deliver_frame(NULL, frame, sizeof(frame)); vsx_deliver_frame_from(NULL, frame, sizeof(frame), 0x1424); + vsx_deliver_frame_failed(NULL, frame, sizeof(frame), 1); vsx_transmit_finished(NULL); vsx_set_fem_enabled(NULL, 1); vsx_set_last_signal(NULL, 0.0f, 0.0f); diff --git a/test/test_commands.cpp b/test/test_commands.cpp new file mode 100644 index 0000000..a48fb84 --- /dev/null +++ b/test/test_commands.cpp @@ -0,0 +1,289 @@ +/* The rest of the command set. + * + * Every case here was previously an opcode that fell through to "acknowledged + * and ignored": the firmware issued it, got a status byte, and carried on with + * the chip in a state it had not asked for. + * + * Several of these commands are recorded rather than acted on, and the cases + * say so. A test that a value can be read back is not a weaker test than one + * that it changed behaviour: the fault this model exists to catch is a node + * configured differently from how its operator believes, and a value nobody can + * read back is a value nobody can check. + */ +#include "harness.h" + +namespace { + +void tx(vsx_chip* c, const uint8_t* cmd, size_t n) { + vsx_spi_transaction(c, cmd, nullptr, n); +} + +vsx_state state_of(vsx_chip* c) { + vsx_state s; + vsx_get_state(c, &s); + return s; +} + +vsx_counters counters_of(vsx_chip* c) { + vsx_counters k; + vsx_get_counters(c, &k); + return k; +} + +} // namespace + +int main() { + std::printf("virtual-sx1262 commands"); + + /* ---------------------------------------------------------------- */ + CASE("GetStatus says what the chip is actually doing"); + /* It answered the constant 0x22 - standby - whatever the part was doing, so a + * firmware polling it to find out whether its receiver was still armed was + * told no by a chip that was receiving. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + + uint8_t in[3] = {0}; + const uint8_t get[] = {0xC0, 0x00, 0x00}; + vsx_spi_transaction(c, get, in, sizeof(get)); + check(((in[1] >> 4) & 0x7) == 0x5, "receiving reads as RX"); + + const uint8_t stby[] = {0x80, 0x00}; + tx(c, stby, sizeof(stby)); + vsx_spi_transaction(c, get, in, sizeof(get)); + check(((in[1] >> 4) & 0x7) == 0x2, "standby reads as STBY_RC"); + + const uint8_t txc[] = {0x83, 0x00, 0x00, 0x00}; + tx(c, txc, sizeof(txc)); + vsx_spi_transaction(c, get, in, sizeof(get)); + check(((in[1] >> 4) & 0x7) == 0x6, "transmitting reads as TX"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("SetPacketType is remembered, and GetPacketType answers it"); + /* GetPacketType returned a hardcoded LoRa, so a firmware that had switched + * the part to GFSK was told it had not. */ + { + vsx_chip* c = vsx_create(); + check(state_of(c).packet_type == 0x01, "LoRa to begin with"); + + const uint8_t gfsk[] = {0x8A, 0x00}; + tx(c, gfsk, sizeof(gfsk)); + uint8_t in[3] = {0}; + const uint8_t get[] = {0x11, 0x00, 0x00}; + vsx_spi_transaction(c, get, in, sizeof(get)); + check(in[2] == 0x00, "GFSK is answered as GFSK"); + check(state_of(c).packet_type == 0x00, "and reported to the host, which is"); + check(1, "how a host can refuse to believe a run that switched modem"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("the three SetPacketParams fields that were assumed now count"); + /* estAirtimeMs hardcoded an explicit header and CRC on. An implicit header is + * twenty symbols of payload the model charged for and the part did not. */ + { + vsx_chip* c = vsx_create(); + const uint8_t mod[] = {0x8B, 9, 0x04, 1, 0}; + tx(c, mod, sizeof(mod)); + + /* preamble 8, explicit header, 32 bytes, CRC on, IQ normal */ + const uint8_t explicit_crc[] = {0x8C, 0x00, 0x08, 0x00, 32, 0x01, 0x00}; + tx(c, explicit_crc, sizeof(explicit_crc)); + const uint32_t full = vsx_est_airtime_ms(c, 32); + check(state_of(c).header_implicit == 0 && state_of(c).crc_on == 1, + "explicit header and CRC on are read, not assumed"); + + const uint8_t implicit_nocrc[] = {0x8C, 0x00, 0x08, 0x01, 32, 0x00, 0x01}; + tx(c, implicit_nocrc, sizeof(implicit_nocrc)); + const uint32_t lean = vsx_est_airtime_ms(c, 32); + check(state_of(c).header_implicit == 1, "an implicit header is read"); + check(state_of(c).crc_on == 0, "so is CRC off"); + check(state_of(c).invert_iq == 1, "so is inverted IQ"); + check(lean < full, "and dropping the header and the CRC costs less airtime"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("StopTimerOnPreamble keeps a bounded receive from cutting off a frame"); + /* Without it a receiver on a deadline gives up mid packet. This is the whole + * reason the command exists. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + const uint8_t stop[] = {0x9F, 0x01}; + tx(c, stop, sizeof(stop)); + /* 6400 counts of 15.625 us is 100 ms. */ + const uint8_t rx[] = {0x82, 0x00, 0x19, 0x00}; + tx(c, rx, sizeof(rx)); + + vsx_set_channel_busy(c, 1); + for (uint64_t t = now + 1; t <= now + 200; ++t) { + vsx_tick(c, t); + } + check((irq_flags(c) & IRQ_TIMEOUT) == 0, "a preamble stopped the timer"); + check(mode_of(c) == 1, "and the receiver is still listening past the deadline"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("SetLoRaSymbNumTimeout is a deadline counted in symbols"); + { + vsx_chip* c = vsx_create(); + uint64_t now = 100; + const uint8_t mod[] = {0x8B, 12, 0x04, 4, 0}; /* 32.768 ms a symbol */ + tx(c, mod, sizeof(mod)); + const uint8_t dio[] = {0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0}; + tx(c, dio, sizeof(dio)); + const uint8_t symb[] = {0xA0, 4}; + tx(c, symb, sizeof(symb)); + vsx_tick(c, now); + const uint8_t rx[] = {0x82, 0x00, 0x00, 0x00}; /* no millisecond timeout */ + tx(c, rx, sizeof(rx)); + + check(state_of(c).symb_num_timeout == 4, "four symbols is what was asked for"); + vsx_tick(c, now + 130); + check((irq_flags(c) & IRQ_TIMEOUT) == 0, "a symbol short, still listening"); + vsx_tick(c, now + 132); + check((irq_flags(c) & IRQ_TIMEOUT) != 0, "four symbols on, Timeout"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("SetRxTxFallbackMode is recorded, and FS is not a mode this model has"); + /* PLACEHOLDER, and the test pins the placeholder rather than pretending: a + * firmware asking to fall back to FS gets standby, and what it asked for is + * readable so a host can tell that is what happened. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + const uint8_t fb[] = {0x93, 0x40}; /* FS */ + tx(c, fb, sizeof(fb)); + check(state_of(c).fallback_mode == 0x40, "the mode asked for is readable"); + + const uint8_t txc[] = {0x83, 0x00, 0x00, 0x00}; + tx(c, txc, sizeof(txc)); + vsx_transmit_finished(c); + check(mode_of(c) == 0, "and a finished transmission still lands in standby"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("SetPaConfig is recorded, because the board owns the other half"); + { + vsx_chip* c = vsx_create(); + const uint8_t pa[] = {0x95, 0x04, 0x07, 0x00, 0x01}; /* the +22 dBm row */ + tx(c, pa, sizeof(pa)); + const vsx_state s = state_of(c); + check(s.pa_duty_cycle == 0x04 && s.pa_hp_max == 0x07, "both halves readable"); + check(s.pa_device_sel == 0x00, "and which device it selected"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("the recorded-only commands are all readable"); + /* One case for the lot of them, because the assertion is the same: the + * firmware said something and the host can find out what. */ + { + vsx_chip* c = vsx_create(); + const uint8_t reg[] = {0x96, 0x01}; /* DC-DC */ + const uint8_t sw[] = {0x9D, 0x01}; /* DIO2 drives the RF switch */ + const uint8_t tcxo[] = {0x97, 0x02, 0x00, 0x00, 0x64}; + tx(c, reg, sizeof(reg)); + tx(c, sw, sizeof(sw)); + tx(c, tcxo, sizeof(tcxo)); + + const vsx_state s = state_of(c); + check(s.regulator_mode == 0x01, "the regulator mode"); + check(s.dio2_as_rf_switch == 1, "that the chip was told to drive the RF switch"); + check(s.dio3_as_tcxo == 1, "and that DIO3 feeds a TCXO"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("SetRxDutyCycle records its periods and leaves the receiver on"); + /* PLACEHOLDER. A duty-cycled receiver is deaf for a scheduled fraction of the + * time, which changes which frames arrive at all, and that is the engine's + * decision rather than the chip's. Optimistic, and the test says so. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + const uint8_t duty[] = {0x94, 0x00, 0x19, 0x00, 0x00, 0x32, 0x00}; + tx(c, duty, sizeof(duty)); + + const vsx_state s = state_of(c); + check(s.rx_duty_rx_period == 0x001900, "the listening period is readable"); + check(s.rx_duty_sleep_period == 0x003200, "so is the sleeping one"); + check(mode_of(c) == 1, "and the receiver stays on, which is the optimistic case"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("the test modes key the transmitter and say so"); + /* RECORDED: a bare carrier is a transmission with no frame behind it, and + * there is no way to hand that to the engine. A host modelling an interferer + * reads the flag and puts the carrier on the air itself. */ + { + vsx_chip* c = vsx_create(); + const uint8_t cw[] = {0xD1}; + tx(c, cw, sizeof(cw)); + check(state_of(c).tx_continuous_wave == 1, "a continuous wave is flagged"); + check(mode_of(c) == 2, "and the chip is transmitting"); + vsx_transmit_finished(c); + check(state_of(c).tx_continuous_wave == 0, "and stops when the carrier does"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("GetStats counts what arrived"); + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + const uint8_t frame[] = {1, 2, 3}; + vsx_deliver_frame(c, frame, sizeof(frame)); + vsx_tick(c, ++now); + + uint8_t in[9] = {0}; + const uint8_t get[] = {0x10, 0, 0, 0, 0, 0, 0, 0, 0}; + vsx_spi_transaction(c, get, in, sizeof(get)); + check(((in[2] << 8) | in[3]) == 1, "one packet received"); + check(counters_of(c).stat_rx_packets == 1, "and the host sees the same"); + + const uint8_t reset[] = {0x00, 0, 0, 0, 0, 0, 0}; + tx(c, reset, sizeof(reset)); + check(counters_of(c).stat_rx_packets == 0, "ResetStats clears it"); + vsx_destroy(c); + } + + /* ---------------------------------------------------------------- */ + CASE("a frame that failed its CRC can be delivered, once something says so"); + /* PLACEHOLDER INTERFACE. Nothing calls this: the chip cannot know a CRC + * failed, and no host is wired to tell it. The case exists so that the path + * is real rather than hypothetical when one is. */ + { + vsx_chip* c = vsx_create(); + uint64_t now = 0; + bring_up(c, &now); + const uint8_t frame[] = {0xDE, 0xAD}; + vsx_deliver_frame_failed(c, frame, sizeof(frame), 1); + + check((irq_flags(c) & IRQ_RX_DONE) != 0, "RxDone, because a frame did arrive"); + check((irq_flags(c) & IRQ_CRC_ERR) != 0, "and CrcErr beside it"); + check(counters_of(c).stat_crc_errors == 1, "GetStats counts it"); + + vsx_deliver_frame_failed(c, frame, sizeof(frame), 2); + check((irq_flags(c) & IRQ_HEADER_ERR) != 0, "a header error raises HeaderErr"); + check(counters_of(c).stat_header_errors == 1, "and is counted separately"); + vsx_destroy(c); + } + + std::printf("\n%s: %d failure(s)\n", g_failures ? "FAILED" : "PASSED", g_failures); + return g_failures ? 1 : 0; +}