Fix G3 gain transitions, slot commands and radio restore

This commit is contained in:
mikecarper committed 2026-10-18 14:29:19 -07:00
1 parent 015eff4c22
commit 22ab02caf7
11 files changed
+933 -21

No files matched your search

+3
View File
@@ -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
+3
View File
@@ -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
+5 -11
View File
@@ -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;
+3 -8
View File
@@ -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;
+9
View File
@@ -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);
+333
View File
@@ -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()
+262
View File
@@ -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()
+246
View File
@@ -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()
+8 -2
View File
@@ -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;
};