diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 95301b1e..7b6af1e0 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -87,6 +87,7 @@ jobs: - name: Verify temporary-radio timing and exact reply delivery run: | + python3 -B test/test_carrier_wave.py python3 -B test/test_radio_profile_cli.py python3 -B test/test_radio_profile_scan.py python3 -B test/test_repeater_radio_timing_integration.py diff --git a/docs/carrier_wave.md b/docs/carrier_wave.md new file mode 100644 index 00000000..f4bd25a6 --- /dev/null +++ b/docs/carrier_wave.md @@ -0,0 +1,48 @@ +# Carrier-wave RF checks + +`cw` and `cw2` transmit a continuous, unmodulated carrier at the selected +profile's frequency and the configured TX power. They are available in the +shared ASCII CLI on SX1262-family and LR1110 builds, including repeaters, +Companions, room servers, and sensors that use that CLI. + +| Command | Action | +| --- | --- | +| `cw on` | Use the active primary frequency for 10 seconds. | +| `cw on 2.5` | Use the active primary frequency for 2.5 seconds. | +| `cw2 on 0.75` | Use the active second profile for 0.75 seconds. | +| `cw off` or `cw2 off` | Stop the carrier immediately. Either name stops it. | +| `get cw` or `get cw2` | Show which carrier is active and its remaining seconds. | + +`set cw ...` and `set cw2 ...` are also accepted. Durations range from +0.001 to 60 seconds, rounded to milliseconds. The deadline runs in the main +loop, so it is not a precision pulse generator or a hardware cutoff. + +`cw2` requires an active `radio2` or `tempradio2` profile in `rxtx` mode. +An `rx` or disabled second profile cannot transmit a carrier. `cw` uses +`tempradio` when that is the current primary profile. There is one physical +transmitter: a carrier uses one profile at a time and ignores the cross-TX +setting. Stop it before selecting the other carrier. Repeating `on` on the +same profile restarts the duration. + +Packet transmission, reception, profile scanning, CAD, and receive recovery +checks pause during the carrier. The firmware keeps the MCU awake to service +the deadline, controls the board's external amplifier, and resumes normal +reception and profile scanning afterward. It rejects entry during a packet +or a busy radio. Profile retuning waits until the carrier stops; replacement +or expiry of the active second profile stops its carrier. Nothing about CW +is saved across reboot. + +Use the local USB or BLE console for immediate replies and manual stopping. +An on-air CLI reply must wait until the carrier ends because LoRa packets +cannot transmit during the test. + +A second radio on the same frequency can detect the carrier using +instantaneous RSSI. Compare readings before, during, and after transmission; +CAD and packet counters are not carrier detectors. This confirms RF activity, +not calibrated output power, frequency accuracy, antenna match, or SWR. +Afterward, exchange ordinary LoRa packets to verify both RX and TX recovery. + +The reproducible bench witness is `tools/hil/cw_observer.cpp`, built with +`tools/hil/cw_observer.ini` on a separate RAK4631. It never transmits on its own. +See the [hardware validation](carrier_wave_validation.md) for measured results, +reproduction steps, and the tested preamble settings. diff --git a/docs/carrier_wave_validation.md b/docs/carrier_wave_validation.md new file mode 100644 index 00000000..4eb2b7be --- /dev/null +++ b/docs/carrier_wave_validation.md @@ -0,0 +1,87 @@ +# CW and CW2 validation + +Tested on the MercerWoodMesh lab Pi on September 14, 2026 (Pacific), using +base commit `9241ccc253ad2f8ac2c9a2fe756f59ba2f52505d` plus the CW changes. +Test images are marked `v1.17.1.6-cw-test-9241ccc2`; they are development images. + +A separate RAK4631 sampled instantaneous RSSI every approximately 10 ms. +The transmitting boards ran the Full V4 repeater and Full T1000-E Companion +builds. Both were configured for −9 dBm chip TX power, with profiles at +909.5 and 919.5 MHz, BW125, SF7, CR5, and 32-symbol preambles. + +| Check | V4 / SX1262 | T1000-E / LR1110 | +| --- | --- | --- | +| `cw on 0.25` measured duration | 0.25 s | 0.25 s | +| `cw on 1.25` measured duration | 1.25 s | 1.25 s | +| Manual stop approximately 1 s into a 5 s request | 1.01 s | 1.01 s | +| `cw on 1.25` after enabling RXPS | 1.26 s | 1.25 s | +| `cw2 on 1.25` on the second frequency | 1.25 s | 1.25 s | +| Primary-frequency RSSI change while CW2 transmits | 0 dB | 0 dB | +| Paired RX and TX recovery checks | 5/5 passed | 5/5 passed | + +The observer saw roughly −56 to −57 dBm during V4 carriers and −67 to +−71 dBm during T1000-E carriers, against a −112 to −113 dBm baseline. +Readings returned to baseline after timeout and manual stop. These are +received signal readings, **not calibrated transmitter output measurements**. + +RX recovery used CRC-valid LoRa test payloads and the DUT's receive counter. +TX recovery used each DUT's MeshCore advertisement, received with valid CRC +by the observer. Both DUTs finished in RX, with CW and radio2 disabled and +no outbound packet pending. The observer has no autonomous transmission. + +## Software checks + +- 1,436 native tests passed, including new checks that CW holds queued packets, + services its deadline, and prevents transmission in the same loop where a + received command starts CW. +- Compiled production-method tests cover fractional input, malformed durations, + bounds, timer rollover, busy/IRQ rejection, RXPS cleanup, board TX/RX callbacks, + profile selection and replacement, failed TX entry, stop retries, and failed + RX restart. An ESP32 sleep test covers a carrier with USB disconnected. +- Full V4 repeater, Full T1000-E Companion, and ordinary STM32 + `RAK_3x72_companion_radio_usb` builds passed. The STM32 build is a compilation + check, not an RF test of that board. +- An additional existing OTA sleep test still expects the old + `ota::ota_ctx().apply_pending` spelling. It fails against unchanged HEAD + `src/Mesh.cpp` and test-file contents; the actual implementation uses + `ota_context_if_active()`. This is separate from the CW changes. + +## Bench qualifications + +The first CW2 request arrived before radio2's existing delayed activation. +The harness now polls `get radio2.status` for the active mode before testing. + +An initial V4 dual-profile packet probe used a 120-symbol preamble against +32-symbol profiles. A comparison around another CW2 burst received **0/8 +before and 1/8 afterward** with that mismatched probe, versus **8/8 before +and 8/8 afterward** with a matching 32-symbol probe. The configured preamble +also sets the driver's preamble-detection hold timeout, so a much longer +incoming preamble can outlast that hold and be interrupted by scanning. +The final dual-profile recovery checks use matched preambles. This finding +does not establish general packet-loss performance for every scan combination. + +These checks establish RF activity, selected-frequency behavior, and recovery +on the tested boards. They do not measure spectral purity, frequency error, +SWR, calibrated power, or months of uptime. Carrier timeouts are serviced by +the main loop and depend on that loop remaining responsive. + +## Reproduction + +Append `tools/hil/cw_observer.ini` to `extra_configs` in `platformio.local.ini`, +preserving existing entries, and build `pio run -e cw_observer_rak4631`. +Flash that application onto a separate lab RAK4631. Do not run PlatformIO +concurrently with another build or upload in the checkout. + +Configure the lab DUT's primary profile as above, disable radio2, set TX power +to −9 dBm, and reboot to apply saved primary settings. Then run: + +```sh +python3 tools/hil/carrier_wave.py --dut /dev/serial/by-id/DUT \ + --observer /dev/serial/by-id/OBSERVER --role repeater --output cw-results.json +``` + +Use `--role companion` for the T1000-E. The harness changes lab radio2/RXPS +settings and emits short RF bursts and packet probes. It is for test boards. + +Compact evidence: [carrier_wave_results.json](../tools/hil/carrier_wave_results.json). +Command guide: [carrier-wave RF checks](carrier_wave.md). diff --git a/docs/cli_commands.md b/docs/cli_commands.md index dd48cce8..7dbc177f 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3,6 +3,9 @@ See [two LoRa profiles](radio_profiles.md) for `radio2`, `tempradio2`, scheduling, RX-only mode, cross-transmission policy, and optional radio preambles. +See [carrier-wave RF checks](carrier_wave.md) for `cw on [seconds]`, +`cw2 on [seconds]`, and stopping a carrier with `off`. + For copy/paste on/off recipes and the differences from Full Companion, see [feature switches by role](role_feature_switches.md). The [USB web console](https://flasher.meshcore.io/console) opens the default ASCII diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 9c6d3e25..081b66b4 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -2,6 +2,10 @@ Below are the commands you can enter into the Terminal Chat clients: +For a timed RF carrier test on SX1262 or LR1110, use `cw on 2.5` or +`cw2 on 2.5`, then `cw off` to stop early. See [carrier-wave RF checks](carrier_wave.md) +for profile selection, limits, and status commands. + ## Companion WiFi browser terminal On ESP32 WiFi Companions with WebConfig, open the node's LAN IP address and diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index e95f5428..36e63cc7 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -309,6 +309,9 @@ void Dispatcher::loop() { next_floor_calib_time = futureMillis(NOISE_FLOOR_CALIB_INTERVAL); } _radio->loop(); + // A maintenance carrier has no TxDone packet. Keep its deadline serviced, + // but leave queued packets and RX/AGC watchdogs alone until normal RX returns. + if (_radio->isCarrierWaveActive()) return; const unsigned long now = _ms->getMillis(); // check for radio 'stuck' in mode other than Rx @@ -590,6 +593,7 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) { } void Dispatcher::checkRecv() { + if (_radio->isCarrierWaveActive()) return; Packet* pkt; float score; float snr; @@ -697,6 +701,7 @@ void Dispatcher::processRecvPacket(Packet* pkt) { } void Dispatcher::checkSend() { + if (_radio->isCarrierWaveActive()) return; const uint32_t now = _ms->getMillis(); uint32_t next_outbound; if (_mgr->getNextOutboundTime(now, next_outbound)) { diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 30404e65..1c94310e 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -31,6 +31,16 @@ public: */ class Radio { public: + static constexpr uint32_t CarrierWaveDefaultMillis = 10000; + static constexpr uint32_t CarrierWaveMaxMillis = 60000; + virtual bool supportsCarrierWave() const { return false; } + // duration_ms == 0 stops either carrier; there is one physical transmitter. + virtual RadioParamApplyResult setCarrierWave(uint8_t profile, uint32_t duration_ms) { + return RadioParamApplyResult::FAILED; + } + virtual bool isCarrierWaveActive() const { return false; } + virtual uint8_t carrierWaveProfile() const { return 0; } + virtual uint32_t carrierWaveRemainingMillis() const { return 0; } virtual void begin() { } virtual RadioProfiles* profiles() { return nullptr; } virtual const RadioProfiles* profiles() const { return nullptr; } diff --git a/src/MeshCore.h b/src/MeshCore.h index 252a1cc4..4f73429e 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -128,7 +128,10 @@ namespace mesh { #define BD_STARTUP_RX_PACKET 1 class MainBoard { + bool _radio_test_active = false; public: + void setRadioTestActive(bool active) { _radio_test_active = active; } + bool isRadioTestActive() const { return _radio_test_active; } virtual uint16_t getBattMilliVolts() = 0; virtual float getMCUTemperature() { return NAN; } virtual bool setAdcMultiplier(float multiplier) { return false; }; @@ -152,6 +155,7 @@ public: virtual void onBootComplete() { /* no op */ } virtual uint32_t getIRQGpio() { return -1; } // not supported. Returns DIO1 (SX1262) and DIO0 (SX127x) virtual void sleep(uint32_t secs) { + if (isRadioTestActive()) return; (void)secs; #if defined(RP2040_PLATFORM) || defined(STM32_PLATFORM) // These platforms have no shared deep-sleep board implementation. WFI is diff --git a/src/helpers/CarrierWaveCLI.h b/src/helpers/CarrierWaveCLI.h new file mode 100644 index 00000000..ccca9bae --- /dev/null +++ b/src/helpers/CarrierWaveCLI.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +namespace mesh { + +// Volatile maintenance commands shared by every role using RadioProfileCLI. +inline bool handleCarrierWaveCommand(Radio* radio, const char* command, + char* reply, size_t capacity) { + const char* text = command; + bool query = false; + if (!strncmp(text, "get ", 4)) { text += 4; query = true; } + else if (!strncmp(text, "set ", 4)) text += 4; + const size_t length = strcspn(text, " \t"); + const bool secondary = length == 3 && !strncmp(text, "cw2", 3); + if (!secondary && !(length == 2 && !strncmp(text, "cw", 2))) return false; + const char* name = secondary ? "cw2" : "cw"; + const char* args = cli::skipRecentRepeaterSpaces(text + length); + if (!radio || !radio->supportsCarrierWave()) { + snprintf(reply, capacity, "Error: CW unsupported on this radio"); + return true; + } + if (!*args) { + if (radio->isCarrierWaveActive()) { + const uint32_t remaining = radio->carrierWaveRemainingMillis(); + snprintf(reply, capacity, "> %s on; %lu.%03lu seconds left", + radio->carrierWaveProfile() ? "cw2" : "cw", + (unsigned long)(remaining / 1000), (unsigned long)(remaining % 1000)); + } else snprintf(reply, capacity, "> off"); + return true; + } + uint32_t duration = 0; + const size_t action_length = strcspn(args, " \t"); + const char* seconds = cli::skipRecentRepeaterSpaces(args + action_length); + const bool on = action_length == 2 && !strncmp(args, "on", 2); + const bool off = action_length == 3 && !strncmp(args, "off", 3); + bool valid = !query && (on || (off && !*seconds)); + if (on) { + duration = Radio::CarrierWaveDefaultMillis; + if (*seconds) { + float value; + valid = valid && cli::parseDecimalStrict(seconds, value) + && value >= 0.001f && value <= Radio::CarrierWaveMaxMillis / 1000.0f; + if (valid) duration = static_cast(value * 1000.0f + 0.5f); + } + } + if (!valid) { + snprintf(reply, capacity, "Error: use %s on [seconds 0.001-60] | %s off", name, name); + return true; + } + if (on && secondary && (!radio->profiles() || !radio->profiles()->canTransmit(1))) { + snprintf(reply, capacity, "Error: cw2 requires active radio2 rxtx"); + return true; + } + const auto result = radio->setCarrierWave(secondary ? 1 : 0, duration); + if (result == RadioParamApplyResult::BUSY) { + snprintf(reply, capacity, "Error: radio busy; stop active CW or retry when idle"); + } else if (result != RadioParamApplyResult::APPLIED) { + snprintf(reply, capacity, "Error: CW transition failed; check radio recovery"); + } else if (on) { + snprintf(reply, capacity, "OK - %s on for %lu.%03lu seconds; RX paused", name, + (unsigned long)(duration / 1000), (unsigned long)(duration % 1000)); + } else snprintf(reply, capacity, "OK - carrier off%s", + radio->isInRecvMode() ? ", receive resumed" : ""); + return true; +} + +} // namespace mesh diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 0b65a9a8..1f10d6dc 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -103,7 +103,7 @@ public: // Native USB loses its connection in light sleep. An enumerated host // still needs USB serviced when no terminal asserts CDC DTR (for example, // after a Pi closes its serial port). Guard every caller here. - bool keep_awake = inhibit_sleep || isUsbHostConnected(); + bool keep_awake = inhibit_sleep || isUsbHostConnected() || isRadioTestActive(); #if MESH_USB_LOGGING_AVAILABLE // A live logging stream must also remain available before a host opens // it and across host disconnects. Compiled-out logging is not a blocker. diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index ed27f3b3..91f3d2c5 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -462,6 +462,7 @@ bool NRF52Board::isUsbHostConnected() { } void NRF52Board::sleep(uint32_t secs) { + if (isRadioTestActive()) { delay(1); return; } // Clear FPU interrupt flags to avoid insomnia // see errata 87 for details https://docs.nordicsemi.com/bundle/errata_nRF52840_Rev3/page/ERR/nRF52840/Rev3/latest/anomaly_840_87.html #if (__FPU_USED == 1) diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 7416ede6..084817b1 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -1,4 +1,5 @@ #include "RadioProfileCLI.h" +#include "CarrierWaveCLI.h" #include "RadioProfileCommandUtils.h" #include "radiolib/RXPowerSaving.h" #include @@ -274,6 +275,7 @@ void RadioProfileCLI::formatConfig(char* reply, size_t capacity, const RadioProf } bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity) { + if (handleCarrierWaveCommand(radio_, command, reply, capacity)) return true; const char* text = command; enum { Get, Set, Delete } verb = Get; if (!strncmp(text, "get ", 4)) text += 4; diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 78b892d0..45186b2b 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -15,10 +15,14 @@ class CustomLR1110Wrapper : public RadioLibWrapper { public: CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board), _deep_init(NULL) { } + bool supportsCarrierWave() const override { return true; } void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } - void powerOff() { _radio->standby(); _radio->sleep(); } + void powerOff() { + if (isCarrierWaveActive()) setCarrierWave(0, 0); + _radio->standby(); _radio->sleep(); + } protected: bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { @@ -37,6 +41,7 @@ protected: public: bool setCodingRate(uint8_t cr) override { + if (isCarrierWaveActive()) return false; return ((CustomLR1110 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index b3709429..fd87eee4 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -36,6 +36,7 @@ protected: public: CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + bool supportsCarrierWave() const override { return true; } protected: int16_t applyCachedTxPower(int8_t dbm) override { @@ -65,6 +66,7 @@ protected: public: bool setCodingRate(uint8_t cr) override { + if (isCarrierWaveActive()) return false; return ((CustomSX1262 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } @@ -84,6 +86,7 @@ public: } uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } void powerOff() { + if (isCarrierWaveActive()) setCarrierWave(0, 0); ((CustomSX1262 *)_radio)->standby(); ((CustomSX1262 *)_radio)->sleep(false); } @@ -100,6 +103,7 @@ public: } bool setRxPowerSavingRfRxDisabled(bool disabled) override { + if (isCarrierWaveActive()) return false; if (!supportsRxPowerSavingRfRxDisable()) return false; if (_rx_ps_armed) stopReceiveDutyCycle(); diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index baea3588..816f4602 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -105,6 +105,7 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) { } uint8_t RadioLibWrapper::beginReconfigure() { + if (_cw_active) return 2; const uint8_t base_state = state & ~STATE_INT_READY; // On SX126x/LR11xx duty-cycle RX, BUSY may remain asserted during the sleep // side of the cycle. Do not issue an IRQ/preamble query over SPI then; due @@ -228,6 +229,7 @@ bool RadioLibWrapper::validateProfile(const mesh::RadioProfileParams& p) const { } mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { + if (_cw_active) return mesh::RadioParamApplyResult::BUSY; if (profile > 1 || (profile == 1 && !_profiles.enabled())) return mesh::RadioParamApplyResult::FAILED; const auto& p = _profiles.params(profile); const uint16_t preamble = profilePreamble(profile); @@ -292,6 +294,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t prof } void RadioLibWrapper::serviceProfileScan() { + if (_cw_active) return; if (!_params_valid || (_profile_retry_at && (int32_t)(millis() - _profile_retry_at) < 0)) return; _profile_retry_at = 0; if (_profiles.enabled() && !_profile_rxps_suspended) { @@ -398,6 +401,7 @@ bool RadioLibWrapper::setRxBoostedGainMode(bool enabled) { } void RadioLibWrapper::idle() { + if (_cw_active) return; _radio->standby(); _rx_hold_continuous = false; state = STATE_IDLE; // need another startReceive() @@ -444,6 +448,7 @@ void RadioLibWrapper::doResetAGC() { } void RadioLibWrapper::resetAGC() { + if (_cw_active) return; // make sure we're not mid-receiving and mid-sending of packet! if (isPacketPendingOrReceiving() || (state == STATE_TX_WAIT)) return; @@ -460,6 +465,7 @@ void RadioLibWrapper::resetAGC() { } bool RadioLibWrapper::recoverRadio(bool hard) { + if (_cw_active) return false; const uint8_t base_state = state & ~STATE_INT_READY; if ((state & STATE_INT_READY) != 0 || base_state == STATE_TX_WAIT) return false; @@ -658,7 +664,98 @@ void RadioLibWrapper::checkReceiveMode(uint32_t now) { recoverRadio(_rx_mode_failures >= 3); } +uint32_t RadioLibWrapper::carrierWaveRemainingMillis() const { + if (!_cw_active || _cw_stopping) return 0; + const int32_t remaining = static_cast(_cw_until - millis()); + return remaining > 0 ? static_cast(remaining) : 0; +} + +mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uint32_t duration_ms) { + using Result = mesh::RadioParamApplyResult; + if (!supportsCarrierWave()) return Result::FAILED; + if (!duration_ms) return !_cw_active || stopCarrierWave() ? Result::APPLIED : Result::FAILED; + if (profile > 1 || duration_ms > CarrierWaveMaxMillis || !_params_valid + || !_profiles.canTransmit(profile)) return Result::FAILED; + if (_cw_active) { + if (_cw_stopping || profile != _cw_profile + || _cw_generation != _profiles.generation[profile]) return Result::BUSY; + _cw_until = static_cast(millis()) + duration_ms; + return Result::APPLIED; + } + + // Own an idle radio before changing profiles or keying the external FEM. + // This also performs the SX1262 duty-cycle RTC cleanup used by packet TX. + const uint8_t resume = beginReconfigure(); + if (resume > 1) return Result::BUSY; + _cw_restore_profile = _active_profile; + if (_radio->standby() != RADIOLIB_ERR_NONE + || _radio->clearIrqFlags(UINT32_MAX) != RADIOLIB_ERR_NONE + || tuneProfile(profile) != Result::APPLIED) { + startRecv(); + return Result::FAILED; + } + _radio->clearPacketReceivedAction(); + state = STATE_IDLE; + _rx_ps_armed = false; + _rx_hold_continuous = false; + _nf_calib_active = false; + _wd_observe_until = 0; + _cw_profile = profile; + _cw_generation = _profiles.generation[profile]; + _cw_active = true; + _cw_stopping = false; + _board->setRadioTestActive(true); // service the deadline even without USB + _board->onBeforeTransmit(); + _cw_board_tx = true; + if (_radio->transmitDirect() != RADIOLIB_ERR_NONE) { + stopCarrierWave(); // a failed SPI result does not prove TX stayed off + return Result::FAILED; + } + _cw_until = static_cast(millis()) + duration_ms; + return Result::APPLIED; +} + +bool RadioLibWrapper::stopCarrierWave() { + _cw_stopping = true; + // finishTransmit clears radio IRQs and enters standby on both supported + // families. Deassert external TX even if SPI fails, then try a radio reset. + const int16_t status = _radio->finishTransmit(); + if (_cw_board_tx) { _board->onAfterTransmit(); _cw_board_tx = false; } + if (status != RADIOLIB_ERR_NONE && !restoreAfterDeepInit()) { + _cw_retry_at = static_cast(millis()) + 100; + return false; // hold normal work until stopping the carrier is confirmed + } + _cw_active = false; + _cw_stopping = false; + _rx_ps_armed = false; + state = STATE_IDLE; + _radio->setPacketReceivedAction(setFlag); + _profile_refresh_required = true; + const uint8_t restore = _profiles.enabled() ? _cw_restore_profile : 0; + const bool tuned = tuneProfile(restore) == mesh::RadioParamApplyResult::APPLIED; + recalibrateNoiseFloor(); + startRecv(); + if (!isInRecvMode() && restoreAfterDeepInit()) startRecv(); + _board->setRadioTestActive(false); + return tuned && isInRecvMode(); +} + +bool RadioLibWrapper::serviceCarrierWave() { + if (_cw_active) { + const uint32_t now = millis(); + if ((_cw_stopping && static_cast(now - _cw_retry_at) >= 0) + || (!_cw_stopping && (static_cast(now - _cw_until) >= 0 + || !_profiles.canTransmit(_cw_profile) + || _cw_generation != _profiles.generation[_cw_profile]))) { + if (!stopCarrierWave()) MESH_DEBUG_PRINTLN("CW: stop/RX recovery failed"); + } + return true; + } + return false; +} + void RadioLibWrapper::loop() { + if (serviceCarrierWave()) return; serviceProfileScan(); // Calibration batches need one stable channel. Do not publish a noise floor // assembled from different frequencies, or let a batch pin the scan on one. @@ -744,6 +841,7 @@ void RadioLibWrapper::loop() { } void RadioLibWrapper::startRecv() { + if (_cw_active) return; int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { // A very short frame may complete while startReceiveMode() returns. @@ -807,6 +905,7 @@ bool RadioLibWrapper::isInRecvMode() const { // RX PowerSaving bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + if (_cw_active) return false; if (_profile_rxps_suspended) { _profile_saved_rxps = enabled; _rx_ps_rx_us = rx_us; @@ -830,6 +929,7 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl } int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { + if (_cw_active) return 0; int len = 0; if (state & STATE_INT_READY) { last_radio_interrupt_millis = millis(); // ISR fired -> radio hardware is alive @@ -921,6 +1021,7 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { } bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { + if (_cw_active) return false; _rx_hold_continuous = false; if (_rx_ps_armed) { // stop the duty-cycle sequencer before SetTx, otherwise its next RTC @@ -989,6 +1090,7 @@ int16_t RadioLibWrapper::performChannelScanWithTimeout(unsigned long timeout_ms) } bool RadioLibWrapper::isChannelActive() { + if (_cw_active) return true; if (isPacketPendingOrReceiving() || (state & ~STATE_INT_READY) == STATE_TX_WAIT) return true; // int.thresh: RSSI-based interference detection (relative to noise floor). // In RX duty-cycle mode only checked while the chip is in a listen window @@ -1020,6 +1122,7 @@ bool RadioLibWrapper::isChannelActive() { } bool RadioLibWrapper::isReceivingPassive(int interference_margin_db) { + if (_cw_active) return true; // RX duty-cycle radios hold BUSY high while asleep. Do not attempt an SPI // IRQ/RSSI read until the next listen window. Treat the unknown channel as // busy so the retry is deferred instead of transmitting blind; Dispatcher diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 7c8bbacb..5e705a46 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -83,6 +83,13 @@ protected: bool _profile_rxps_suspended = false; bool _profile_saved_rxps = false; bool _profile_refresh_required = false; + bool _cw_active = false; + bool _cw_stopping = false; + bool _cw_board_tx = false; + uint8_t _cw_profile = 0, _cw_restore_profile = 0; + uint32_t _cw_until = 0, _cw_retry_at = 0, _cw_generation = 0; + bool stopCarrierWave(); + bool serviceCarrierWave(); mesh::RadioParamApplyResult tuneProfile(uint8_t profile); void serviceProfileScan(); @@ -187,6 +194,10 @@ public: } void begin() override; + mesh::RadioParamApplyResult setCarrierWave(uint8_t profile, uint32_t duration_ms) override; + bool isCarrierWaveActive() const override { return _cw_active; } + uint8_t carrierWaveProfile() const override { return _cw_profile; } + uint32_t carrierWaveRemainingMillis() const override; mesh::RadioProfiles* profiles() override { return &_profiles; } const mesh::RadioProfiles* profiles() const override { return &_profiles; } bool validateProfile(const mesh::RadioProfileParams& params) const override; diff --git a/test/fixtures/radio_profiles/mesh_tests.h b/test/fixtures/radio_profiles/mesh_tests.h index 034888fb..ef7aa88e 100644 --- a/test/fixtures/radio_profiles/mesh_tests.h +++ b/test/fixtures/radio_profiles/mesh_tests.h @@ -5,6 +5,12 @@ class DualProfileTestRadio : public RetryCodingRateRadio { uint8_t selected = 0; int busy_profile = -1; bool fail_next_send = false; + bool carrier = false, carrier_on_receive = false; + unsigned carrier_services = 0, recoveries = 0; + bool isCarrierWaveActive() const override { return carrier; } + bool isInRecvMode() const override { return !carrier && !sending; } + void loop() override { ++carrier_services; } + bool recoverRadio(bool) override { ++recoveries; return true; } uint32_t estimated_airtime = 10; std::vector incoming; std::vector transmissions; @@ -34,6 +40,7 @@ class DualProfileTestRadio : public RetryCodingRateRadio { return RetryCodingRateRadio::startSendRaw(bytes, size); } int recvRaw(uint8_t* bytes, int size) override { + if (carrier_on_receive) { carrier = true; carrier_on_receive = false; return 0; } if (incoming.empty() || (int)incoming.size() > size) return 0; const int length = incoming.size(); memcpy(bytes, incoming.data(), length); incoming.clear(); @@ -299,3 +306,36 @@ TEST_F(DualProfileTest, ChangedPolicyRemovesCrossCopiesBeforeTransmission) { EXPECT_EQ((std::vector{0}), radio.transmissions); EXPECT_EQ(40, manager.getFreeCount()); } + +TEST_F(DualProfileTest, CarrierKeepsQueuesAndRecoveryPausedButServicesDeadline) { + node.flood_attempts = 0; + radio.config.cross = mesh::RadioCrossMode::Off; + ASSERT_NE(nullptr, queue()); + const int queued = manager.getOutboundTotal(); + radio.carrier = true; + tick(10000); tick(10000); + EXPECT_GE(radio.carrier_services, 2u); + EXPECT_EQ(0u, radio.recoveries); + EXPECT_EQ(queued, manager.getOutboundTotal()); + EXPECT_TRUE(radio.transmissions.empty()); + radio.carrier = false; + tick(); + EXPECT_EQ((std::vector{0}), radio.transmissions); + radio.complete = true; tick(); + EXPECT_EQ(40, manager.getFreeCount()); +} + +TEST_F(DualProfileTest, CarrierStartedInReceiveCallbackDoesNotDequeuePacket) { + node.flood_attempts = 0; + radio.config.cross = mesh::RadioCrossMode::Off; + ASSERT_NE(nullptr, queue()); + const int queued = manager.getOutboundTotal(); + radio.carrier_on_receive = true; + tick(); + EXPECT_TRUE(radio.carrier); + EXPECT_EQ(queued, manager.getOutboundTotal()); + EXPECT_TRUE(radio.transmissions.empty()); + radio.carrier = false; + tick(); + EXPECT_EQ((std::vector{0}), radio.transmissions); +} diff --git a/test/test_carrier_wave.py b/test/test_carrier_wave.py new file mode 100644 index 00000000..f7bf96d1 --- /dev/null +++ b/test/test_carrier_wave.py @@ -0,0 +1,228 @@ +"""Compile production CW transitions and command parser with fault-injected hardware.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = r''' +#include +#include +#include +#include +#include +#define STATE_IDLE 0 +#define STATE_RX 1 +#define STATE_TX_WAIT 3 +#define STATE_INT_READY 16 +#define RADIOLIB_ERR_NONE 0 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +static uint8_t state; +static void setFlag() { state |= STATE_INT_READY; } +struct Board { + bool awake=false; unsigned before=0, after=0; + void setRadioTestActive(bool value) { awake=value; } + void onBeforeTransmit() { ++before; } + void onAfterTransmit() { ++after; } +}; +struct Chip { + int start_result=0, stop_result=0; + unsigned starts=0, stops=0, standby_calls=0; + bool carrier=false; + void (*callback)()=setFlag; + int standby() { ++standby_calls; return 0; } + int clearIrqFlags(uint32_t) { return 0; } + void clearPacketReceivedAction() { callback=nullptr; } + void setPacketReceivedAction(void (*fn)()) { callback=fn; } + int transmitDirect() { carrier=true; ++starts; return start_result; } + int finishTransmit() { ++stops; if (!stop_result) carrier=false; return stop_result; } +}; +struct RadioLibWrapper : mesh::Radio { + Chip chip; Chip* _radio=&chip; + Board board; Board* _board=&board; + mesh::RadioProfiles _profiles; + bool _cw_active=false, _cw_stopping=false, _cw_board_tx=false; + uint8_t _cw_profile=0, _cw_restore_profile=0, _active_profile=0; + uint32_t _cw_until=0, _cw_retry_at=0, _cw_generation=0; + bool _params_valid=true, _rx_ps_armed=true, _rx_hold_continuous=false; + bool _nf_calib_active=true, _profile_refresh_required=false; + unsigned long _wd_observe_until=0; + bool packet=false, busy=false, reset_ok=false, supported=true; + unsigned rtc_stops=0, tunes=0, rx_starts=0, calibrations=0, resets=0; + unsigned fail_rx_starts=0; + bool fail_tune=false; + float frequency=909.5; + RadioLibWrapper() { + g_mock_millis=100; state=STATE_RX; + _profiles.primary.freq=909.5; + _profiles.secondary.params.freq=911.5; + _profiles.secondary.mode=mesh::RadioProfileMode::RxTx; + } + mesh::RadioProfiles* profiles() override { return &_profiles; } + bool supportsCarrierWave() const override { return supported; } + bool isCarrierWaveActive() const override { return _cw_active; } + uint8_t carrierWaveProfile() const override { return _cw_profile; } + uint32_t carrierWaveRemainingMillis() const override; + mesh::RadioParamApplyResult setCarrierWave(uint8_t,uint32_t) override; + bool stopCarrierWave(); + bool serviceCarrierWave(); + uint8_t beginReconfigure(); + bool isChipBusy() { return busy; } + bool isReceivingPacket() { return packet; } + bool isInRecvMode() const override { return (state & ~STATE_INT_READY)==STATE_RX; } + void stopReceiveDutyCycle() { ++rtc_stops; _rx_ps_armed=false; } + mesh::RadioParamApplyResult tuneProfile(uint8_t target) { + ++tunes; + if (fail_tune) return mesh::RadioParamApplyResult::FAILED; + _active_profile=target; frequency=_profiles.params(target).freq; + return mesh::RadioParamApplyResult::APPLIED; + } + void startRecv() override { + ++rx_starts; + if (fail_rx_starts) { --fail_rx_starts; return; } + state=STATE_RX; _rx_ps_armed=true; + } + bool restoreAfterDeepInit() { + ++resets; + if (reset_ok) { chip.carrier=false; chip.stop_result=0; } + return reset_ok; + } + void recalibrateNoiseFloor() override { ++calibrations; } + int recvRaw(uint8_t*,int) override { return 0; } + uint32_t getEstAirtimeFor(int) override { return 1; } + float packetScore(float,int) override { return 0; } + bool startSendRaw(const uint8_t*,int) override { return false; } + bool isSendComplete() override { return false; } + void onSendFinished() override {} +}; +@METHODS@ +static void advance(uint32_t ms) { g_mock_millis+=ms; } +int main() { + using Result=mesh::RadioParamApplyResult; + for (uint8_t profile : {0,1}) { + RadioLibWrapper w; + assert(w.setCarrierWave(profile,1250)==Result::APPLIED); + assert(w.frequency==(profile ? 911.5f : 909.5f)); + assert(w.chip.carrier && !w.chip.callback && w.board.before==1 && w.board.awake); + assert(w.rtc_stops==1 && !w._rx_ps_armed && !w._nf_calib_active); + assert(w.beginReconfigure()==2); + advance(1249); assert(w.serviceCarrierWave() && w.chip.carrier); + advance(1); assert(w.serviceCarrierWave() && !w.chip.carrier); + assert(!w._cw_active && w.isInRecvMode() && w._rx_ps_armed); + assert(w.frequency==909.5f && w.board.after==1 && !w.board.awake && w.chip.callback); + assert(!w.serviceCarrierWave()); + } + // No mutation of packets already transmitting, completed RX, busy or receiving hardware. + for (int reason : {0,1,2,3}) { + RadioLibWrapper w; + if(reason==0) state=STATE_TX_WAIT; + if(reason==1) state|=STATE_INT_READY; + if(reason==2) w.busy=true; + if(reason==3) w.packet=true; + assert(w.setCarrierWave(1,100)==Result::BUSY); + assert(!w.chip.starts && !w.chip.standby_calls && !w.board.before && !w.tunes); + } + { + RadioLibWrapper w; + g_mock_millis=0xfffffff0; + assert(w.setCarrierWave(0,25)==Result::APPLIED); + advance(20); assert(w.carrierWaveRemainingMillis()==5); + assert(w.setCarrierWave(1,50)==Result::BUSY); // never switch an active carrier + assert(w.setCarrierWave(0,50)==Result::APPLIED && w.chip.starts==1); + advance(49); w.serviceCarrierWave(); assert(w.chip.carrier); + advance(1); w.serviceCarrierWave(); assert(!w.chip.carrier); + } + { + RadioLibWrapper w; w.chip.start_result=-705; + assert(w.setCarrierWave(0,100)==Result::FAILED); + assert(!w.chip.carrier && w.board.before==1 && w.board.after==1 && w.isInRecvMode()); + } + { + RadioLibWrapper w; + assert(w.setCarrierWave(1,100)==Result::APPLIED); + w.chip.stop_result=-705; + assert(w.setCarrierWave(0,0)==Result::FAILED); + assert(w._cw_active && w._cw_stopping && w.board.awake && w.board.after==1); + const auto attempts=w.chip.stops; + advance(99); w.serviceCarrierWave(); assert(w.chip.stops==attempts); + w.reset_ok=true; + advance(1); w.serviceCarrierWave(); + assert(!w._cw_active && !w.chip.carrier && w.board.after==1 && w.isInRecvMode()); + } + { + RadioLibWrapper w; w.setCarrierWave(0,100); + w.fail_rx_starts=2; + assert(w.setCarrierWave(0,0)==Result::FAILED); // never claim RX resumed + assert(!w._cw_active && !w.isInRecvMode()); + } + { + RadioLibWrapper w; w.setCarrierWave(0,100); + w.fail_rx_starts=1; w.reset_ok=true; + assert(w.setCarrierWave(0,0)==Result::APPLIED && w.isInRecvMode()); + } + { + RadioLibWrapper w; w.setCarrierWave(1,1000); + w._profiles.setSecondary({},false); // expired temporary radio2 + w.serviceCarrierWave(); assert(!w._cw_active && w.frequency==909.5f); + } + { + RadioLibWrapper w; w.setCarrierWave(1,1000); + ++w._profiles.generation[1]; // scheduled profile replacement ends old carrier + w.serviceCarrierWave(); assert(!w._cw_active); + } + { + RadioLibWrapper w; char reply[160]; + const auto run=[&](const char* command) { + assert(mesh::handleCarrierWaveCommand(&w,command,reply,sizeof(reply))); + return reply; + }; + assert(strstr(run("cw on"),"10.000 seconds") && w.carrierWaveRemainingMillis()==10000); + assert(strstr(run("cw2 off"),"receive resumed")); // either name can stop the one transmitter + assert(strstr(run("set cw2 on 2.5"),"2.500 seconds") && w.frequency==911.5f); + assert(strstr(run("get cw2"),"cw2 on; 2.500 seconds left")); + run("cw off"); + for (const char* invalid : {"cw on 0", "cw on -1", "cw on nan", "cw on inf", + "cw on 60.1", "cw on 1e2", "cw on 2.5junk", "cw on 1 2", "cw off 2", + "get cw on", "cw yes", "cw on .0001"}) { + const unsigned starts=w.chip.starts; + assert(!strncmp(run(invalid),"Error:",6) && w.chip.starts==starts); + } + assert(strstr(run("cw on .001"),"0.001 seconds")); run("cw off"); + assert(strstr(run("cw on 60"),"60.000 seconds")); run("cw off"); + w._profiles.secondary.mode=mesh::RadioProfileMode::Rx; + assert(strstr(run("cw2 on 1"),"requires active radio2 rxtx")); + w.supported=false; assert(strstr(run("cw on"),"unsupported")); + assert(!mesh::handleCarrierWaveCommand(&w,"cw22 on",reply,sizeof(reply))); + char small[8]; mesh::handleCarrierWaveCommand(&w,"cw on",small,sizeof(small)); + assert(small[7]==0); + } + puts("CW: transitions, timer rollover, profiles, RF hooks, failure recovery and CLI passed"); +} +''' + + +class CarrierWaveTest(unittest.TestCase): + def test_production_transitions_and_commands(self): + source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text() + signatures = ('uint8_t RadioLibWrapper::beginReconfigure(', + 'mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(', + 'uint32_t RadioLibWrapper::carrierWaveRemainingMillis(', + 'bool RadioLibWrapper::stopCarrierWave(', + 'bool RadioLibWrapper::serviceCarrierWave(') + code = HARNESS.replace('@METHODS@', '\n'.join(method(source, s) for s in signatures)) + with tempfile.TemporaryDirectory() as folder: + cpp = Path(folder)/'cw.cpp'; exe = Path(folder)/'cw.exe' + cpp.write_text(code) + built = subprocess.run([os.environ.get('CXX','g++'), '-std=c++17', '-Wall', '-Wextra', + '-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'), str(cpp), '-o', str(exe)], + capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + print(result.stdout, end='') + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_esp32_usb_sleep.py b/test/test_esp32_usb_sleep.py index 58b9eca8..4f609b08 100644 --- a/test/test_esp32_usb_sleep.py +++ b/test/test_esp32_usb_sleep.py @@ -89,6 +89,8 @@ static void gpio_set_intr_type(gpio_num_t pin, int type) { #define portEXIT_CRITICAL(mux) (--critical_depth) struct MainBoard { + bool radio_test_active = false; + bool isRadioTestActive() const { return radio_test_active; } virtual void sleep(uint32_t) = 0; virtual bool isUsbDataConnected() = 0; virtual bool isUsbHostConnected() = 0; @@ -153,6 +155,10 @@ int main() { board.sleep(30); require(sleep_calls == 1, "OTA sleep inhibition regressed"); board.inhibit_sleep = false; + board.radio_test_active = true; + board.sleep(30); + require(sleep_calls == 1, "CW deadline would be missed while USB is disconnected"); + board.radio_test_active = false; radio_irq_high = true; board.sleep(30); require(sleep_calls == 1 && critical_depth == 0, diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index 543b7b96..a46e060a 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -26,6 +26,7 @@ uint32_t millis() { return (uint32_t)(elapsed_us / 1000); } struct Chip { bool standbyXOSC=false; int standby() { return 0; } }; using CustomSX1262 = Chip; struct RadioLibWrapper { + bool _cw_active=false; Chip chip; Chip* _radio = &chip; mesh::RadioProfiles _profiles; bool _params_valid=true, packet=false, busy=false, fail=false; diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index e6fa81b3..9b053053 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -58,6 +58,8 @@ struct Radio { } }; struct RadioLibWrapper { + bool _cw_active = false; + bool serviceCarrierWave() { return false; } // dedicated CW harness owns this path mesh::RadioProfiles _profiles; uint32_t _profile_visit_us = 0; void serviceProfileScan() {} // separate profile-scan harness exercises tuning diff --git a/tools/hil/carrier_wave.py b/tools/hil/carrier_wave.py new file mode 100644 index 00000000..504fea4b --- /dev/null +++ b/tools/hil/carrier_wave.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""CW/RX/TX lab check using a separate cw_observer_rak4631 over USB. + +Requires preconfigured lab DUT: primary 909.5 MHz / BW125 / SF7 / CR5, +preamble 32, TX -9 dBm. Saves radio2 off/919.5 MHz and toggles RXPS during +testing. Never run against a production node. No bootloader or flashing code. +""" +import argparse +import json +from pathlib import Path +import re +import statistics +import time +import serial + + +class Bench: + def __init__(self, dut, observer): + assert Path(dut).resolve() != Path(observer).resolve() + self.dut = serial.Serial(dut, 115200, timeout=.02) + self.observer = serial.Serial(observer, 115200, timeout=.02) + self.results = {'commands': [], 'measurements': [], 'packets': []} + time.sleep(.3) + self.dut.reset_input_buffer(); self.observer.reset_input_buffer() + + def command(self, text, observer=False, wait=.25, timeout=8): + port = self.observer if observer else self.dut + port.write((text+'\r\n').encode()); port.flush() + start = last = time.monotonic(); data = b'' + while time.monotonic()-start < timeout: + chunk = port.read(8192) + if chunk: data += chunk; last = time.monotonic() + complete = (b'\n' in data if observer else + b'\r\n> ' in data or + any(line.lstrip().startswith(b'-> ') for line in data.splitlines())) + if complete and time.monotonic()-start > wait and time.monotonic()-last > .12: break + result = data.decode(errors='replace') + self.results['commands'].append({'command': text, 'observer': observer, 'reply': result}) + assert result, f'No response: {text}' + assert not re.search(r'\b(?:Error|ERROR|error)[:\"]', result), (text,result) + return result + + def listen(self, khz): + # Match the DUT profile: its preamble-detect hold uses that setting. + # An unrelated longer probe can time out during dual-profile scanning. + reply = self.command(f'listen {khz} 125 7 5 32', observer=True) + assert '"listen_rc":0' in reply, reply + + def wait_profile_mode(self, mode): + # Flash writes and an earlier RXPS transition can delay the CLI reply. + # Poll the active profile, rather than count two seconds from USB send. + end = time.monotonic()+10 + while time.monotonic() {mode};' in self.command('get radio2.status'): return + time.sleep(.25) + raise AssertionError(f'radio2 did not become {mode}') + + def measure(self, name, seconds, khz, manual=None, signal=True): + self.listen(khz) + self.command('cw off') + self.observer.reset_input_buffer(); self.dut.reset_input_buffer() + actual = seconds if manual is None else manual + window = max(4.5, actual+2.5) + self.observer.write(f'sample {int(window*1000)}\n'.encode()); self.observer.flush() + start = time.monotonic(); sent = None; off = False; reply = b''; rows = [] + attempts = 0 + while time.monotonic()-start < window+.2: + elapsed = time.monotonic()-start + if sent is None and elapsed >= .75 and attempts < 6: + self.dut.write(f'{name} on {seconds}\r\n'.encode()); self.dut.flush() + sent = elapsed; attempts += 1 + if sent is not None and manual is not None and elapsed >= sent+manual and not off: + self.dut.write(b'cw off\r\n'); self.dut.flush(); off = True + line = self.observer.readline() + if line: + try: row = json.loads(line) + except (ValueError,UnicodeDecodeError): continue + if 'rssi_x10' in row: + row['host_s'] = time.monotonic()-start; rows.append(row) + if self.dut.in_waiting: + reply += self.dut.read(self.dut.in_waiting) + if b'Error: radio busy' in reply and b'OK - cw' not in reply: + reply = b''; sent = None # RXPS may be in its brief BUSY sleep window + text = reply.decode(errors='replace') + result = {'command': name, 'seconds': seconds, 'manual_off': manual, 'listen_khz': khz, + 'expect_signal': signal, 'reply': text, 'samples': rows, 'attempts': attempts} + self.results['measurements'].append(result) + assert sent is not None and f'OK - {name} on' in text, text + before = [r['rssi_x10']/10 for r in rows if r['host_s'] < sent-.1] + during = [r['rssi_x10']/10 for r in rows if sent+.06 < r['host_s'] < sent+actual-.03] + after = [r['rssi_x10']/10 for r in rows if r['host_s'] > sent+actual+.35] + assert len(before)>=20 and len(during)>=5 and len(after)>=20, 'Insufficient samples' + b,d,a = map(statistics.median,(before,during,after)) + result.update(before_dbm=b, during_dbm=d, after_dbm=a, rise_db=d-b) + high = [r for r in rows if r['rssi_x10']/10 > b+12] + if signal: + assert d-b >= 15, result | {'samples':'omitted'} + assert a-b <= 8, result | {'samples':'omitted'} + measured = high[-1]['ms']/1000-high[0]['ms']/1000+.01 + result['observed_seconds'] = round(measured,3) + assert abs(measured-actual) <= .18, result | {'samples':'omitted'} + else: + assert d-b < 10, result | {'samples':'omitted'} + assert '> off' in self.command('get cw') + result['passed'] = True + print(json.dumps({k:v for k,v in result.items() if k!='samples'}), flush=True) + + def packet_check(self, role, sequence): + self.listen(909500) + before = self.command('stats-radio-diag') + n0 = int(re.search(r'recv=(\d+)',before)[1]) + probe = self.command(f'packet {sequence}',observer=True) + assert '"tx_rc":0' in probe and '"rx_rc":0' in probe, probe + time.sleep(.25) + after = self.command('stats-radio-diag') + n1 = int(re.search(r'recv=(\d+)',after)[1]) + assert n1>n0, (before,after) + self.observer.reset_input_buffer() + advert = self.command('advert.zerohop' if role=='repeater' else 'advert') + end = time.monotonic()+5; received = [] + while time.monotonic() -9' in self.command('get tx') + self.command('set radio2 off',wait=2.5) + self.wait_profile_mode('off') + self.command('set radio.rxps off') + if not second_only: + for sequence,duration in enumerate((.25,1.25),1): + self.measure('cw',duration,909500) + self.packet_check(role,sequence) + self.measure('cw',5,909500,manual=1) + self.packet_check(role,3) + self.command('set radio.rxps on') + self.measure('cw',1.25,909500) + self.packet_check(role,4) + self.command('set radio.rxps off') + self.command('set radio2 919.5,125,7,5,rxtx,32',wait=2.5) + self.wait_profile_mode('rxtx') + self.measure('cw2',1.25,919500) + self.packet_check(role,5) + self.measure('cw2',.5,909500,signal=False) + self.command('set radio2 off',wait=2.5) + self.wait_profile_mode('off') + self.results['final'] = self.command('stats-radio-diag') + self.results['passed'] = True + + def close(self): + try: + self.dut.write(b'cw off\r\n'); self.dut.flush() + self.observer.write(b'stop\n'); self.observer.flush() + finally: + self.dut.close(); self.observer.close() + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--dut',required=True);parser.add_argument('--observer',required=True) + parser.add_argument('--role',choices=('repeater','companion'),required=True) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--second-only',action='store_true') + args=parser.parse_args(); bench=Bench(args.dut,args.observer) + try: bench.run(args.role,args.second_only) + finally: + bench.close() + args.output.write_text(json.dumps(bench.results,indent=2)+'\n') + + +if __name__=='__main__': main() diff --git a/tools/hil/carrier_wave_results.json b/tools/hil/carrier_wave_results.json new file mode 100644 index 00000000..26133500 --- /dev/null +++ b/tools/hil/carrier_wave_results.json @@ -0,0 +1,229 @@ +{ + "date_utc": "2026-09-15", + "base_commit": "9241ccc253ad2f8ac2c9a2fe756f59ba2f52505d", + "observer": "RAK4631 SX1262 running cw_observer_rak4631", + "observer_image_sha256": "621fa21a8180b23eed655da55441542105f19a151491a29f5857534330a0702f", + "profile_0": { + "frequency_mhz": 909.5, + "bandwidth_khz": 125, + "sf": 7, + "cr": 5, + "preamble": 32 + }, + "profile_1": { + "frequency_mhz": 919.5, + "bandwidth_khz": 125, + "sf": 7, + "cr": 5, + "preamble": 32 + }, + "configured_chip_tx_dbm": -9, + "native_tests_passed": 1436, + "boards": [ + { + "board": "Heltec V4", + "chip": "SX1262", + "role": "Full repeater", + "measurements": [ + { + "case": "primary_250ms", + "command": "cw", + "seconds": 0.25, + "manual_off": null, + "listen_khz": 909500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -112.0, + "during_dbm": -56.0, + "after_dbm": -113.0, + "rise_db": 56.0, + "observed_seconds": 0.25, + "passed": true + }, + { + "case": "primary_1250ms", + "command": "cw", + "seconds": 1.25, + "manual_off": null, + "listen_khz": 909500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -56.0, + "after_dbm": -113.0, + "rise_db": 57.0, + "observed_seconds": 1.25, + "passed": true + }, + { + "case": "manual_stop", + "command": "cw", + "seconds": 5, + "manual_off": 1, + "listen_khz": 909500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -56.0, + "after_dbm": -113.0, + "rise_db": 57.0, + "observed_seconds": 1.01, + "passed": true + }, + { + "case": "primary_after_rxps", + "command": "cw", + "seconds": 1.25, + "manual_off": null, + "listen_khz": 909500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -56.0, + "after_dbm": -113.0, + "rise_db": 57.0, + "observed_seconds": 1.26, + "passed": true + }, + { + "case": "secondary_1250ms", + "command": "cw2", + "seconds": 1.25, + "manual_off": null, + "listen_khz": 919500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -112.0, + "during_dbm": -57.0, + "after_dbm": -113.0, + "rise_db": 55.0, + "observed_seconds": 1.25, + "passed": true + }, + { + "case": "secondary_primary_frequency_control", + "command": "cw2", + "seconds": 0.5, + "manual_off": null, + "listen_khz": 909500, + "expect_signal": false, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -113.0, + "after_dbm": -113.0, + "rise_db": 0.0, + "passed": true + } + ], + "rx_tx_recovery_checks": 5, + "final_status": "stats-radio-diag\r\n -> state=1(RX), recv=32, sent=6, errors=0, err_flags=4, outbound=no, last_rx=15s ago", + "passed": true + }, + { + "board": "T1000-E", + "chip": "LR1110", + "role": "Full Companion", + "measurements": [ + { + "case": "primary_250ms", + "command": "cw", + "seconds": 0.25, + "manual_off": null, + "listen_khz": 909500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -68.0, + "after_dbm": -113.0, + "rise_db": 45.0, + "observed_seconds": 0.25, + "passed": true + }, + { + "case": "primary_1250ms", + "command": "cw", + "seconds": 1.25, + "manual_off": null, + "listen_khz": 909500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -68.0, + "after_dbm": -113.0, + "rise_db": 45.0, + "observed_seconds": 1.25, + "passed": true + }, + { + "case": "manual_stop", + "command": "cw", + "seconds": 5, + "manual_off": 1, + "listen_khz": 909500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -71.0, + "after_dbm": -113.0, + "rise_db": 42.0, + "observed_seconds": 1.01, + "passed": true + }, + { + "case": "primary_after_rxps", + "command": "cw", + "seconds": 1.25, + "manual_off": null, + "listen_khz": 909500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -68.0, + "after_dbm": -113.0, + "rise_db": 45.0, + "observed_seconds": 1.25, + "passed": true + }, + { + "case": "secondary_1250ms", + "command": "cw2", + "seconds": 1.25, + "manual_off": null, + "listen_khz": 919500, + "expect_signal": true, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -67.0, + "after_dbm": -113.0, + "rise_db": 46.0, + "observed_seconds": 1.25, + "passed": true + }, + { + "case": "secondary_primary_frequency_control", + "command": "cw2", + "seconds": 0.5, + "manual_off": null, + "listen_khz": 909500, + "expect_signal": false, + "attempts": 1, + "before_dbm": -113.0, + "during_dbm": -113.0, + "after_dbm": -113.0, + "rise_db": 0.0, + "passed": true + } + ], + "rx_tx_recovery_checks": 5, + "final_status": "stats-radio-diag\r\n state=1(RX), recv=5, sent=6, errors=0, err_flags=0, outbound=no, last_rx=15s ago\r\n>", + "passed": true + } + ], + "qualifications": [ + "Instantaneous RSSI confirms RF activity and frequency selection, not calibrated power, spectral purity, frequency error or SWR.", + "Initial radio2 activation was tested too soon. The final harness polls active radio2.status before starting CW2.", + "Initial V4 dual-profile recovery probe used 120 symbols against 32 configured. Separate comparison received 0/8 and 1/8 such probes before/after another CW2 burst, versus 8/8 and 8/8 with matched 32-symbol probes. Final dual-profile packet checks use matched preambles.", + "CW timeouts are main-loop serviced, not hardware timing guarantees or months-long uptime validation.", + "The existing test_deferred_ota_apply_prevents_event_only_sleep assertion fails on unchanged HEAD sources because it expects the old ota_ctx accessor. It is unrelated to CW; the native suite and focused CW/radio/sleep checks pass." + ] +} diff --git a/tools/hil/cw_observer.cpp b/tools/hil/cw_observer.cpp new file mode 100644 index 00000000..6dd45985 --- /dev/null +++ b/tools/hil/cw_observer.cpp @@ -0,0 +1,85 @@ +// Independent RF witness: instantaneous RSSI detects an unmodulated carrier; +// explicit packet probes verify LoRa recovery. Settings live only in RAM. +#include +#include +#include +#include + +SX1262 chip = new Module(42, 47, 38, 46); +volatile bool received = false; +bool ready = false; +uint32_t packets = 0, sampleUntil = 0, sampleAt = 0; +uint32_t frequency = 0; +void irq() { received = true; } + +void command(const char* line) { + unsigned khz, bw, sf, cr, preamble, duration, sequence; + char extra; + int rc = 0; + if (!strcmp(line, "info")) { + Serial.printf("{\"observer\":\"cw-v1\",\"ready\":%s,\"freq_khz\":%u,\"packets\":%u}\n", + ready ? "true" : "false", unsigned(frequency), unsigned(packets)); + } else if (sscanf(line, "listen %u %u %u %u %u %c", &khz, &bw, &sf, &cr, &preamble, &extra)==5 + && khz>=902000 && khz<=928000 && bw>=62 && bw<=500 && sf>=5 && sf<=12 + && cr>=5 && cr<=8 && preamble>=8 && preamble<=256) { + sampleUntil=0; + chip.standby(); + rc=chip.begin(khz/1000.0f, bw==62 ? 62.5f : float(bw), sf, cr, + RADIOLIB_SX126X_SYNC_WORD_PRIVATE, -9, preamble, 1.8); + if (!rc) rc=chip.setDio2AsRfSwitch(true); + if (!rc) rc=chip.setCRC(true); + if (!rc) rc=chip.explicitHeader(); + chip.setPacketReceivedAction(irq); received=false; + if (!rc) rc=chip.startReceive(); + ready=rc==0; frequency=khz; + Serial.printf("{\"listen_rc\":%d,\"freq_khz\":%u}\n",rc,khz); + } else if (ready && sscanf(line,"sample %u %c",&duration,&extra)==1 && duration && duration<=15000) { + sampleUntil=millis()+duration; sampleAt=millis(); + Serial.printf("{\"sampling_ms\":%u,\"at_ms\":%u}\n",duration,unsigned(millis())); + } else if (ready && sscanf(line,"packet %u %c",&sequence,&extra)==1) { + sampleUntil=0; + uint8_t bytes[16]={'M','C','W','1'}; + memcpy(bytes+4,&sequence,sizeof(sequence)); + chip.clearPacketReceivedAction(); received=false; + rc=chip.transmit(bytes,sizeof(bytes)); + chip.setPacketReceivedAction(irq); + const int rx=chip.startReceive(); + Serial.printf("{\"packet\":%u,\"tx_rc\":%d,\"rx_rc\":%d}\n",sequence,rc,rx); + } else if (!strcmp(line,"stop")) { + sampleUntil=0; Serial.println("{\"stopped\":true}"); + } else Serial.println("{\"error\":\"use info, listen kHz BW SF CR preamble, sample ms, packet sequence, stop\"}"); +} + +void setup() { + Serial.begin(115200); + SPI.setPins(45,43,44); SPI.begin(); +} + +void loop() { + if (NRF_WDT->RUNSTATUS) for(unsigned i=0;i<8;++i) NRF_WDT->RR[i]=0x6E524635; + static char line[96]; static unsigned used=0; static bool overflow=false; + while (Serial.available()) { + const char c=Serial.read(); + if (c=='\r' || c=='\n') { + if (overflow) { overflow=false;used=0;continue; } + if (used) { line[used]=0;used=0;command(line); } + } else if (used=0) { + sampleUntil=0; Serial.println("{\"samples_done\":true}"); + } + if (sampleUntil && int32_t(millis()-sampleAt)>=0) { + sampleAt=millis()+10; + Serial.printf("{\"ms\":%u,\"rssi_x10\":%d}\n",unsigned(millis()),int(chip.getRSSI(false)*10)); + } + delay(1); +} diff --git a/tools/hil/cw_observer.ini b/tools/hil/cw_observer.ini new file mode 100644 index 00000000..6108761d --- /dev/null +++ b/tools/hil/cw_observer.ini @@ -0,0 +1,20 @@ +; A separate, RAM-only RSSI/packet witness for CW validation. No autonomous TX. +[env:cw_observer_rak4631] +extends = arduino_base +platform = nordicnrf52 +platform_packages = ${nrf52_base.platform_packages} +board = rak4631 +board_build.variants_dir = tools/hil +board_build.variant = cw_observer_variant +board_build.ldscript = boards/nrf52840_s140_v6.ld +upload_protocol = nrfutil +extra_scripts = create-uf2.py + pre:scripts/nrf52_usb_power_fix.py +lib_deps = + SPI + https://github.com/jgromes/RadioLib.git#187ef24791c3d844939b2be13a68bd890bd04e4c +build_flags = ${arduino_base.build_flags} + -D NRF52_PLATFORM + -I lib/nrf52/s140_nrf52_6.1.1_API/include + -I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52 +build_src_filter = +<../tools/hil/cw_observer.cpp> diff --git a/tools/hil/cw_observer_variant/variant.cpp b/tools/hil/cw_observer_variant/variant.cpp new file mode 100644 index 00000000..3e1f0e5b --- /dev/null +++ b/tools/hil/cw_observer_variant/variant.cpp @@ -0,0 +1 @@ +#include "../../../variants/rak4631/variant.cpp" diff --git a/tools/hil/cw_observer_variant/variant.h b/tools/hil/cw_observer_variant/variant.h new file mode 100644 index 00000000..5336675f --- /dev/null +++ b/tools/hil/cw_observer_variant/variant.h @@ -0,0 +1 @@ +#include "../../../variants/rak4631/variant.h"