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:
1 parent
9241ccc253
commit
9dd9881d52
27 files changed
+1153
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
#include "../../../variants/rak4631/variant.h"
|
||||
Reference in new issue
Block a user