diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index d5c608b61..429eef3c8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 5f755bb2d..706aeedc4 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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. --- diff --git a/docs/test-results/station_g3_g2_compatibility.md b/docs/test-results/station_g3_g2_compatibility.md index 84db93c09..7cf2a0bd2 100644 --- a/docs/test-results/station_g3_g2_compatibility.md +++ b/docs/test-results/station_g3_g2_compatibility.md @@ -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 diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index ceed8c15f..cc34c0e23 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 7ece45383..f81bf3bbd 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -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; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index a45bdd133..550d2f430 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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); diff --git a/test/test_room_sensor_radio_restore.py b/test/test_room_sensor_radio_restore.py new file mode 100644 index 000000000..dcef4c5ee --- /dev/null +++ b/test/test_room_sensor_radio_restore.py @@ -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 +#include +#include +#include +#include +// 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 T next(std::deque& 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 power_results; + std::deque 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 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() diff --git a/test/test_station_g3_cli_dispatch.py b/test/test_station_g3_cli_dispatch.py new file mode 100644 index 000000000..d0e0d26a3 --- /dev/null +++ b/test/test_station_g3_cli_dispatch.py @@ -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 +#include +#include +#include +#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() diff --git a/test/test_station_g3_fem_control.py b/test/test_station_g3_fem_control.py new file mode 100644 index 000000000..06e49b924 --- /dev/null +++ b/test/test_station_g3_fem_control.py @@ -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 +#include +#include +#include +#include +#include +#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 \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() diff --git a/variants/station_g3_esp32/LoRaFEMControl.cpp b/variants/station_g3_esp32/LoRaFEMControl.cpp index 441209622..beca39098 100644 --- a/variants/station_g3_esp32/LoRaFEMControl.cpp +++ b/variants/station_g3_esp32/LoRaFEMControl.cpp @@ -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) { diff --git a/variants/station_g3_esp32/LoRaFEMControl.h b/variants/station_g3_esp32/LoRaFEMControl.h index f622de91e..cfb0dac9d 100644 --- a/variants/station_g3_esp32/LoRaFEMControl.h +++ b/variants/station_g3_esp32/LoRaFEMControl.h @@ -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; };