Fix G3 gain transitions, slot commands and radio restore
This commit is contained in:
1 parent
015eff4c22
commit
22ab02caf7
11 files changed
+933
-21
No files matched your search
@@ -430,6 +430,7 @@ jobs:
|
||||
python3 -B test/test_companion_ota_config.py -v
|
||||
python3 -B test/test_ota_identity_policy.py -v
|
||||
python3 -B test/test_companion_radio_gain_restore.py -v
|
||||
python3 -B test/test_room_sensor_radio_restore.py -v
|
||||
python3 -B test/test_common_radio_persistence.py -v
|
||||
python3 -B test/test_common_cli_save_results.py -v
|
||||
python3 -B test/test_common_prefs_uncertain_reply.py -v
|
||||
@@ -789,6 +790,8 @@ jobs:
|
||||
- name: Verify Full logging compiler flags and packaged capabilities
|
||||
run: |
|
||||
python3 -B test/test_station_g3_radio_pins.py -v
|
||||
python3 -B test/test_station_g3_cli_dispatch.py -v
|
||||
python3 -B test/test_station_g3_fem_control.py -v
|
||||
python3 -B test/test_station_g3_slot_settings.py -v
|
||||
python3 -B test/test_station_g3_slot_detection.py -v
|
||||
python3 -B test/test_station_g3_boot_recovery.py -v
|
||||
|
||||
@@ -1812,6 +1812,9 @@ Station G2/G3 targets default to `off`.
|
||||
**Notes:**
|
||||
- This controls the external LoRa FEM receive-path LNA where the board supports it.
|
||||
- This is separate from `radio.rxgain`, which controls the radio chip receive gain mode.
|
||||
- On Station G3, transmit temporarily bypasses the LNA. A gain change made
|
||||
during transmission is applied when receive mode resumes; `get` reports the
|
||||
configured receive preference. Startup and terminal sleep keep the LNA off.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -99,6 +99,58 @@ three-attempt limit fail the delayed-startup regressions. These results prove
|
||||
the software tolerance gap; they do not prove it caused the user's hardware
|
||||
failure. No transmit or TCXO-setting override was introduced.
|
||||
|
||||
## Physical socket commands in the normal infrastructure CLI
|
||||
|
||||
The follow-up audit also found that Repeater, Room, and Sensor infrastructure
|
||||
commands consumed all `get` and `set` requests in the shared CLI. Their final
|
||||
board-command hook was reached only for other verbs. Consequently, the G3's
|
||||
normal `get radio.slot` and `set radio.slot ...` commands never reached the
|
||||
board implementation. The separate pre-radio USB recovery console and KISS
|
||||
binary commands called the board directly and were unaffected.
|
||||
|
||||
Unknown shared getters and setters now reach the board-command hook before
|
||||
returning an unknown-setting reply. Shared commands, including the guarded FEM
|
||||
setters, retain priority. Socket changes still save only the next-boot selection;
|
||||
they never switch the running radio or reboot behind the operator's back.
|
||||
|
||||
## Receive-gain changes during transmission
|
||||
|
||||
The follow-up comparison from `eb00b5370` found another G3-specific runtime
|
||||
defect. G2 firmware leaves the external amplifier controls alone. G3's
|
||||
`setLNAEnable()` reapplied receive mode unconditionally, so even repeating
|
||||
`set radio.fem.rxgain on` during an active transmission enabled the LNA before
|
||||
the transmit-completion hook. This was reachable through both the normal
|
||||
infrastructure CLI and Companion gain setters.
|
||||
|
||||
The G3 FEM now tracks startup/sleep, receive, and transmit mode. A gain setter
|
||||
updates the requested receive preference while preserving the transmit bypass;
|
||||
the requested state is applied when receive mode resumes. Startup and terminal
|
||||
sleep also keep the LNA disabled until an explicit receive transition. PA-level
|
||||
changes retain their existing policy of applying at the start of the next
|
||||
transmission. The inactive socket's LNA is left disabled.
|
||||
|
||||
The unconditional receive-mode call came from the runtime FEM preference
|
||||
implementation in
|
||||
[`7509e51e3d`](https://github.com/meshcore-dev/MeshCore/commit/7509e51e3d36010dccc81554a47df9b90b6915e4),
|
||||
Kevin Le, 2026-08-12. This finding concerns mode handling; the PA/LNA pin
|
||||
assignments and polarities match the manufacturer documentation.
|
||||
|
||||
## Complete saved-radio restoration in Room and Sensor roles
|
||||
|
||||
The same comparison found a shared role issue that can affect G3, G2, and other
|
||||
boards. Room and Sensor could clear their saved-radio retry flag after accepting
|
||||
the frequency/modulation tuple even when the subsequent saved TX-power command
|
||||
was rejected. Room also ignored rejection of its saved internal RX-gain command.
|
||||
An incoming packet or BUSY response during runtime restoration could therefore
|
||||
leave only part of the saved configuration applied.
|
||||
|
||||
Their saved-radio helper now reports success only after every applicable
|
||||
setting has been accepted. Rejection keeps the existing retry/backoff active,
|
||||
including restoration after a temporary radio setting expires. No preference
|
||||
layout, default setting, or Sensor RX-gain feature is added by this fix. This
|
||||
shared issue alone does not explain why G2 firmware works better on the user's
|
||||
G3.
|
||||
|
||||
## Saved FEM and socket settings
|
||||
|
||||
G3 firmware actively drives its LNA and PA-level GPIOs. G2 firmware does not,
|
||||
@@ -168,6 +220,13 @@ QIO override must fail that check.
|
||||
| Combined socket detection/settings, boot recovery, radio pins, KISS slot and GPIO-hold suites | PASS, 36 test methods, including the 6 detection methods above |
|
||||
| Ordinary Repeater, legacy `_r2` Repeater and KISS firmware builds | PASS; all bootloader and application image headers verified as DIO |
|
||||
| Canonical Full Companion package | PASS; 24 linked capability checks, internal RAM and application-slot checks passed; bootloader/application and merged-image headers verified as DIO |
|
||||
| Follow-up G3 gain-mode tests through production CLI/Companion setters and GPIO helpers | PASS, 2 test methods; both sockets, failed saves and old-setter negative control |
|
||||
| Follow-up Room/Sensor saved-radio startup and runtime restoration | PASS, 4 test methods; actual wrapper rejection, retry/backoff and temporary-radio expiry paths |
|
||||
| Follow-up normal infrastructure socket-command routing | PASS, 7 test methods; real board/NVS dispatch, shared guards and old-dispatch negative controls |
|
||||
| Combined follow-up native regression batch | PASS, 30 test methods with sanitizer coverage |
|
||||
| Follow-up Repeater, Room, Sensor and ESP32-C6 Room firmware builds | PASS, all 4 environments |
|
||||
| Follow-up canonical Full Companion package | PASS; 24 linked capability checks, internal RAM and application-slot checks passed |
|
||||
| Follow-up hosted documentation link checks | PASS, 7 test methods |
|
||||
| Physical G3 boot and RF receive/transmit | Not performed for this change |
|
||||
|
||||
No successful physical G3 boot, RF receive/transmit trial, or reproduction of
|
||||
|
||||
@@ -2668,10 +2668,6 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
}
|
||||
|
||||
saved_radio_apply_pending = !applySavedRadioParams();
|
||||
if (!saved_radio_apply_pending) {
|
||||
radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
|
||||
}
|
||||
const bool fem_gain_changed = board.canControlLoRaFemLna()
|
||||
&& board.isLoRaFemLnaEnabled() != (_prefs.radio_fem_rxgain != 0);
|
||||
if (board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain) && fem_gain_changed) {
|
||||
@@ -2787,6 +2783,10 @@ void MyMesh::begin(FILESYSTEM *fs) {
|
||||
}
|
||||
|
||||
bool MyMesh::applySavedRadioParams() {
|
||||
// Each setter can defer while the radio is busy. Keep recovery pending
|
||||
// until the complete saved configuration has been accepted.
|
||||
if (radio_driver.supportsRxBoostedGainMode()
|
||||
&& !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false;
|
||||
uint32_t timings[2] = {_prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us};
|
||||
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
|
||||
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
|
||||
@@ -2796,7 +2796,7 @@ bool MyMesh::applySavedRadioParams() {
|
||||
return false;
|
||||
}
|
||||
active_cr = _prefs.cr;
|
||||
return true;
|
||||
return radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||
}
|
||||
|
||||
bool MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt,
|
||||
@@ -4025,10 +4025,6 @@ void MyMesh::loop() {
|
||||
MESH_DEBUG_PRINTLN("Temp radio params expired before apply");
|
||||
} else if (revert_radio_due && !hasOutbound() && radio_apply_ready) {
|
||||
if (applySavedRadioParams()) {
|
||||
if (saved_radio_apply_pending) {
|
||||
radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
|
||||
}
|
||||
set_radio_at = revert_radio_at = 0;
|
||||
temp_radio_applied = false;
|
||||
saved_radio_apply_pending = false;
|
||||
@@ -4071,8 +4067,6 @@ void MyMesh::loop() {
|
||||
if (saved_radio_apply_pending && !temp_radio_applied && !hasOutbound()
|
||||
&& radio_apply_ready && !radio_apply_failed) {
|
||||
if (applySavedRadioParams()) {
|
||||
radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
|
||||
saved_radio_apply_pending = false;
|
||||
radio_apply_retry_at = 0;
|
||||
radio_apply_failures = 0;
|
||||
|
||||
@@ -1294,9 +1294,6 @@ void SensorMesh::begin(FILESYSTEM* fs) {
|
||||
}
|
||||
|
||||
saved_radio_apply_pending = !applySavedRadioParams();
|
||||
if (!saved_radio_apply_pending) {
|
||||
radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||
}
|
||||
const bool fem_gain_changed = board.canControlLoRaFemLna()
|
||||
&& board.isLoRaFemLnaEnabled() != (_prefs.radio_fem_rxgain != 0);
|
||||
if (board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain) && fem_gain_changed) {
|
||||
@@ -1328,6 +1325,8 @@ void SensorMesh::begin(FILESYSTEM* fs) {
|
||||
}
|
||||
|
||||
bool SensorMesh::applySavedRadioParams() {
|
||||
// A busy radio can accept the tuple before deferring TX power. Recovery
|
||||
// is complete only after both saved settings have been accepted.
|
||||
uint32_t timings[2] = {_prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us};
|
||||
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
|
||||
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
|
||||
@@ -1337,7 +1336,7 @@ bool SensorMesh::applySavedRadioParams() {
|
||||
return false;
|
||||
}
|
||||
active_cr = _prefs.cr;
|
||||
return true;
|
||||
return radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||
}
|
||||
|
||||
bool SensorMesh::formatFileSystem() {
|
||||
@@ -1534,9 +1533,6 @@ void SensorMesh::loop() {
|
||||
MESH_DEBUG_PRINTLN("Temp radio params expired before apply");
|
||||
} else if (revert_radio_due && !hasOutbound() && radio_apply_ready) {
|
||||
if (applySavedRadioParams()) {
|
||||
if (saved_radio_apply_pending) {
|
||||
radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||
}
|
||||
set_radio_at = revert_radio_at = 0;
|
||||
temp_radio_applied = false;
|
||||
saved_radio_apply_pending = false;
|
||||
@@ -1579,7 +1575,6 @@ void SensorMesh::loop() {
|
||||
if (saved_radio_apply_pending && !temp_radio_applied && !hasOutbound()
|
||||
&& radio_apply_ready && !radio_apply_failed) {
|
||||
if (applySavedRadioParams()) {
|
||||
radio_driver.setTxPower(_prefs.tx_power_dbm);
|
||||
saved_radio_apply_pending = false;
|
||||
radio_apply_retry_at = 0;
|
||||
radio_apply_failures = 0;
|
||||
|
||||
@@ -5411,6 +5411,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
}
|
||||
#else
|
||||
strcpy(reply, "Error: extra.sf requires an LR2021 radio");
|
||||
#endif
|
||||
#if defined(STATION_G3_ESP32)
|
||||
} else if (_board->handleCommand(command, sender_timestamp, reply)) {
|
||||
// Board-specific settings come after the shared validation/ownership gates.
|
||||
if (_prefs->isDirty()) savePrefs();
|
||||
#endif
|
||||
} else {
|
||||
strcpy(reply, "unknown config");
|
||||
@@ -5941,6 +5946,10 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
}
|
||||
#else
|
||||
strcpy(reply, "Error: extra.sf requires an LR2021 radio");
|
||||
#endif
|
||||
#if defined(STATION_G3_ESP32)
|
||||
} else if (_board->handleCommand(command, sender_timestamp, reply)) {
|
||||
// A board getter must not persist unrelated pending preferences.
|
||||
#endif
|
||||
} else {
|
||||
mesh::cli::formatUnknownSetting(reply, 160, config);
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Fault-inject actual Room/Sensor saved-radio startup, retry and TempRadio paths."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cpp_source import body
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ROLES = {
|
||||
"room": ("examples/simple_room_server/MyMesh.cpp", "MyMesh"),
|
||||
"sensor": ("examples/simple_sensor/SensorMesh.cpp", "SensorMesh"),
|
||||
}
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <deque>
|
||||
// Negative controls must fail without opening a Windows CRT abort dialog.
|
||||
#undef assert
|
||||
#define assert(condition) do { if (!(condition)) { \
|
||||
std::fprintf(stderr, "fixture assertion line %u: %s\n", unsigned(__LINE__), #condition); \
|
||||
std::exit(91); } } while (false)
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
#define MESH_ENABLE_TX_BEACONS 0
|
||||
#define MESH_ENABLE_CARRIER_TOOLS 0
|
||||
#define MESH_ENABLE_MORSE_RX 0
|
||||
#define RADIOLIB_ERR_NONE 0
|
||||
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
|
||||
using Apply = mesh::RadioParamApplyResult;
|
||||
static uint32_t now_ms = 100;
|
||||
template<typename T> T next(std::deque<T>& values, T fallback) {
|
||||
if (values.empty()) return fallback;
|
||||
T value = values.front(); values.pop_front(); return value;
|
||||
}
|
||||
struct Chip { int standby() { return 0; } } chip;
|
||||
struct Driver {
|
||||
enum { STATE_IDLE, STATE_RX, STATE_TX_WAIT };
|
||||
uint8_t state = STATE_IDLE;
|
||||
bool receiving = false, pending_irq = false, busy = false, supported = true;
|
||||
bool _rx_ps_armed = false, _cw_active = false;
|
||||
bool _dbm_valid = true, _rx_boosted_gain_valid = false;
|
||||
int8_t _cur_dbm = 7;
|
||||
bool _cur_rx_boosted_gain = false;
|
||||
unsigned power_commands = 0, gain_commands = 0;
|
||||
std::deque<int16_t> power_results;
|
||||
std::deque<bool> gain_results;
|
||||
Chip* _radio = &chip;
|
||||
bool hasPendingRadioInterrupt() { return pending_irq; }
|
||||
bool isChipBusy() { return busy; }
|
||||
bool isReceivingPacket() { return receiving; }
|
||||
void stopReceiveDutyCycle() { _rx_ps_armed = false; }
|
||||
void startRecv() { state = STATE_RX; }
|
||||
void recalibrateNoiseFloor() {}
|
||||
int16_t applyCachedTxPower(int8_t) {
|
||||
++power_commands; return next(power_results, int16_t(0));
|
||||
}
|
||||
#if ROOM_ROLE
|
||||
bool supportsRxBoostedGainMode() { return supported; }
|
||||
bool applyRxBoostedGainMode(bool) {
|
||||
assert(supported); ++gain_commands; return next(gain_results, true);
|
||||
}
|
||||
bool setRxBoostedGainMode(bool);
|
||||
#endif
|
||||
bool supportsRxPowerSaving() { return true; }
|
||||
uint8_t beginReconfigure();
|
||||
void endReconfigure(bool);
|
||||
bool setTxPower(int8_t);
|
||||
} radio_driver;
|
||||
@WRAPPER_METHODS@
|
||||
struct Prefs {
|
||||
float freq = 915.125f, bw = 250;
|
||||
uint8_t sf = 9, cr = 6;
|
||||
int8_t tx_power_dbm = 17;
|
||||
bool rx_boosted_gain = true, rx_powersaving_enabled = false;
|
||||
uint8_t rx_ps_level = 0, rx_ps_preamble = 32;
|
||||
uint32_t rx_ps_rx_us = 1234, rx_ps_sleep_us = 5678;
|
||||
};
|
||||
struct Profiles {
|
||||
float live_freq = 906.5f, live_bw = 125;
|
||||
uint8_t live_sf = 7, live_cr = 5;
|
||||
unsigned calls = 0;
|
||||
bool receive_after_tuple = false, had_timings = false;
|
||||
uint32_t rx_us = 0, sleep_us = 0;
|
||||
std::deque<Apply> results;
|
||||
uint16_t primaryPreamble() { return 32; }
|
||||
Apply applyPrimary(float freq, float bw, uint8_t sf, uint8_t cr,
|
||||
bool, uint16_t, const uint32_t* timings) {
|
||||
++calls;
|
||||
const Apply result = next(results, Apply::APPLIED);
|
||||
if (result != Apply::APPLIED) return result;
|
||||
const uint8_t resume = radio_driver.beginReconfigure();
|
||||
if (resume > 1) return Apply::BUSY;
|
||||
live_freq = freq; live_bw = bw; live_sf = sf; live_cr = cr;
|
||||
had_timings = timings != nullptr;
|
||||
if (timings) { rx_us = timings[0]; sleep_us = timings[1]; }
|
||||
radio_driver.endReconfigure(resume);
|
||||
// A packet can start after tuple application resumes an existing receiver.
|
||||
if (receive_after_tuple && radio_driver.state == Driver::STATE_RX)
|
||||
radio_driver.receiving = true;
|
||||
return Apply::APPLIED;
|
||||
}
|
||||
};
|
||||
struct CommonCLI {
|
||||
Profiles profiles;
|
||||
Profiles& radioProfiles() { return profiles; }
|
||||
static bool calculateRxPowerSavingLevel(uint8_t, uint8_t, float,
|
||||
uint32_t, uint32_t* rx, uint32_t* sleep) { *rx = 2222; *sleep = 3333; return true; }
|
||||
};
|
||||
@RETRY_DELAY@
|
||||
struct @CLASS@ {
|
||||
Prefs _prefs;
|
||||
CommonCLI _cli;
|
||||
uint8_t active_cr = 5;
|
||||
bool saved_radio_apply_pending = false, temp_radio_applied = false, outbound = false;
|
||||
uint32_t set_radio_at = 0, revert_radio_at = 0, radio_apply_retry_at = 0;
|
||||
uint8_t radio_apply_failures = 0;
|
||||
float pending_freq = 916.5f, pending_bw = 125;
|
||||
uint8_t pending_sf = 8, pending_cr = 7, pending_preamble = 16;
|
||||
@CLASS@() { now_ms = 100; radio_driver = Driver(); }
|
||||
bool hasOutbound() { return outbound; }
|
||||
bool millisHasNowPassed(uint32_t at) { return int32_t(now_ms - at) > 0; }
|
||||
uint32_t futureMillis(uint32_t delay) { return now_ms + delay; }
|
||||
uint32_t rxPowerSavingPreambleForParams(uint8_t, float) { return 32; }
|
||||
bool applySavedRadioParams();
|
||||
void startup() { @STARTUP@ }
|
||||
void service() { @SERVICE@ }
|
||||
};
|
||||
@APPLY@
|
||||
using Node = @CLASS@;
|
||||
static void restored(const Node& node) {
|
||||
assert(node._cli.profiles.live_freq == node._prefs.freq);
|
||||
assert(node._cli.profiles.live_bw == node._prefs.bw);
|
||||
assert(node._cli.profiles.live_sf == node._prefs.sf);
|
||||
assert(node.active_cr == node._prefs.cr);
|
||||
assert(radio_driver._cur_dbm == node._prefs.tx_power_dbm);
|
||||
#if ROOM_ROLE
|
||||
if (radio_driver.supported)
|
||||
assert(radio_driver._cur_rx_boosted_gain == node._prefs.rx_boosted_gain);
|
||||
#endif
|
||||
}
|
||||
static unsigned calls(const Node& node) {
|
||||
return node._cli.profiles.calls + radio_driver.power_commands + radio_driver.gain_commands;
|
||||
}
|
||||
static void startupAndRetry() {
|
||||
for (unsigned fault = 0; fault < 3; ++fault) {
|
||||
#if !ROOM_ROLE
|
||||
if (fault == 0) continue; // Sensor has no RX-gain feature.
|
||||
#endif
|
||||
Node node;
|
||||
node._prefs.rx_powersaving_enabled = true;
|
||||
#if ROOM_ROLE
|
||||
if (fault == 0) radio_driver.gain_results = {false, false, true};
|
||||
#endif
|
||||
if (fault == 1) node._cli.profiles.results = {Apply::BUSY, Apply::FAILED, Apply::APPLIED};
|
||||
if (fault == 2) radio_driver.power_results = {-1, -1, 0};
|
||||
node.startup();
|
||||
assert(node.saved_radio_apply_pending && "startup must retain incomplete saved radio work");
|
||||
node.service();
|
||||
assert(node.saved_radio_apply_pending && node.radio_apply_failures == 1);
|
||||
assert(node.radio_apply_retry_at > now_ms);
|
||||
const unsigned before = calls(node);
|
||||
node.service();
|
||||
assert(calls(node) == before); // No retry before the existing backoff.
|
||||
now_ms = node.radio_apply_retry_at + 1;
|
||||
node.outbound = true; node.service();
|
||||
assert(calls(node) == before && node.saved_radio_apply_pending);
|
||||
node.outbound = false; node.service();
|
||||
assert(!node.saved_radio_apply_pending && !node.radio_apply_retry_at && !node.radio_apply_failures);
|
||||
restored(node);
|
||||
assert(node._cli.profiles.had_timings);
|
||||
assert(node._cli.profiles.rx_us == 1234 && node._cli.profiles.sleep_us == 5678);
|
||||
}
|
||||
{
|
||||
Node node; node.startup(); restored(node);
|
||||
assert(!node.saved_radio_apply_pending && radio_driver.power_commands == 1);
|
||||
#if ROOM_ROLE
|
||||
assert(radio_driver.gain_commands == 1); // No unchecked caller duplicates.
|
||||
#endif
|
||||
}
|
||||
#if ROOM_ROLE
|
||||
for (bool desired : {false, true}) {
|
||||
Node node; node._prefs.rx_boosted_gain = desired;
|
||||
radio_driver._cur_rx_boosted_gain = !desired;
|
||||
node.startup(); restored(node);
|
||||
assert(!node.saved_radio_apply_pending);
|
||||
}
|
||||
{
|
||||
Node node; radio_driver.supported = false; radio_driver.gain_results = {false};
|
||||
node.startup(); restored(node);
|
||||
assert(!node.saved_radio_apply_pending && !radio_driver.gain_commands);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
static void receiveBetweenTupleAndPower() {
|
||||
Node node;
|
||||
radio_driver.state = Driver::STATE_RX;
|
||||
node.saved_radio_apply_pending = true;
|
||||
node._cli.profiles.receive_after_tuple = true;
|
||||
node.service();
|
||||
assert(node.saved_radio_apply_pending && "RX after tuple must defer saved TX power restoration");
|
||||
assert(radio_driver.receiving && radio_driver._cur_dbm == 7 && !radio_driver.power_commands);
|
||||
assert(node.radio_apply_failures == 1 && node.radio_apply_retry_at > now_ms);
|
||||
const unsigned before = calls(node);
|
||||
node.service(); assert(calls(node) == before);
|
||||
radio_driver.receiving = false; node._cli.profiles.receive_after_tuple = false;
|
||||
now_ms = node.radio_apply_retry_at + 1; node.service();
|
||||
assert(!node.saved_radio_apply_pending && !node.radio_apply_failures);
|
||||
restored(node);
|
||||
}
|
||||
static void tempRadioRestoration() {
|
||||
for (bool pending : {false, true}) for (unsigned fault = 0; fault < 3; ++fault) {
|
||||
#if !ROOM_ROLE
|
||||
if (fault == 0) continue;
|
||||
#endif
|
||||
Node node; node.saved_radio_apply_pending = pending;
|
||||
node.temp_radio_applied = true; node.revert_radio_at = now_ms - 1;
|
||||
#if ROOM_ROLE
|
||||
if (fault == 0) radio_driver.gain_results = {false, true};
|
||||
#endif
|
||||
if (fault == 1) node._cli.profiles.results = {Apply::BUSY, Apply::APPLIED};
|
||||
if (fault == 2) radio_driver.power_results = {-1, 0};
|
||||
node.service();
|
||||
assert(node.temp_radio_applied && node.revert_radio_at && "failed TempRadio restoration must retain its lease");
|
||||
assert(node.saved_radio_apply_pending == pending);
|
||||
assert(node.radio_apply_retry_at > now_ms && node.radio_apply_failures == 1);
|
||||
const unsigned before = calls(node);
|
||||
node.service(); assert(calls(node) == before);
|
||||
now_ms = node.radio_apply_retry_at + 1;
|
||||
node.outbound = true; node.service(); assert(calls(node) == before);
|
||||
node.outbound = false; node.service();
|
||||
assert(!node.temp_radio_applied && !node.revert_radio_at && !node.set_radio_at);
|
||||
assert(!node.saved_radio_apply_pending && !node.radio_apply_retry_at && !node.radio_apply_failures);
|
||||
restored(node);
|
||||
}
|
||||
{
|
||||
Node node; node.saved_radio_apply_pending = true;
|
||||
node.temp_radio_applied = true; node.revert_radio_at = now_ms + 10000;
|
||||
node.service(); assert(!calls(node) && node.saved_radio_apply_pending);
|
||||
// Do not apply an expired temporary tuple that never acquired its lease.
|
||||
node.saved_radio_apply_pending = false; node.temp_radio_applied = false;
|
||||
node.set_radio_at = now_ms - 2; node.revert_radio_at = now_ms - 1;
|
||||
node.service(); assert(!calls(node) && !node.set_radio_at && !node.revert_radio_at);
|
||||
}
|
||||
}
|
||||
int main() {
|
||||
startupAndRetry(); receiveBetweenTupleAndPower(); tempRadioRestoration();
|
||||
std::puts("PASS: production saved radio startup/retry/TempRadio restoration");
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def harness(role, mutation=None):
|
||||
path, name = ROLES[role]
|
||||
source = (ROOT / path).read_text()
|
||||
apply = body(source, f"bool {name}::applySavedRadioParams()")
|
||||
if mutation == "unchecked-power":
|
||||
checked = "return radio_driver.setTxPower(_prefs.tx_power_dbm);"
|
||||
assert checked in apply
|
||||
apply = apply.replace(checked, "radio_driver.setTxPower(_prefs.tx_power_dbm); return true;")
|
||||
elif mutation == "unchecked-gain":
|
||||
checked = "&& !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false;"
|
||||
assert checked in apply
|
||||
apply = apply.replace(checked, ") (void)radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);")
|
||||
begin = body(source, f"void {name}::begin(")
|
||||
start = begin.index(" saved_radio_apply_pending = !applySavedRadioParams();")
|
||||
startup = begin[start:begin.index(" const bool fem_gain_changed", start)]
|
||||
start = source.index(" const bool revert_radio_due = revert_radio_at")
|
||||
failed = source.index(" if (radio_apply_failed) {", start)
|
||||
service = source[start:failed] + body(source[failed:], " if (radio_apply_failed) {")
|
||||
wrapper = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
|
||||
signatures = ["uint8_t RadioLibWrapper::beginReconfigure()",
|
||||
"void RadioLibWrapper::endReconfigure(bool resume_rx)",
|
||||
"bool RadioLibWrapper::setTxPower(int8_t dbm)"]
|
||||
if role == "room":
|
||||
signatures.append("bool RadioLibWrapper::setRxBoostedGainMode(bool enabled)")
|
||||
methods = "\n".join(body(wrapper, signature) for signature in signatures)
|
||||
methods = methods.replace("RadioLibWrapper::", "Driver::")
|
||||
values = {
|
||||
"@CLASS@": name, "@APPLY@": apply, "@STARTUP@": startup,
|
||||
"@SERVICE@": service, "@WRAPPER_METHODS@": methods,
|
||||
"@RETRY_DELAY@": body(source, "static uint32_t nextRadioApplyRetryDelay("),
|
||||
}
|
||||
result = HARNESS
|
||||
for marker, value in values.items():
|
||||
result = result.replace(marker, value)
|
||||
return result
|
||||
|
||||
|
||||
class RoomSensorRadioRestoreTests(unittest.TestCase):
|
||||
def run_case(self, role, mutation=None):
|
||||
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
with tempfile.TemporaryDirectory(prefix="room-sensor-radio-restore-") as folder:
|
||||
cpp = Path(folder) / "test.cpp"
|
||||
binary = Path(folder) / ("test.exe" if os.name == "nt" else "test")
|
||||
cpp.write_text(harness(role, mutation))
|
||||
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-pie", "-no-pie"]
|
||||
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
f"-DROOM_ROLE={int(role == 'room')}", *flags,
|
||||
str(cpp), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=45)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15)
|
||||
if mutation:
|
||||
self.assertNotEqual(ran.returncode, 0, "unchecked setter negative control escaped")
|
||||
self.assertIn("startup must retain incomplete saved radio work", ran.stderr)
|
||||
else:
|
||||
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
|
||||
|
||||
def test_production_room_startup_retry_and_temp_restore(self):
|
||||
self.run_case("room")
|
||||
|
||||
def test_production_sensor_startup_retry_and_temp_restore(self):
|
||||
self.run_case("sensor")
|
||||
|
||||
def test_old_unchecked_power_cannot_complete_either_role(self):
|
||||
for role in ROLES:
|
||||
with self.subTest(role=role):
|
||||
self.run_case(role, "unchecked-power")
|
||||
|
||||
def test_old_unchecked_room_gain_cannot_complete(self):
|
||||
self.run_case("room", "unchecked-gain")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Execute CommonCLI get/set routing through the real G3 board and NVS adapter.
|
||||
|
||||
Only unrelated hardware setting branches are omitted. The production verb
|
||||
normalizer, top-level get/set branches, shared FEM handlers and unknown tails
|
||||
are extracted verbatim; board methods and checked NVS persistence run whole.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cpp_source import body
|
||||
from test_replay_reset_integration import extract_braced
|
||||
from test_station_g3_slot_settings import BOARD, NVS, VARIANT
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def unknown_tail(method, *, legacy=False):
|
||||
final_else = method.rfind("\n } else {")
|
||||
if final_else < 0:
|
||||
raise AssertionError("review the production unknown-setting branch")
|
||||
start = method.rfind("\n#if defined(STATION_G3_ESP32)", 0, final_else)
|
||||
if legacy or start < 0 or "_board->handleCommand(" not in method[start:final_else]:
|
||||
start = final_else
|
||||
return method[start:]
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
#include "nvs.h"
|
||||
#include "StationG3Board.h"
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <cassert>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#undef assert
|
||||
#define assert(condition) do { if (!(condition)) { \
|
||||
std::fprintf(stderr, "fixture assertion line %u: %s\n", unsigned(__LINE__), #condition); \
|
||||
std::exit(91); } } while (false)
|
||||
struct Board : StationG3Board {
|
||||
unsigned calls = 0;
|
||||
bool handleCommand(const char* command, uint32_t stamp, char* reply) {
|
||||
++calls;
|
||||
return StationG3Board::handleCommand(command, stamp, reply);
|
||||
}
|
||||
} board;
|
||||
struct Prefs {
|
||||
uint8_t radio_fem_rxgain = 1, radio_fem_txgain = 0;
|
||||
bool dirty = false;
|
||||
bool isDirty() const { return dirty; }
|
||||
} prefs;
|
||||
struct Callbacks {
|
||||
bool tx = false, rx = false;
|
||||
unsigned calibrations = 0;
|
||||
const Callbacks* getProfileRadio() const { return this; }
|
||||
bool isMorseTxActive() const { return tx; }
|
||||
bool isMorseRxActive() const { return rx; }
|
||||
void recalibrateNoiseFloor() { ++calibrations; }
|
||||
} callbacks;
|
||||
@KEY_EQUALS@
|
||||
struct CommonCLI {
|
||||
Board* _board = &board;
|
||||
Prefs* _prefs = &prefs;
|
||||
Callbacks* _callbacks = &callbacks;
|
||||
unsigned saves = 0;
|
||||
void savePrefs() { ++saves; prefs.dirty = false; }
|
||||
void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) {
|
||||
const char* config = &command[4];
|
||||
(void)sender_timestamp;
|
||||
@SET_BRANCHES_OPEN@
|
||||
@SET_TAIL@
|
||||
void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) {
|
||||
const char* config = &command[4];
|
||||
(void)sender_timestamp;
|
||||
@GET_BRANCHES_OPEN@
|
||||
@GET_TAIL@
|
||||
void handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
|
||||
mesh::cli::normalizeCommandVerb(command);
|
||||
@DISPATCH@
|
||||
else @TOP_BOARD@
|
||||
else { strcpy(reply, "Unknown command"); }
|
||||
}
|
||||
};
|
||||
static void fresh() {
|
||||
fake::reset(); fake::resetBoard(); board = Board(); prefs = Prefs(); callbacks = Callbacks();
|
||||
station_g3::slotState() = station_g3::SlotState();
|
||||
station_g3::loadSlotSettings();
|
||||
auto& state = station_g3::slotState();
|
||||
state.candidate = state.active = state.last = 2; state.detected = 3;
|
||||
}
|
||||
static std::string run(CommonCLI& cli, const char* input, uint32_t stamp = 0) {
|
||||
char command[160] = {}, reply[160] = {};
|
||||
snprintf(command, sizeof(command), "%s", input);
|
||||
cli.handleCommand(stamp, command, reply);
|
||||
return reply;
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2); const std::string scenario = argv[1];
|
||||
if (scenario == "routing") {
|
||||
for (uint32_t stamp : {0U, 123456U}) {
|
||||
fresh(); CommonCLI cli;
|
||||
prefs.dirty = true;
|
||||
auto reply = run(cli, "GET radio.slot", stamp);
|
||||
#if defined(STATION_G3_ESP32)
|
||||
assert(reply.find("> auto; active=2; detected=1,2") == 0 && "get must reach the real board");
|
||||
assert(cli.saves == 0 && prefs.dirty && fake::sets == 0);
|
||||
reply = run(cli, "SET radio.slot 1", stamp);
|
||||
assert(reply.find("OK - radio.slot 1 saved") == 0 && "set must reach the real board");
|
||||
auto& state = station_g3::slotState();
|
||||
assert(state.savedMode == 1 && state.bootMode == 0 && state.active == 2 && state.candidate == 2);
|
||||
assert(state.last == 2 && fake::disk.at("mode").value == 1 && cli.saves == 1);
|
||||
assert(fake::written.size() == 1 && fake::written.front() == "mode");
|
||||
reply = run(cli, "get radio.slot", stamp);
|
||||
assert(reply.find("reboot required") != std::string::npos && cli.saves == 1);
|
||||
assert(run(cli, "set radio.slot auto", stamp).find("OK") == 0);
|
||||
assert(run(cli, "set radio.slot 2", stamp).find("OK") == 0);
|
||||
state = station_g3::SlotState(); station_g3::loadSlotSettings();
|
||||
assert(state.bootMode == 2 && state.savedMode == 2 && state.active == 0);
|
||||
#else
|
||||
assert(reply.find("unknown") != std::string::npos && board.calls == 0 && cli.saves == 0);
|
||||
assert(run(cli, "set radio.slot 1", stamp) == "unknown config");
|
||||
assert(fake::sets == 0 && board.calls == 0);
|
||||
#endif
|
||||
}
|
||||
} else if (scenario == "failures") {
|
||||
fresh(); CommonCLI cli;
|
||||
for (const char* invalid : {"0", "3", "01", "2 extra", "AUTO", ""}) {
|
||||
const auto reply = run(cli, (std::string("set radio.slot ") + invalid).c_str());
|
||||
assert(reply.find("Error") == 0 && fake::sets == 0);
|
||||
}
|
||||
for (const char* command : {"set radio.slotting 1", "get radio.slotting", "set nothing 1", "get nothing"}) {
|
||||
assert(run(cli, command).find("unknown") != std::string::npos && fake::sets == 0);
|
||||
}
|
||||
for (unsigned fault = 0; fault < 3; ++fault) {
|
||||
fresh();
|
||||
fake::openError = fault == 0 ? 9 : 0;
|
||||
fake::setError = fault == 1 ? 9 : 0;
|
||||
fake::commitError = fault == 2 ? 9 : 0;
|
||||
const auto reply = run(cli, "set radio.slot 1", 123);
|
||||
assert(reply.find("Error: radio.slot could not be saved") == 0);
|
||||
const auto& state = station_g3::slotState();
|
||||
assert(state.savedMode == 0 && state.bootMode == 0 && state.active == 2 && state.candidate == 2);
|
||||
}
|
||||
} else if (scenario == "shared") {
|
||||
fresh(); CommonCLI cli;
|
||||
for (unsigned morse = 0; morse < 2; ++morse) {
|
||||
callbacks.tx = morse == 0; callbacks.rx = morse == 1;
|
||||
for (const char* command : {"set radio.fem.rxgain on", "set radio.fem.txgain on"}) {
|
||||
const auto reply = run(cli, command);
|
||||
assert(reply.find("Error: stop Morse") == 0 && "shared FEM guard must precede the board hook");
|
||||
assert(board.calls == 0 && fake::femMutations == 0 && cli.saves == 0);
|
||||
}
|
||||
}
|
||||
callbacks.tx = callbacks.rx = false;
|
||||
assert(run(cli, "set radio.fem.rxgain off").find("OK") == 0);
|
||||
assert(prefs.radio_fem_rxgain == 0 && cli.saves == 1 && board.calls == 0);
|
||||
assert(run(cli, "get radio.fem.rxgain") == "> off" && board.calls == 0);
|
||||
assert(run(cli, "set radio.fem.txgain on").find("OK") == 0);
|
||||
assert(prefs.radio_fem_txgain == 1 && cli.saves == 2 && board.calls == 0);
|
||||
assert(run(cli, "get radio.fem.txgain") == "> on" && board.calls == 0);
|
||||
assert(fake::sets == 0);
|
||||
} else assert(false);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class StationG3CliDispatchTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
setter = body(common, "void CommonCLI::handleSetCmd(")
|
||||
getter = body(common, "void CommonCLI::handleGetCmd(")
|
||||
top = body(common, "void CommonCLI::handleCommand(")
|
||||
dispatch = "\nelse ".join(extract_braced(top, signature) for signature in (
|
||||
'if (memcmp(command, "get ", 4) == 0)',
|
||||
'if (memcmp(command, "set ", 4) == 0)'))
|
||||
values = {
|
||||
"@KEY_EQUALS@": body(common, "static bool configKeyEquals("),
|
||||
"@SET_BRANCHES_OPEN@": "\nelse ".join(extract_braced(setter, signature) for signature in (
|
||||
'if (memcmp(config, "radio.fem.rxgain ", 17) == 0)',
|
||||
'if (memcmp(config, "radio.fem.txgain ", 17) == 0)'))[:-1],
|
||||
"@GET_BRANCHES_OPEN@": "\nelse ".join(extract_braced(getter, signature) for signature in (
|
||||
'if (configKeyEquals(config, "radio.fem.rxgain"))',
|
||||
'if (configKeyEquals(config, "radio.fem.txgain"))'))[:-1],
|
||||
"@DISPATCH@": dispatch,
|
||||
"@TOP_BOARD@": extract_braced(top, "if (_board->handleCommand("),
|
||||
}
|
||||
cls.work = tempfile.TemporaryDirectory(prefix="g3-cli-dispatch-")
|
||||
work = Path(cls.work.name)
|
||||
header = (VARIANT / "StationG3Board.h").read_text()
|
||||
board_header = BOARD.replace("@BOOT@", body(header, "void begin()"))
|
||||
board_header = board_header.replace("@IRQ@", body(header, "uint32_t getIRQGpio()"))
|
||||
(work / "StationG3Board.h").write_text(board_header)
|
||||
(work / "StationG3RadioSlot.h").write_text((VARIANT / "StationG3RadioSlot.h").read_text())
|
||||
(work / "nvs.h").write_text(NVS)
|
||||
(work / "helpers").mkdir()
|
||||
(work / "helpers/ESP32GpioHold.h").write_text("#pragma once\n")
|
||||
(work / "settings.cpp").write_text((VARIANT / "StationG3RadioSlot.cpp").read_text())
|
||||
(work / "board.cpp").write_text((VARIANT / "StationG3Board.cpp").read_text())
|
||||
cls.binaries = {}
|
||||
for variant in ("production", "old-get", "old-set", "early-board", "other-board"):
|
||||
code = HARNESS
|
||||
for token, value in values.items():
|
||||
code = code.replace(token, value)
|
||||
code = code.replace("@SET_TAIL@", unknown_tail(setter, legacy=variant == "old-set"))
|
||||
code = code.replace("@GET_TAIL@", unknown_tail(getter, legacy=variant == "old-get"))
|
||||
if variant == "early-board":
|
||||
code = code.replace(dispatch, 'if (_board->handleCommand(command, sender_timestamp, reply)) {} else ' + dispatch)
|
||||
source, binary = work / (variant + ".cpp"), work / variant
|
||||
source.write_text(code)
|
||||
sanitize = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
||||
if sys.platform.startswith("linux") else [])
|
||||
built = subprocess.run([
|
||||
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", *sanitize,
|
||||
*([] if variant == "other-board" else ["-DSTATION_G3_ESP32=1"]),
|
||||
"-I" + str(work), "-I" + str(ROOT / "src"), str(work / "settings.cpp"),
|
||||
str(work / "board.cpp"), str(source), "-o", str(binary),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
if built.returncode:
|
||||
cls.work.cleanup()
|
||||
raise AssertionError(built.stderr)
|
||||
cls.binaries[variant] = binary
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.work.cleanup()
|
||||
|
||||
def run_case(self, case, variant="production", failure=None):
|
||||
result = subprocess.run([str(self.binaries[variant]), case], capture_output=True, text=True, timeout=15)
|
||||
if failure:
|
||||
self.assertEqual(result.returncode, 91, result.stdout + result.stderr)
|
||||
self.assertIn(failure, result.stderr)
|
||||
else:
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_local_and_remote_get_set_reach_checked_board_persistence(self):
|
||||
self.run_case("routing")
|
||||
|
||||
def test_invalid_values_unknown_keys_and_nvs_failures_keep_live_slot(self):
|
||||
self.run_case("failures")
|
||||
|
||||
def test_shared_fem_ownership_and_preference_saving_take_priority(self):
|
||||
self.run_case("shared")
|
||||
|
||||
def test_non_g3_boards_keep_the_previous_unknown_setting_path(self):
|
||||
self.run_case("routing", "other-board")
|
||||
|
||||
def test_old_get_tail_cannot_reach_the_board(self):
|
||||
self.run_case("routing", "old-get", "get must reach the real board")
|
||||
|
||||
def test_old_set_tail_cannot_reach_the_board(self):
|
||||
self.run_case("routing", "old-set", "set must reach the real board")
|
||||
|
||||
def test_early_board_hook_would_bypass_shared_morse_guard(self):
|
||||
self.run_case("shared", "early-board", "shared FEM guard must precede the board hook")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Run production CLI/FEM paths against GPIOs while TX or sleep owns the mode."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
import test_esp32_gpio_hold as gpio_fixture
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VARIANT = ROOT / "variants/station_g3_esp32"
|
||||
|
||||
FIXTURE = r'''
|
||||
#include <Arduino.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <helpers/PrefsSaveReplyGuard.h>
|
||||
#include "LoRaFEMControl.h"
|
||||
#include "StationG3RadioSlot.h"
|
||||
struct ESP32Board {
|
||||
virtual void onBeforeTransmit() {}
|
||||
virtual void onAfterTransmit() {}
|
||||
};
|
||||
struct StationG3Board : ESP32Board {
|
||||
LoRaFEMControl loRaFEMControl;
|
||||
bool setLoRaFemLnaEnabled(bool); bool canControlLoRaFemLna() const;
|
||||
bool isLoRaFemLnaEnabled() const;
|
||||
bool setLoRaFemPaGainEnabled(bool); bool canControlLoRaFemPaGain() const;
|
||||
bool isLoRaFemPaGainEnabled() const;
|
||||
@BOARD_CALLBACKS@
|
||||
} board;
|
||||
@BOARD_METHODS@
|
||||
struct Radio {
|
||||
unsigned calibrations = 0;
|
||||
bool isMorseTxActive() const { return false; }
|
||||
bool isMorseRxActive() const { return false; }
|
||||
void recalibrateNoiseFloor() { ++calibrations; }
|
||||
};
|
||||
struct Prefs {
|
||||
uint8_t radio_fem_rxgain = 1, radio_fem_txgain = 0, radio_fem_rxgain_override = 0;
|
||||
};
|
||||
struct MyMesh {
|
||||
Prefs _prefs; Radio radio; Radio* _radio = &radio;
|
||||
bool _radio_available = true, save_ok = true;
|
||||
unsigned saves = 0;
|
||||
bool savePrefs() { ++saves; return save_ok; }
|
||||
Radio* getProfileRadio() { return _radio; }
|
||||
void recalibrateNoiseFloor() { _radio->recalibrateNoiseFloor(); }
|
||||
bool applyAndSaveFemRxGain(bool); bool applyAndSaveFemTxGain(bool);
|
||||
};
|
||||
@COMPANION_METHODS@
|
||||
struct CommonCLI {
|
||||
StationG3Board* _board = &board; MyMesh* _callbacks; Prefs* _prefs;
|
||||
bool save_ok = true;
|
||||
unsigned saves = 0, _prefs_save_failures = 0;
|
||||
explicit CommonCLI(MyMesh& mesh) : _callbacks(&mesh), _prefs(&mesh._prefs) {}
|
||||
void savePrefs() { ++saves; if (!save_ok) ++_prefs_save_failures; }
|
||||
void handleSetCmd(uint32_t, char* command, char* reply) {
|
||||
const char* config = command + 4;
|
||||
@COMMON_BRANCHES@
|
||||
}
|
||||
void handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
|
||||
PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply);
|
||||
@COMMON_DISPATCH@
|
||||
else assert(false && "unexpected non-set command");
|
||||
}
|
||||
};
|
||||
int main() {
|
||||
for (unsigned slot : {1, 2}) for (bool companion : {false, true}) {
|
||||
board = StationG3Board();
|
||||
for (auto& pad : pads) pad = Pad();
|
||||
for (auto& writes : g3_gpio_writes) writes = 0;
|
||||
station_g3::slotState().candidate = slot;
|
||||
const int lna = station_g3::activePins().lna;
|
||||
const int inactive = station_g3::pinsForSlot(slot == 1 ? 2 : 1).lna;
|
||||
board.loRaFEMControl.init();
|
||||
MyMesh mesh; CommonCLI cli(mesh); char reply[160] = {};
|
||||
auto set_rx = [&](bool enabled, bool save_ok = true) {
|
||||
mesh.save_ok = cli.save_ok = save_ok;
|
||||
bool success;
|
||||
if (companion) success = mesh.applyAndSaveFemRxGain(enabled);
|
||||
else {
|
||||
char command[40];
|
||||
strcpy(command, enabled ? "set radio.fem.rxgain on" : "set radio.fem.rxgain off");
|
||||
cli.handleCommand(0, command, reply);
|
||||
success = !strncmp(reply, "OK", 2);
|
||||
}
|
||||
assert(success == save_ok);
|
||||
};
|
||||
auto set_pa = [&](bool enabled, bool save_ok = true) {
|
||||
mesh.save_ok = cli.save_ok = save_ok;
|
||||
bool success;
|
||||
if (companion) success = mesh.applyAndSaveFemTxGain(enabled);
|
||||
else {
|
||||
char command[40];
|
||||
strcpy(command, enabled ? "set radio.fem.txgain on" : "set radio.fem.txgain off");
|
||||
cli.handleCommand(0, command, reply);
|
||||
success = !strncmp(reply, "OK", 2);
|
||||
}
|
||||
assert(success == save_ok);
|
||||
};
|
||||
assert(physical(lna) == HIGH && physical(inactive) == HIGH);
|
||||
const unsigned inactive_writes = g3_gpio_writes[inactive];
|
||||
board.loRaFEMControl.setRxModeEnable();
|
||||
assert(physical(lna) == LOW && physical(inactive) == HIGH);
|
||||
set_rx(false); assert(physical(lna) == HIGH);
|
||||
set_rx(true); assert(physical(lna) == LOW);
|
||||
// The actual board callbacks establish packet TX bypass until retirement.
|
||||
board.onBeforeTransmit(); assert(physical(lna) == HIGH);
|
||||
set_rx(true);
|
||||
assert(physical(lna) == HIGH && "same-value RX preference must retain TX bypass");
|
||||
set_rx(false); set_rx(true);
|
||||
assert(physical(lna) == HIGH && board.isLoRaFemLnaEnabled());
|
||||
board.onAfterTransmit(); assert(physical(lna) == LOW);
|
||||
board.onBeforeTransmit(); set_rx(false);
|
||||
board.onAfterTransmit(); assert(physical(lna) == HIGH);
|
||||
|
||||
// Companion's actual save-failure helper rolls desired state back; the
|
||||
// legacy CommonCLI guard reports uncertainty and leaves its live setting.
|
||||
// Neither path may re-enable the LNA during that transaction's TX mode.
|
||||
board.onBeforeTransmit(); set_rx(true, false);
|
||||
assert(physical(lna) == HIGH && physical(inactive) == HIGH);
|
||||
if (companion) {
|
||||
assert(!board.isLoRaFemLnaEnabled() && mesh._prefs.radio_fem_rxgain == 0);
|
||||
} else {
|
||||
assert(board.isLoRaFemLnaEnabled() && mesh._prefs.radio_fem_rxgain == 1);
|
||||
assert(strstr(reply, "save unconfirmed; changes may be active"));
|
||||
}
|
||||
board.onAfterTransmit();
|
||||
assert(physical(lna) == (companion ? HIGH : LOW));
|
||||
set_rx(true);
|
||||
board.onBeforeTransmit(); set_rx(false, false);
|
||||
assert(physical(lna) == HIGH);
|
||||
if (companion) assert(board.isLoRaFemLnaEnabled() && mesh._prefs.radio_fem_rxgain == 1);
|
||||
board.onAfterTransmit(); assert(physical(lna) == (companion ? LOW : HIGH));
|
||||
|
||||
// PA settings still change only at the next TX start, including rollback.
|
||||
assert(physical(P_PA1_EN) == LOW);
|
||||
board.onBeforeTransmit(); set_pa(true);
|
||||
assert(physical(P_PA1_EN) == LOW && board.isLoRaFemPaGainEnabled());
|
||||
board.onAfterTransmit(); assert(physical(P_PA1_EN) == LOW);
|
||||
board.onBeforeTransmit(); assert(physical(P_PA1_EN) == HIGH);
|
||||
set_pa(false, false); assert(physical(P_PA1_EN) == HIGH);
|
||||
if (companion) assert(board.isLoRaFemPaGainEnabled() && mesh._prefs.radio_fem_txgain == 1);
|
||||
board.onAfterTransmit(); board.onBeforeTransmit();
|
||||
assert(physical(P_PA1_EN) == (companion ? HIGH : LOW));
|
||||
board.onAfterTransmit();
|
||||
assert(g3_gpio_writes[inactive] == inactive_writes);
|
||||
|
||||
// Shutdown overrides both sockets; later setters only cache next-RX gain.
|
||||
board.loRaFEMControl.setSleepModeEnable();
|
||||
assert(physical(lna) == HIGH && physical(inactive) == HIGH && physical(P_PA1_EN) == LOW);
|
||||
const unsigned sleeping_writes = g3_gpio_writes[lna];
|
||||
set_rx(false); set_rx(true); set_pa(true);
|
||||
assert(physical(lna) == HIGH && physical(inactive) == HIGH && physical(P_PA1_EN) == LOW);
|
||||
assert(g3_gpio_writes[lna] == sleeping_writes);
|
||||
board.loRaFEMControl.setRxModeEnable();
|
||||
assert(physical(lna) == LOW && physical(inactive) == HIGH);
|
||||
// A subsequent init also remains non-receiving while adapters publish ON.
|
||||
board.loRaFEMControl.init();
|
||||
assert(physical(lna) == HIGH && physical(inactive) == HIGH);
|
||||
set_rx(true); set_rx(false); set_rx(true);
|
||||
assert(physical(lna) == HIGH && physical(inactive) == HIGH);
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class StationG3FemControlTest(unittest.TestCase):
|
||||
def source_fixture(self):
|
||||
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
||||
board = (VARIANT / "StationG3Board.cpp").read_text()
|
||||
board_header = (VARIANT / "StationG3Board.h").read_text()
|
||||
dispatch = 'if (memcmp(command, "set ", 4) == 0)'
|
||||
self.assertLess(common.index(dispatch), common.index("_board->handleCommand("))
|
||||
values = {
|
||||
"@COMMON_DISPATCH@": extract_braced(common, dispatch),
|
||||
"@COMMON_BRANCHES@": "\nelse ".join(extract_braced(common, signature) for signature in (
|
||||
'if (memcmp(config, "radio.fem.rxgain ", 17) == 0)',
|
||||
'if (memcmp(config, "radio.fem.txgain ", 17) == 0)')),
|
||||
"@COMPANION_METHODS@": "\n".join(extract_braced(companion, signature) for signature in (
|
||||
"bool MyMesh::applyAndSaveFemRxGain(", "bool MyMesh::applyAndSaveFemTxGain(")),
|
||||
"@BOARD_CALLBACKS@": "\n".join(extract_braced(board_header, signature) for signature in (
|
||||
"void onBeforeTransmit()", "void onAfterTransmit()")),
|
||||
"@BOARD_METHODS@": "\n".join(extract_braced(board, signature) for signature in (
|
||||
"bool StationG3Board::setLoRaFemLnaEnabled(", "bool StationG3Board::canControlLoRaFemLna(",
|
||||
"bool StationG3Board::isLoRaFemLnaEnabled(", "bool StationG3Board::setLoRaFemPaGainEnabled(",
|
||||
"bool StationG3Board::canControlLoRaFemPaGain(", "bool StationG3Board::isLoRaFemPaGainEnabled(")),
|
||||
}
|
||||
code = FIXTURE
|
||||
for token, value in values.items():
|
||||
code = code.replace(token, value)
|
||||
return code
|
||||
|
||||
def compile_run(self, *, old_setter=False):
|
||||
with tempfile.TemporaryDirectory(prefix="g3-fem-control-") as directory:
|
||||
work = Path(directory)
|
||||
(work / "driver").mkdir()
|
||||
(work / "driver/rtc_io.h").write_text(gpio_fixture.SDK)
|
||||
(work / "driver/gpio.h").write_text('#include "rtc_io.h"\n')
|
||||
(work / "Arduino.h").write_text(
|
||||
'#pragma once\n#include <driver/gpio.h>\n'
|
||||
'inline unsigned g3_gpio_writes[64] = {};\n'
|
||||
'inline void digitalWrite(int pin, int level) { ++g3_gpio_writes[pin]; gpio_set_level(pin, level); }\n')
|
||||
for name in ("LoRaFEMControl.h", "StationG3RadioSlot.h"):
|
||||
(work / name).write_text((VARIANT / name).read_text())
|
||||
fem = (VARIANT / "LoRaFEMControl.cpp").read_text()
|
||||
if old_setter:
|
||||
old = fem.replace("if (_mode == Mode::Receive) setRxModeEnable();", "setRxModeEnable();")
|
||||
self.assertNotEqual(old, fem)
|
||||
fem = old
|
||||
(work / "fem.cpp").write_text(fem)
|
||||
(work / "test.cpp").write_text(self.source_fixture())
|
||||
sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
||||
for macro_lna in (10, 42):
|
||||
with self.subTest(compiled_lna=macro_lna, old_setter=old_setter):
|
||||
result = subprocess.run([
|
||||
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", *sanitizer,
|
||||
"-DCHIP_S3=1", "-DSOC_RTCIO_INPUT_OUTPUT_SUPPORTED=1", "-DHIGH=1", "-DLOW=0",
|
||||
"-DP_PA1_EN=9", "-DP_PA1_EN_ACTIVE=1", f"-DP_PRIMARY_LNA_EN={macro_lna}",
|
||||
"-DP_PRIMARY_LNA_EN_ACTIVE=0", "-I" + str(work), "-I" + str(ROOT / "src"),
|
||||
str(work / "test.cpp"), str(work / "fem.cpp"), "-o", str(work / "test"),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
result = subprocess.run([str(work / "test")], capture_output=True, text=True, timeout=15)
|
||||
if old_setter:
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("same-value RX preference must retain TX bypass", result.stderr)
|
||||
else:
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_cli_gain_changes_preserve_tx_sleep_and_rollback_modes(self):
|
||||
self.compile_run()
|
||||
|
||||
def test_old_immediate_rx_setter_fails_real_gpio_regression(self):
|
||||
self.compile_run(old_setter=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "StationG3RadioSlot.h"
|
||||
|
||||
void LoRaFEMControl::init() {
|
||||
_mode = Mode::Sleep;
|
||||
#ifdef P_PA1_EN
|
||||
mesh::esp32::releaseOutputAfterDeepSleep((gpio_num_t)P_PA1_EN,
|
||||
pa_gain_enabled ? P_PA1_EN_ACTIVE : !P_PA1_EN_ACTIVE);
|
||||
@@ -19,6 +20,7 @@ void LoRaFEMControl::init() {
|
||||
}
|
||||
|
||||
void LoRaFEMControl::setSleepModeEnable() {
|
||||
_mode = Mode::Sleep;
|
||||
#ifdef P_PA1_EN
|
||||
// PA PL1 low/open selects the lower of the two hardware-jumper-selected levels.
|
||||
digitalWrite(P_PA1_EN, !P_PA1_EN_ACTIVE);
|
||||
@@ -26,11 +28,13 @@ void LoRaFEMControl::setSleepModeEnable() {
|
||||
#ifdef P_PRIMARY_LNA_EN
|
||||
// LNA P/S are active LOW. Bypass/disable the receive amplifier on shutdown,
|
||||
// rather than retaining the same enabled state used during normal receive.
|
||||
digitalWrite(station_g3::activePins().lna, !P_PRIMARY_LNA_EN_ACTIVE);
|
||||
for (uint8_t slot = 1; slot <= 2; ++slot)
|
||||
digitalWrite(station_g3::pinsForSlot(slot).lna, !P_PRIMARY_LNA_EN_ACTIVE);
|
||||
#endif
|
||||
}
|
||||
|
||||
void LoRaFEMControl::setTxModeEnable() {
|
||||
_mode = Mode::Transmit;
|
||||
// Latch the requested PA level here, before the SX1262 starts driving the PA. PA PL1
|
||||
// retargets the PA's DC-DC rail, so moving it mid-transmit collapses the supply while
|
||||
// the PA is still driven at full input power.
|
||||
@@ -41,6 +45,7 @@ void LoRaFEMControl::setTxModeEnable() {
|
||||
}
|
||||
|
||||
void LoRaFEMControl::setRxModeEnable() {
|
||||
_mode = Mode::Receive;
|
||||
#ifdef P_PRIMARY_LNA_EN
|
||||
digitalWrite(station_g3::activePins().lna,
|
||||
lna_enabled ? P_PRIMARY_LNA_EN_ACTIVE : !P_PRIMARY_LNA_EN_ACTIVE);
|
||||
@@ -49,7 +54,8 @@ void LoRaFEMControl::setRxModeEnable() {
|
||||
|
||||
void LoRaFEMControl::setLNAEnable(bool enabled) {
|
||||
lna_enabled = enabled;
|
||||
setRxModeEnable();
|
||||
// Preserve TX bypass and shutdown while accepting the next RX preference.
|
||||
if (_mode == Mode::Receive) setRxModeEnable();
|
||||
}
|
||||
|
||||
void LoRaFEMControl::setPAGainEnable(bool enabled) {
|
||||
|
||||
@@ -15,8 +15,10 @@ public:
|
||||
bool isPAGainEnabled() const { return pa_gain_enabled; }
|
||||
|
||||
private:
|
||||
enum class Mode : unsigned char { Sleep, Receive, Transmit };
|
||||
void applyPAGain();
|
||||
|
||||
Mode _mode = Mode::Sleep;
|
||||
bool lna_enabled = true;
|
||||
bool pa_gain_enabled = false;
|
||||
};
|
||||
Reference in new issue
Block a user