Add timed cw and cw2 commands for SX1262 and LR1110

Support fractional seconds, guard active packet work, keep the MCU awake, and restore reception after carrier tests. Include regression coverage and independent-radio RF validation for the V4 and T1000-E.
This commit is contained in:
mikecarper committed 2026-09-14 19:23:23 -07:00
1 parent 9241ccc253
commit 9dd9881d52
27 files changed
+1153 -2

No files matched your search

+1
View File
@@ -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
+48
View File
@@ -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.
+87
View File
@@ -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).
+3
View File
@@ -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
+4
View File
@@ -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
+5
View File
@@ -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)) {
+10
View File
@@ -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; }
+4
View File
@@ -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
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <Dispatcher.h>
#include <helpers/CLICommandUtils.h>
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<uint32_t>(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
+1 -1
View File
@@ -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.
+1
View File
@@ -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)
+2
View File
@@ -1,4 +1,5 @@
#include "RadioProfileCLI.h"
#include "CarrierWaveCLI.h"
#include "RadioProfileCommandUtils.h"
#include "radiolib/RXPowerSaving.h"
#include <Arduino.h>
@@ -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;
+6 -1
View File
@@ -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;
}
@@ -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();
+103
View File
@@ -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<int32_t>(_cw_until - millis());
return remaining > 0 ? static_cast<uint32_t>(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<uint32_t>(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<uint32_t>(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<uint32_t>(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<int32_t>(now - _cw_retry_at) >= 0)
|| (!_cw_stopping && (static_cast<int32_t>(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
+11
View File
@@ -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;
+40
View File
@@ -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<uint8_t> incoming;
std::vector<uint8_t> 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<uint8_t>{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<uint8_t>{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<uint8_t>{0}), radio.transmissions);
}
+228
View File
@@ -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 <Arduino.h>
#include <helpers/CarrierWaveCLI.h>
#include <cassert>
#include <cstdio>
#include <initializer_list>
#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()
+6
View File
@@ -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,
+1
View File
@@ -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;
+2
View File
@@ -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
+181
View File
@@ -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()<end:
if f'> {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()<end:
line = self.observer.readline()
if not line: continue
try: row=json.loads(line)
except (ValueError,UnicodeDecodeError): continue
if row.get('received') and row.get('rc')==0: received.append(row)
assert received, 'Observer did not receive DUT advert after CW'
result={'sequence':sequence,'dut_rx_before':n0,'dut_rx_after':n1,'observer_received':received,
'dut_reply':advert,'passed':True}
self.results['packets'].append(result)
print(json.dumps(result),flush=True)
def run(self,role,second_only=False):
self.results['version'] = self.command('ver')
assert '909.5,125,7,5' in self.command('get radio')
assert '> -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()
+229
View File
@@ -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."
]
}
+85
View File
@@ -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 <Arduino.h>
#include <Adafruit_TinyUSB.h>
#include <SPI.h>
#include <RadioLib.h>
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<sizeof(line)-1) line[used++]=c;
else overflow=true;
}
if (ready && received) {
received=false;
uint8_t bytes[255]; const size_t len=chip.getPacketLength();
const int rc=chip.readData(bytes, len<=sizeof(bytes) ? len : sizeof(bytes));
if (!rc) ++packets;
const int rx=chip.startReceive();
Serial.printf("{\"received\":%u,\"len\":%u,\"rc\":%d,\"rx_rc\":%d}\n",unsigned(packets),unsigned(len),rc,rx);
}
if (sampleUntil && int32_t(millis()-sampleUntil)>=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);
}
+20
View File
@@ -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>
@@ -0,0 +1 @@
#include "../../../variants/rak4631/variant.cpp"
+1
View File
@@ -0,0 +1 @@
#include "../../../variants/rak4631/variant.h"