From b0c49a4cc3c1c5e7707f6860d2f7230db55dc69d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 25 Oct 2026 22:00:24 -0700 Subject: [PATCH] Fit STM32 release images and repair stale CI fixtures Fix the STM32 flash overflows and fleet fixture compile failure from https://github.com/mikecarper/MeshCore/actions/runs/40241742182. Fold already-unsupported RX control paths only for the integrated STM32WL wrapper. Preserve RXPS-off validation, persistence, pending application and rollback. External radios retain their supported controls. Use measured GCC 14 code sharing in the four small STM32 USB Companion recipes. Keep firmware features, contact/channel capacities, filesystem boundaries and flash/RAM admission checks unchanged. Refresh downstream host fixtures for saved radio intent, deferred service restarts, WebConfig ownership, RX recovery and color tint state. Retain or strengthen behavior checks rather than skipping failing coverage. Validation: canonical RAK3x72, Wio Mini, Wio Dev and Tiny Relay USB Companion packages and Mini Repeater build passed at release-version length. All post-failure host checks, compact arithmetic/float checks, build-profile and real PlatformIO recipe/path/RAM-policy checks passed locally. --- src/helpers/CommonCLI.cpp | 40 ++++++- src/helpers/radiolib/CustomSTM32WLxWrapper.h | 4 + test/fixtures/companion_ap_ownership.cpp | 14 ++- test/fixtures/companion_lost_reply.cpp | 4 +- test/fixtures/sensor_tracker_client.cpp | 7 ++ test/fixtures/sensor_tracker_client.h | 14 ++- test/test_beacon_legacy_lora_protocol.py | 3 +- test/test_companion_ap_ownership.py | 3 + test/test_companion_fleet_control.py | 2 + test/test_companion_wifi_session.py | 12 +-- test/test_esp32_usb_serial_hygiene.py | 5 +- test/test_espnow_radio_lifecycle.py | 8 +- test/test_firmware_ram.py | 1 + test/test_logging_sleep_contract.py | 7 ++ test/test_meshtext_transport.py | 7 +- test/test_power_saving_role_diagnostics.py | 5 +- test/test_radio_rx_settings_transaction.py | 108 +++++++++++++++++-- test/test_rs232_heartbeat_owner.py | 10 +- test/test_rxps_hardware_capability.py | 2 +- test/test_transport_filter_wiring.py | 22 ++++ variants/rak3x72/platformio.ini | 3 + variants/tiny_relay/platformio.ini | 2 + variants/wio-e5-dev/platformio.ini | 2 + variants/wio-e5-mini/platformio.ini | 2 + 24 files changed, 252 insertions(+), 35 deletions(-) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c2fce59c5..b065613fa 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -253,8 +253,10 @@ mesh::RadioParamApplyResult CommonCLI::applyRxSetting(uint8_t setting) { const mesh::Radio* radio = _callbacks->getProfileRadio(); if (mesh::morseTimingActive(radio) || (radio && radio->isCarrierWaveActive())) return Result::BUSY; switch (setting) { +#if !defined(STM32_PLATFORM) || !defined(STM32WLxx) || !defined(USE_STM32WL) case RxGain: return _callbacks->trySetRxBoostedGain(_prefs->rx_boosted_gain != 0); +#endif case RxPowerSaving: return _callbacks->trySetRxPowerSaving(_prefs->rx_powersaving_enabled != 0, _prefs->rx_ps_rx_us, _prefs->rx_ps_sleep_us); @@ -265,9 +267,11 @@ mesh::RadioParamApplyResult CommonCLI::applyRxSetting(uint8_t setting) { return _callbacks->tryConfigSideDetectors(_prefs->extra_sf, count, _prefs->bw); } #endif +#if !defined(STM32_PLATFORM) || !defined(STM32WLxx) || !defined(USE_STM32WL) case RxRfRx: return _callbacks->trySetRxPowerSavingRfRxDisabled( (_rx_settings_pending & RxRfRxDisabled) != 0); +#endif default: return Result::FAILED; } } @@ -276,7 +280,14 @@ void CommonCLI::servicePendingRxSettings() { if (!(_rx_settings_pending & RxPendingMask) || (_rx_settings_retry_at && static_cast(millis() - _rx_settings_retry_at) < 0)) return; bool failed = false; - for (uint8_t setting = RxGain; setting <= RxRfRx; setting <<= 1) { +#if defined(STM32_PLATFORM) && defined(STM32WLxx) && defined(USE_STM32WL) + // The integrated driver supports only the RXPS-off transaction below. + // Gain, RF_RX control and side detectors cannot acquire pending targets. + constexpr uint8_t first = RxPowerSaving, last = RxPowerSaving; +#else + constexpr uint8_t first = RxGain, last = RxRfRx; +#endif + for (uint8_t setting = first; setting <= last; setting <<= 1) { if (!(_rx_settings_pending & setting)) continue; const auto result = applyRxSetting(setting); if (result == mesh::RadioParamApplyResult::APPLIED) _rx_settings_pending &= ~setting; @@ -4782,7 +4793,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep mesh::cli::formatError(reply, 160, "state must be on or off"); return; } - if (!_callbacks->supportsRxBoostedGain()) { +#if defined(STM32_PLATFORM) && defined(STM32WLxx) && defined(USE_STM32WL) + // CustomSTM32WLxWrapper has no runtime gain/RXPS/RF_RX controls. Fold + // unreachable setters without changing their validation or error replies. + constexpr bool gain_supported = false; +#else + const bool gain_supported = _callbacks->supportsRxBoostedGain(); +#endif + if (!gain_supported) { mesh::cli::formatError(reply, 160, "unsupported"); return; } @@ -4865,7 +4883,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } - if (!_callbacks->supportsRxPowerSavingRfRxDisable()) { +#if defined(STM32_PLATFORM) && defined(STM32WLxx) && defined(USE_STM32WL) + constexpr bool rf_rx_control_supported = false; +#else + const bool rf_rx_control_supported = _callbacks->supportsRxPowerSavingRfRxDisable(); +#endif + if (!rf_rx_control_supported) { mesh::cli::formatError(reply, 160, "unsupported"); return; } @@ -4909,7 +4932,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } using mesh::cli::RxPowerSavingArgumentMode; - if (!_callbacks->supportsRxPowerSaving() && parsed.mode != RxPowerSavingArgumentMode::Off) { +#if defined(STM32_PLATFORM) && defined(STM32WLxx) && defined(USE_STM32WL) +#ifdef WRAPPER_CLASS + static_assert(!WRAPPER_CLASS::SupportsRxPowerSaving, + "Revisit STM32WL CLI folding when the integrated driver gains RXPS"); +#endif + constexpr bool powersaving_supported = false; +#else + const bool powersaving_supported = _callbacks->supportsRxPowerSaving(); +#endif + if (!powersaving_supported && parsed.mode != RxPowerSavingArgumentMode::Off) { strcpy(reply, "ERROR: RX powersaving unsupported"); return; } diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index 91c048fe9..641eaf093 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -1,4 +1,8 @@ #pragma once + +#ifndef USE_STM32WL +#define USE_STM32WL +#endif #include "SX126xReceiveMode.h" #include "CustomSTM32WLx.h" diff --git a/test/fixtures/companion_ap_ownership.cpp b/test/fixtures/companion_ap_ownership.cpp index 20d757aee..e05b125a3 100644 --- a/test/fixtures/companion_ap_ownership.cpp +++ b/test/fixtures/companion_ap_ownership.cpp @@ -42,9 +42,10 @@ struct Board{ void setInhibitSleep(bool enabled){inhibit_sleep=enabled;} }board; struct Mesh{ - bool setup=false,recovery=false,active=false; + bool setup=false,recovery=false,active=false,stopping=false; bool isWebConfigSetupActive(){return setup;} bool isWebConfigWiFiRecoveryActive(){return recovery;} + bool isWebConfigWiFiTransitionActive(){return setup||stopping;} bool isWebConfigActiveOrStopping(){return active;} bool startWebConfig(bool,char*){return false;} void stopWebConfig(){setup=false;} @@ -113,6 +114,17 @@ int main(int argc,char** argv){ sdk_mode=WIFI_STA;board.ota=false;serviceCompanionWiFiCredentialReload(); assert(!companion_wifi_credential_reload_pending&&companion_wifi_credential_reload_at==0); assert(credential_loads==1&&mode_writes==1&&disconnects==1&&joins==1); + }else if(scenario=="transition_reload"){ +#ifdef WITH_WEBCONFIG + sdk_mode=WIFI_STA;board.ota=false;the_mesh.stopping=true; + serviceCompanionWiFiCredentialReload(); + assert(companion_wifi_credential_reload_pending&&companion_wifi_credential_reload_at==1); + assert(credential_loads==0&&mode_writes==0&&disconnects==0&&joins==0); + // The SDK AP may be gone while WebConfig still owns its teardown barrier. + the_mesh.stopping=false;serviceCompanionWiFiCredentialReload(); + assert(!companion_wifi_credential_reload_pending&&companion_wifi_credential_reload_at==0); + assert(credential_loads==1&&mode_writes==1&&disconnects==1&&joins==1); +#endif }else if(scenario=="setup_handoff"){ #ifdef WITH_WEBCONFIG sdk_mode=WIFI_AP_STA;board.ota=false;the_mesh.setup=the_mesh.recovery=true; diff --git a/test/fixtures/companion_lost_reply.cpp b/test/fixtures/companion_lost_reply.cpp index 74421fc5c..47bcca407 100644 --- a/test/fixtures/companion_lost_reply.cpp +++ b/test/fixtures/companion_lost_reply.cpp @@ -111,6 +111,7 @@ public: void endCommand() {} } _morse_reply; bool _morse_reply_admission = false; + bool _primary_radio_reply_admission = false; struct Prefs { uint8_t lost_reply = 0; } _prefs; Clock clock, *_ms = &clock; Manager manager, *_mgr = &manager; @@ -462,8 +463,9 @@ static void localPolicyAndRemoteBoundary() { auto& peer = m.contact(1); peer.flags = 0x10; for (uint32_t timestamp : {uint32_t(0), uint32_t(1), uint32_t(0xffffffffU)}) { reply[0] = 0; + m._primary_radio_reply_admission = true; m.onCLICommandRecv(peer, &m.incoming, timestamp, "set lost.reply yes", reply); - assert(m._prefs.lost_reply == 0 && m.saves == 4); + assert(!m._primary_radio_reply_admission && m._prefs.lost_reply == 0 && m.saves == 4); reply[0] = 0; m.onCLICommandRecv(peer, &m.incoming, timestamp, "get lost.reply", reply); assert(std::string(reply) == "Unknown command"); diff --git a/test/fixtures/sensor_tracker_client.cpp b/test/fixtures/sensor_tracker_client.cpp index 47fc03e1a..48e454486 100644 --- a/test/fixtures/sensor_tracker_client.cpp +++ b/test/fixtures/sensor_tracker_client.cpp @@ -53,7 +53,13 @@ static void transmitted(SensorMesh& mesh) { assert(mesh.pool.queued.size() == 1 && mesh.tracker_pending); Packet* packet = mesh.pool.removeOutboundByIdx(0); mesh.pool.in_flight = packet; + const auto tx_services = mesh._cli.tx_services, rx_services = mesh._cli.rx_services; + const auto tuning_services = mesh._cli.profiles.tuning_services; + const auto primary_services = mesh.primary_radio_services; mesh.onSendComplete(packet); + assert(mesh._cli.tx_services == tx_services + 1 && mesh._cli.rx_services == rx_services + 1); + assert(mesh._cli.profiles.tuning_services == tuning_services + 1); + assert(mesh.primary_radio_services == primary_services + 1); assert(mesh.tracker_sent && mesh.tracker_reply_until == uint32_t(mesh.now_ms + 20000)); // The dispatcher owns and frees the original packet after this callback. mesh.pool.in_flight = nullptr; mesh.releasePacket(packet); @@ -640,6 +646,7 @@ static void gpsAndMaintenance() { { SensorMesh mesh; configure(mesh); queueCheck(mesh); transmitted(mesh); assert(response(mesh, Safe)); bool* inhibitors[] = {&mesh.board.usb, &mesh.board.ota, &mesh.board.test, &mesh.temporary, &mesh.pending_ota, &mesh.dual, &mesh.saved_radio_apply_pending, &mesh._cli.active, + &mesh._cli.pending_radio_settings, &mesh.queue_wake, &mesh.retry_wake, &mesh::test_logging_watchdog}; for (bool* condition : inhibitors) { *condition = true; assert(mesh.trackerNeedsRadio()); mesh.serviceTracker(); diff --git a/test/fixtures/sensor_tracker_client.h b/test/fixtures/sensor_tracker_client.h index 28d159d98..525b93286 100644 --- a/test/fixtures/sensor_tracker_client.h +++ b/test/fixtures/sensor_tracker_client.h @@ -169,7 +169,15 @@ public: FakeTrigger* alert_tasks[4] = {&alert1, &alert2}; int num_alert_tasks = 2; struct CLI { - void servicePendingTxPower() {} + unsigned tx_services = 0, rx_services = 0; + bool pending_radio_settings = false; + void servicePendingTxPower() { ++tx_services; } + void servicePendingRxSettings() { ++rx_services; } + struct Profiles { + unsigned tuning_services = 0; + void servicePendingTuning() { ++tuning_services; } + } profiles; + Profiles& radioProfiles() { return profiles; } bool batteryAllowsForward(bool) const { return true; } bool batteryAllowsTransmit(const mesh::Packet*) const { return true; } struct NoMorseReply { void complete(const void*) {} void fail(const void*) {} } morse; @@ -180,13 +188,15 @@ public: SensorMesh* owner = nullptr; void loop(); bool hasActiveUserGpioTimer() const { return active; } - bool hasPendingTxPower() const { return false; } + bool hasPendingRadioSettings() const { return pending_radio_settings; } } _cli; struct ClockSync { unsigned observed = 0, loops = 0; void loop() { ++loops; } void observeAcceptedFlood(const mesh::Packet*) { ++observed; } } _clock_sync; uint32_t set_radio_at = 0, revert_radio_at = 0; bool saved_radio_apply_pending = false, temporary = false, pending_ota = false, dual = false; + unsigned primary_radio_services = 0; + void servicePendingPrimaryRadioSettings(bool tx_gap) { assert(tx_gap); ++primary_radio_services; } bool queue_wake = false, retry_wake = false, available = true; bool allocation_failure = false, admission_failure = false; unsigned created = 0, direct_sends = 0, flood_sends = 0, adverts = 0, zero_hops = 0; diff --git a/test/test_beacon_legacy_lora_protocol.py b/test/test_beacon_legacy_lora_protocol.py index 7045a3d01..7327b538b 100644 --- a/test/test_beacon_legacy_lora_protocol.py +++ b/test/test_beacon_legacy_lora_protocol.py @@ -76,6 +76,7 @@ struct RadioLibWrapper { PhysicalLayer* _radio; mesh::MainBoard* _board; bool _params_valid = true, _rx_ps_armed = true, _dbm_valid = false, _dbm_applied = false, _rx_boosted_gain_valid = false; + bool _rx_boosted_gain_applied = true; bool _profile_repair_required = false; int state = 1; float _cur_freq = 906.875f, _cur_bw = 250; @@ -142,7 +143,7 @@ static void failures_and_recovery() { assert(wrapper.restoreAfterDeepInit()); assert(!wrapper._profile_repair_required); assert(chip.sync == 0x2b && chip.crc && chip.explicit_header && !chip.iq); - assert(!wrapper._rx_ps_armed && wrapper.state == STATE_IDLE); + assert(!wrapper._rx_ps_armed && wrapper.state == STATE_IDLE && !wrapper._rx_boosted_gain_applied); chip.fail_at = fail; wrapper._profile_repair_required = true; assert(!wrapper.restoreAfterDeepInit()); diff --git a/test/test_companion_ap_ownership.py b/test/test_companion_ap_ownership.py index 4fa789dc6..c82c86add 100644 --- a/test/test_companion_ap_ownership.py +++ b/test/test_companion_ap_ownership.py @@ -62,6 +62,9 @@ class CompanionAccessPointOwnershipTest(unittest.TestCase): self.run_fixture("reload") self.run_fixture("reload", webconfig=False) + def test_credential_reload_waits_for_webconfig_teardown_after_sdk_ap_is_gone(self): + self.run_fixture("transition_reload") + def test_starting_wifi_services_defers_without_consuming_requested_intent(self): self.run_fixture("start_wifi") self.run_fixture("start_wifi", webconfig=False) diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index cac51540d..e6644acb2 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -27,6 +27,7 @@ HARNESS = r''' #include #include #include +#include #include "regulator_cli_stub.h" #include #include @@ -132,6 +133,7 @@ struct Clock { uint32_t now = mesh::FleetCommand::MinEpoch + 1000; class MyMesh : public mesh::Mesh { public: struct NoMorseReply { void copy(const void*, const void*) {} } _morse_reply; + mesh::TempRadioReplyBarrier _primary_radio_reply; mesh::LocalIdentity self_id; Clock clock; ChannelDetails channels[MAX_GROUP_CHANNELS]; diff --git a/test/test_companion_wifi_session.py b/test/test_companion_wifi_session.py index 729e8042d..d2634af50 100644 --- a/test/test_companion_wifi_session.py +++ b/test/test_companion_wifi_session.py @@ -47,10 +47,10 @@ MultiSerialInterface interface_manager; constexpr int EXPECTED_ACK_TABLE_SIZE = 6; struct MyMesh { bool streaming = false; - int stream_cancels = 0, pending_cancels = 0, radio_cancels = 0; + int stream_cancels = 0, pending_cancels = 0; int trace_cancels = 0, signing_cancels = 0, expirations = 0; mesh::CompanionDelayedReplies _delayed_replies; - BaseSerialInterface* command_radio_reply_route = nullptr; + bool saved_radio_apply_pending = false; BaseSerialInterface* sign_data_reply_route = nullptr; BaseSerialInterface* private_key_backup_route = nullptr; unsigned long private_key_backup_deadline = 0; @@ -64,7 +64,6 @@ struct MyMesh { if (streaming) { streaming = false; interface_manager.unlockReplyRoute(); } } void clearPendingReqs() { ++pending_cancels; _delayed_replies.retireRequest(millis()); } - void cancelPendingRadioParamApply() { ++radio_cancels; command_radio_reply_route = nullptr; } void clearBinaryTraceReply() { ++trace_cancels; _delayed_replies.retireBinaryTrace(millis()); } void cancelSigningSession() { ++signing_cancels; sign_data_reply_route = nullptr; } void expireExpectedAcks() { ++expirations; } @@ -91,7 +90,7 @@ int main() { assert(the_mesh._delayed_replies.reserveRequest( mesh::CompanionDelayedReplies::Binary, peer, owner, false, millis())); the_mesh._delayed_replies.armRequest(17, 1000, false, millis()); - the_mesh.command_radio_reply_route = owner; + the_mesh.saved_radio_apply_pending = true; assert(the_mesh._delayed_replies.reserveBinaryTrace(17, 23, owner, millis())); the_mesh._delayed_replies.armBinaryTrace(1000, millis()); the_mesh.sign_data_reply_route = owner; @@ -111,12 +110,13 @@ int main() { assert(the_mesh.stream_cancels == int(cancelled)); assert(the_mesh.streaming == !cancelled); assert(the_mesh.pending_cancels == int(cancelled)); - assert(the_mesh.radio_cancels == int(cancelled)); assert(the_mesh.trace_cancels == int(cancelled)); assert(the_mesh.signing_cancels == int(cancelled)); assert(the_mesh._delayed_replies.request.route == (cancelled ? nullptr : owner)); assert(the_mesh._delayed_replies.hasRequest() == !cancelled); - assert(the_mesh.command_radio_reply_route == (cancelled ? nullptr : owner)); + // The radio setting was already saved and acknowledged. Losing a client + // session must not cancel its automatic hardware application. + assert(the_mesh.saved_radio_apply_pending); assert(the_mesh._delayed_replies.trace.route == (cancelled ? nullptr : owner)); assert(the_mesh._delayed_replies.hasBinaryTrace() == !cancelled); assert(the_mesh.sign_data_reply_route == (cancelled ? nullptr : owner)); diff --git a/test/test_esp32_usb_serial_hygiene.py b/test/test_esp32_usb_serial_hygiene.py index 0a24ca7e5..b510f6e4a 100644 --- a/test/test_esp32_usb_serial_hygiene.py +++ b/test/test_esp32_usb_serial_hygiene.py @@ -159,7 +159,10 @@ int main() { start = mesh.index("bool MyMesh::hasFiniteDelayedReplyForRoute(") body = mesh[start : mesh.index("void MyMesh::servicePendingSerialReply()", start)] self.assertIn("_delayed_replies.hasReplyForRoute(route, _ms->getMillis())", body) - self.assertIn("command_radio_reply_route == route", body) + # Radio settings acknowledge their saved value immediately; automatic + # background application must not prolong the USB reply lease. + self.assertNotIn("command_radio_reply_route", body) + self.assertNotIn("saved_radio_apply_pending", body) delayed = source("src/helpers/CompanionDelayedReplies.cpp") lease = delayed[delayed.index("bool CompanionDelayedReplies::hasReplyForRoute("):] self.assertIn("{ &request, &trace }", lease) diff --git a/test/test_espnow_radio_lifecycle.py b/test/test_espnow_radio_lifecycle.py index 663cd8078..1d3e8d89b 100644 --- a/test/test_espnow_radio_lifecycle.py +++ b/test/test_espnow_radio_lifecycle.py @@ -142,10 +142,9 @@ class EspNowLifecycleTests(unittest.TestCase): mqtt_switch = method(common, 'if (strncmp(config, "mqtt.enabled ", 13) == 0)') self.assertIn('enable ? _callbacks->setMqttBridgeState(true)', mqtt_switch) self.assertIn(': _callbacks->requestMqttBridgeStop()', mqtt_switch) - # Reject an unsafe restart before changing saved intent. An accepted - # OFF command saves successfully before signalling cooperative stop. - self.assertLess(mqtt_switch.index('isMqttBridgeStopping()'), - mqtt_switch.index('_prefs->bridge_enabled =')) + # Save desired state even during teardown; the role queues startup + # until stopping finishes. OFF still saves before cooperative stop. + self.assertNotIn('isMqttBridgeStopping()', mqtt_switch) self.assertLess(mqtt_switch.index('if (!trySavePrefs())'), mqtt_switch.index('setMqttBridgeState(true)')) self.assertLess(mqtt_switch.index('if (!trySavePrefs())'), @@ -161,6 +160,7 @@ class EspNowLifecycleTests(unittest.TestCase): espnow = method(header, 'bool setEspNowBridgeState(bool enable) override') self.assertIn('if (espnow_bridge.isRunning()) espnow_bridge.end();', mqtt) self.assertLess(mqtt.index('isStopping()'), mqtt.index('espnow_bridge.end()')) + self.assertIn('->isStartPending()', mqtt) self.assertIn('->requestStop()', stop) self.assertNotIn('->end()', stop) self.assertIn('if (mqtt_bridge' if role == 'simple_repeater' else 'if (bridge', espnow) diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 899f8efd3..d66956638 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -198,6 +198,7 @@ struct Bus { }; struct Panel { uint16_t displayWidth=0,displayHeight=0,displayBufferSize=0,_buffheight=0,_RGB=0xffff; + uint16_t _lastRGB=0xffff; OLEDDISPLAY_GEOMETRY geometry=GEOMETRY_RAWMODE; unsigned logBufferSize=0,logBufferFilled=0,logBufferLine=0,logBufferMaxLines=0; uint8_t* buffer=nullptr; uint8_t* buffer_back=nullptr; uint8_t* logBuffer=nullptr; diff --git a/test/test_logging_sleep_contract.py b/test/test_logging_sleep_contract.py index aa74b005f..2a4a79d80 100644 --- a/test/test_logging_sleep_contract.py +++ b/test/test_logging_sleep_contract.py @@ -44,8 +44,10 @@ struct MyMesh { const AbstractBridge* activeBridge() const { return bridge; } struct { bool pending = false; } deferred_cli_command; struct { + bool radio_settings_pending = false; bool hasActiveUserGpioTimer() const { return false; } bool hasPendingTxPower() const { return false; } + bool hasPendingRadioSettings() const { return radio_settings_pending; } } _cli; struct { int getNumClients() const { return 0; } } acl; struct { bool battery_alert_enabled = false; } _prefs; @@ -83,6 +85,11 @@ int main() { try { MyMesh node; require(node.getPowerSaveSleepSeconds(30) == 30, "quiet node cannot sleep"); + node._cli.radio_settings_pending = true; + require(node.hasPendingWork(), "pending radio settings lost their sleep blocker"); + require(node.getPowerSaveSleepSeconds(30) == 0, + "device sleep can interrupt pending radio settings"); + node._cli.radio_settings_pending = false; MQTTBridge logger; node.bridge = &logger; require(node.getPowerSaveSleepSeconds(30) == 30, "stopped MQTT blocked sleep"); diff --git a/test/test_meshtext_transport.py b/test/test_meshtext_transport.py index 625e535eb..b074e7427 100644 --- a/test/test_meshtext_transport.py +++ b/test/test_meshtext_transport.py @@ -325,6 +325,7 @@ struct RadioLibWrapper { bool supported=true; bool supportsMeshtext() const { return supported; } bool _rx_ps_armed=true,_params_valid=true,_dbm_valid=false,_dbm_applied=false,_rx_boosted_gain_valid=false; + bool _rx_boosted_gain_applied=true,gain_ok=true; int state=1;uint8_t _cur_sf=11,_cur_cr=5;float _cur_freq=906.875,_cur_bw=250; int8_t _cur_dbm=22;bool _cur_rx_boosted_gain=false; mesh::RadioProfileProtocol _physical_protocol=mesh::RadioProfileProtocol::Meshtext; @@ -333,7 +334,7 @@ struct RadioLibWrapper { bool applyParams(float,float,uint8_t,uint8_t) { return true; } uint16_t currentPreambleLength() { return 16; } int applyCachedTxPower(int8_t) { return 0; } - bool applyRxBoostedGainMode(bool) { return true; } + bool applyRxBoostedGainMode(bool) { return gain_ok; } void recalibrateNoiseFloor() {} @WIRE@ bool restoreAfterDeepInit(); @@ -353,8 +354,10 @@ int main() { assert(!invalid.applyProtocol(static_cast(99)) && invalid.chip.step==0); RadioLibWrapper w;assert(w.restoreAfterDeepInit()); assert(w.chip.sync==0x2b && w.chip.crc && w.chip.explicit_header && !w.chip.iq); - assert(!w._rx_ps_armed && w.state==STATE_IDLE); + assert(!w._rx_ps_armed && w.state==STATE_IDLE && !w._rx_boosted_gain_applied); w._physical_protocol=Protocol::MeshCore;assert(w.restoreAfterDeepInit());assert(w.chip.sync==0x12); + w._rx_boosted_gain_valid=true;assert(w.restoreAfterDeepInit() && w._rx_boosted_gain_applied); + w.gain_ok=false;assert(!w.restoreAfterDeepInit() && !w._rx_boosted_gain_applied); RadioLibWrapper disabled;disabled.supported=false; assert(disabled.applyProtocol(Protocol::MeshCore)); assert(!disabled.applyProtocol(Protocol::Meshtext) && disabled.chip.step==0); diff --git a/test/test_power_saving_role_diagnostics.py b/test/test_power_saving_role_diagnostics.py index d6956b4e8..12285fa6c 100644 --- a/test/test_power_saving_role_diagnostics.py +++ b/test/test_power_saving_role_diagnostics.py @@ -182,9 +182,9 @@ struct MyMesh { struct { bool busy_value = false; bool busy() const { return busy_value; } } _local_cli_output; struct { bool pending = false; } deferred_cli_command; struct { - bool gpio = false, tx_pending = false; + bool gpio = false, tx_pending = false, rx_pending = false; bool hasActiveUserGpioTimer() const { return gpio; } - bool hasPendingTxPower() const { return tx_pending; } + bool hasPendingRadioSettings() const { return tx_pending || rx_pending; } } _cli; struct { bool watchdog = false, calibration = false; bool isWatchdogObserving() const { return watchdog; } @@ -245,6 +245,7 @@ int main() { check(node._local_cli_output.busy_value, Reason::SerialIO); check(node.deferred_cli_command.pending, Reason::Scheduled); check(node._cli.tx_pending, Reason::Scheduled); + check(node._cli.rx_pending, Reason::Scheduled); check(node.uart.running, Reason::Bridge); node.active_rs232_bridge_uart = 1; assert(node.pendingWorkReason() == mask(Reason::BridgeCleanup)); diff --git a/test/test_radio_rx_settings_transaction.py b/test/test_radio_rx_settings_transaction.py index f003ba5b5..67414d71d 100644 --- a/test/test_radio_rx_settings_transaction.py +++ b/test/test_radio_rx_settings_transaction.py @@ -10,13 +10,13 @@ from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] -def preprocess(source): +def preprocess(source, defines=("USE_LR2021",)): return subprocess.run([os.environ.get("CXX", "g++"), "-E", "-P", "-x", "c++", - "-DUSE_LR2021", "-"], input=source, text=True, + *("-D" + value for value in defines), "-"], input=source, text=True, capture_output=True, check=True).stdout -def fixture(): +def fixture(defines=("USE_LR2021",)): source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii") methods = "\n".join(extract_braced(source, signature) for signature in ( @@ -24,18 +24,18 @@ def fixture(): "void CommonCLI::servicePendingRxSettings(", "bool CommonCLI::saveRxSetting(", "void CommonCLI::restoreRxSetting(", "bool CommonCLI::trySavePrefs(", "void CommonCLI::loop(", "bool CommonCLI::calculateRxPowerSavingLevel(")) - setter = preprocess(extract_braced(source, "void CommonCLI::handleSetCmd(")) + setter = preprocess(extract_braced(source, "void CommonCLI::handleSetCmd("), defines) branches = [extract_braced(setter, signature) for signature in ( 'if (memcmp(config, "radio.rxgain ", 13) == 0)', 'if (memcmp(config, "radio.rxps ", 11) == 0)', 'if (strncmp(config, "radio.rxps.rfrx_disabled ", 25) == 0)', 'if (strcmp(config, "extra.sf") == 0 || memcmp(config, "extra.sf ", 9) == 0)')] values = { - "METHODS": preprocess(methods), - "SETTERS": preprocess(" else ".join(branches)), + "METHODS": preprocess(methods, defines), + "SETTERS": preprocess(" else ".join(branches), defines), "ENUM": extract_braced(header, "enum RxSetting : uint8_t") + ";", "PENDING": extract_braced(header, "bool hasPendingRadioSettings() const"), - "NOTE": preprocess(extract_braced(source, "static void appendRxPowerSavingAdjustmentNote(")), + "NOTE": preprocess(extract_braced(source, "static void appendRxPowerSavingAdjustmentNote("), defines), } result = HARNESS for key, value in values.items(): @@ -313,10 +313,10 @@ int main() { class InfrastructureRxSettingsTests(unittest.TestCase): - def test_actual_commands_and_pending_transactions(self): + def compile_run(self, code): with tempfile.TemporaryDirectory(prefix="mesh-infra-rx-") as directory: source, exe = Path(directory) / "test.cpp", Path(directory) / "test" - source.write_text(fixture(), encoding="ascii") + source.write_text(code, encoding="ascii") built = subprocess.run([ os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", @@ -326,6 +326,96 @@ class InfrastructureRxSettingsTests(unittest.TestCase): result = subprocess.run([str(exe)], text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_actual_commands_and_pending_transactions(self): + self.compile_run(fixture()) + + def test_integrated_stm32_preserves_unsupported_errors_and_off_transactions(self): + code = fixture(("STM32_PLATFORM", "STM32WLxx", "USE_STM32WL")).split("static bool ok(", 1)[0] + code += r''' +int main() { + char reply[160]; + CommonCLI cli; + cli.callbacks.gain_supported = cli.callbacks.ps_supported = cli.callbacks.rf_supported = false; + for (const char* value : {"radio.rxgain on", "radio.rxgain off", + "radio.rxps.rfrx_disabled on", "radio.rxps.rfrx_disabled off"}) { + cli.command(value, reply); assert(!strcmp(reply, "Error: unsupported")); + } + for (const char* value : {"radio.rxgain banana", "radio.rxgain on ", + "radio.rxps.rfrx_disabled banana", "radio.rxps.rfrx_disabled off "}) { + cli.command(value, reply); assert(!strcmp(reply, "Error: state must be on or off")); + } + for (const char* value : {"radio.rxps on", "radio.rxps conservative", "radio.rxps balanced", + "radio.rxps level 8 preamble 32", "radio.rxps 2000 3000"}) { + cli.command(value, reply); assert(!strcmp(reply, "ERROR: RX powersaving unsupported")); + } + cli.command("radio.rxps banana", reply); assert(strstr(reply, "ERROR: use off|on|")); + assert(!cli.callbacks.gain_attempts && !cli.callbacks.rf_attempts + && !cli.callbacks.ps_attempts && !cli.callbacks.saves && !cli.hasPendingRadioSettings()); + // Off retains accepted timing/level intent, checked persistence and safe-gap + // application even though this integrated driver cannot enable duty cycling. + cli.prefs.rx_powersaving_enabled = 1; cli.prefs.rx_ps_rx_us = 2000; + cli.prefs.rx_ps_sleep_us = 3000; cli.prefs.rx_ps_level = 8; cli.prefs.rx_ps_preamble = 32; + cli.callbacks.hardware_ps = RxPowerSavingConfig(true, 2000, 3000, 8, 32); + cli.callbacks.transmitting = true; + cli.command("radio.rxps off", reply); + assert(!strcmp(reply, "OK - off,2000,3000; saved; pending")); + assert(!cli.prefs.rx_powersaving_enabled && cli.prefs.rx_ps_level == 8 + && cli.prefs.rx_ps_preamble == 32 && cli.callbacks.saves == 1); + assert(!cli.callbacks.persisted.rx_powersaving_enabled && cli.callbacks.hardware_ps.enabled); + assert(cli.hasPendingRadioSettings() && cli._rx_settings_pending == CommonCLI::RxPowerSaving); + cli.callbacks.transmitting = false; cli.servicePendingRxSettings(); + assert(!cli.hasPendingRadioSettings() && !cli.callbacks.hardware_ps.enabled); + assert(cli.callbacks.ps_attempts == 2 && cli.callbacks.saves == 1); + assert(!cli.callbacks.gain_attempts && !cli.callbacks.rf_attempts && !cli.callbacks.sf_attempts); + // A failed save restores the previous accepted values and pending deadline. + cli.callbacks.save_ok = false; cli.callbacks.transmitting = true; + cli._rx_settings_pending = CommonCLI::RxPowerSaving; cli._rx_settings_retry_at = 1234; + cli.command("radio.rxps off", reply); + assert(!strcmp(reply, "Error: RX save unconfirmed; reboot may differ")); + assert(cli._rx_settings_pending == CommonCLI::RxPowerSaving && cli._rx_settings_retry_at == 1234); + assert(cli.prefs.rx_ps_rx_us == 2000 && cli.prefs.rx_ps_sleep_us == 3000 + && cli.prefs.rx_ps_level == 8 && cli.prefs.rx_ps_preamble == 32); + cli.callbacks.transmitting = false; now_ms = 1234; cli.servicePendingRxSettings(); + assert(!cli.hasPendingRadioSettings()); + cli.callbacks.save_ok = true; + cli.callbacks.ps_results = {Result::FAILED, Result::APPLIED}; + const unsigned saves = cli.callbacks.saves; + cli.command("radio.rxps off", reply); + assert(!strcmp(reply, "Error: RX setting rejected") && cli.callbacks.saves == saves); + assert(!cli.hasPendingRadioSettings() && !cli.callbacks.hardware_ps.enabled); + // The existing Off path still normalizes absent timing values locally. + cli.prefs.rx_ps_rx_us = cli.prefs.rx_ps_sleep_us = 0; + cli.command("radio.rxps off", reply); + assert(!strncmp(reply, "OK - off,", 9) && cli.prefs.rx_ps_rx_us == RX_POWERSAVING_DEFAULT_RX_US + && cli.prefs.rx_ps_sleep_us == RX_POWERSAVING_DEFAULT_SLEEP_US); +} +''' + self.compile_run(code) + + def test_other_platforms_and_external_radio_keep_supported_runtime_controls(self): + for defines in (("STM32_PLATFORM",), ("STM32WLxx",), + ("STM32_PLATFORM", "STM32WLxx")): + with self.subTest(defines=defines): + code = fixture(defines).split("static bool ok(", 1)[0] + code += r''' +int main() { + CommonCLI cli; char reply[160]; + cli.command("radio.rxgain on", reply); + assert(!strcmp(reply, "OK") && cli.callbacks.hardware_gain && cli.callbacks.saves == 1); + cli.command("radio.rxps 2000 3000", reply); + assert(!strncmp(reply, "OK - on,", 8) && cli.callbacks.hardware_ps.enabled && cli.callbacks.saves == 2); + cli.command("radio.rxps.rfrx_disabled on", reply); + assert(strstr(reply, "OK - radio.rxps.rfrx_disabled on") && cli.callbacks.hardware_rf); +} +''' + self.compile_run(code) + + def test_stm32_fold_matches_the_integrated_driver_capabilities(self): + driver = (ROOT / "src/helpers/radiolib/CustomSTM32WLxWrapper.h").read_text(encoding="ascii") + self.assertNotRegex(driver, r"\b(?:supportsRxBoostedGainMode|supportsRxPowerSavingRfRxDisable|supportsRxPowerSaving)\s*\(") + self.assertNotRegex(driver, r"\bSupportsRxPowerSaving\b") + self.assertIn("#define USE_STM32WL", driver) + def test_pending_radio_settings_keep_all_infrastructure_roles_awake(self): for role, name in (("simple_repeater", "MyMesh"), ("simple_room_server", "MyMesh"), ("simple_sensor", "SensorMesh")): diff --git a/test/test_rs232_heartbeat_owner.py b/test/test_rs232_heartbeat_owner.py index 23cc2135f..8160d141d 100644 --- a/test/test_rs232_heartbeat_owner.py +++ b/test/test_rs232_heartbeat_owner.py @@ -145,8 +145,9 @@ struct MyMesh : Callbacks { } struct { bool pending = false; } deferred_cli_command; struct { + bool pending_radio_settings = false; bool hasActiveUserGpioTimer() const { return false; } - bool hasPendingTxPower() const { return false; } + bool hasPendingRadioSettings() const { return pending_radio_settings; } } _cli; struct { bool isWatchdogObserving() const { return false; } @@ -188,6 +189,13 @@ int main() { { MyMesh node; assert(node.getPowerSaveSleepSeconds(30) == 30); + // Deferred radio settings own an awake loop even without an active UART. + node._cli.pending_radio_settings = true; + assert(node.pendingWorkReason() == mesh::powerdiag::mask(mesh::powerdiag::Reason::Scheduled)); + assert(node.hasPendingWork() && node.getPowerSaveSleepMilliseconds(30000) == 0); + assert(node.getPowerSaveSleepSeconds(30) == 0); + node._cli.pending_radio_settings = false; + assert(!node.hasPendingWork() && node.getPowerSaveSleepSeconds(30) == 30); char status[161]; std::memset(status, '!', sizeof(status)); node.formatRs232Status(status); assert(status[160] == '!'); assert(std::strstr(status, "saved=off running=off")); diff --git a/test/test_rxps_hardware_capability.py b/test/test_rxps_hardware_capability.py index ac446d9b7..e3ce86cec 100644 --- a/test/test_rxps_hardware_capability.py +++ b/test/test_rxps_hardware_capability.py @@ -191,7 +191,7 @@ int main() { common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text() setter = body(common, 'if (memcmp(config, "radio.rxps ", 11) == 0)') if mutate: - guard = body(setter, 'if (!_callbacks->supportsRxPowerSaving()') + guard = body(setter, 'if (!powersaving_supported && parsed.mode') setter = setter.replace(guard, '', 1) code = HARNESS.replace('@SETTER@', setter) code = code.replace('@GETTER@', body(common, 'if (strcmp(config, "radio.rxps.config") == 0)')) diff --git a/test/test_transport_filter_wiring.py b/test/test_transport_filter_wiring.py index 7b56bc22d..477c43c51 100644 --- a/test/test_transport_filter_wiring.py +++ b/test/test_transport_filter_wiring.py @@ -419,14 +419,36 @@ int main() { subprocess.run([str(work/'test')], check=True) def test_roles_wire_filter_at_every_bridge_start_and_cross_hook(self): + # An on request during asynchronous MQTT teardown only retains start + # intent on the existing object. Its packet filter survives the stop; + # the fresh-start branches below must still configure admission first. + mqtt_begin = method((ROOT/'src/helpers/bridges/MQTTBridge.cpp').read_text(), + 'void MQTTBridge::begin(') + stop_guard = mqtt_begin.index('if (_initialized || _lifecycle.isStopInProgress())') + self.assertLess(mqtt_begin.index('_start_requested = true;'), stop_guard) + self.assertIn('return;', method(mqtt_begin[stop_guard:], + 'if (_initialized || _lifecycle.isStopInProgress())')) + self.assertNotIn('setPacketFilter(', mqtt_begin) + self.assertNotIn('_packet_filter', mqtt_begin) + deferred_start = re.compile( + r'if \((?:enable && )?(?Pmqtt_bridge|bridge) && ' + r'(?P=pointer)->isStopping\(\)\) \{\s*' + r'(?P=pointer)->begin\(\);\s*' + r'return (?P=pointer)->isStartPending\(\);\s*\}') for role in ['simple_repeater', 'simple_room_server']: header = (ROOT/f'examples/{role}/MyMesh.h').read_text() source = (ROOT/f'examples/{role}/MyMesh.cpp').read_text() + retained_starts = 0 for text in [header, source]: + for match in deferred_start.finditer(text): + self.assertIn('configureBridgeFilter(' + match['pointer'] + ');', header, role) + retained_starts += 1 + text = deferred_start.sub('', text) lines = text.splitlines() for index, line in enumerate(lines): if 'bridge->begin();' in line: self.assertIn('configureBridgeFilter(', lines[index-1], role) + self.assertEqual(retained_starts, 2, role) cross = method(header, 'bool allowRadioProfileCross(') self.assertIn('allowTransportPacket', cross) admission = method(source, 'bool MyMesh::allowTransportPacket(') diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index 2b47492c9..e7302adc9 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -79,6 +79,9 @@ build_flags = ${rak3x72.build_flags} -fno-reorder-functions -fno-inline-small-functions -fno-tree-dominator-opts ; preserve the 32 KiB filesystem with the stability guards + -fno-caller-saves + -fno-guess-branch-probability + --param=max-crossjump-edges=1000 ; share repeated CLI tails without cutting features or storage -fipa-pta -fmerge-all-constants -fno-ipa-cp-clone diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index 3e87ac693..f13376dae 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -86,6 +86,8 @@ build_flags = ${Tiny_Relay.build_flags} -fno-reorder-blocks -fno-reorder-functions -fno-caller-saves + -fno-guess-branch-probability + --param=max-crossjump-edges=1000 ; share repeated CLI tails without cutting features or storage -fipa-pta -fmerge-all-constants -fno-ipa-cp-clone diff --git a/variants/wio-e5-dev/platformio.ini b/variants/wio-e5-dev/platformio.ini index 378d25258..7f1d7cbed 100644 --- a/variants/wio-e5-dev/platformio.ini +++ b/variants/wio-e5-dev/platformio.ini @@ -105,6 +105,8 @@ build_flags = ${lora_e5.build_flags} -fno-reorder-blocks -fno-reorder-functions -fno-caller-saves + -fno-guess-branch-probability + --param=max-crossjump-edges=1000 ; share repeated CLI tails without cutting features or storage -fipa-pta -fmerge-all-constants -fno-ipa-cp-clone diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 40c0b4fb6..b3052c347 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -113,6 +113,8 @@ build_flags = ${lora_e5_mini.build_flags} -fno-inline-small-functions -fno-tree-dominator-opts ; save 160 release bytes while preserving the 32 KiB filesystem -fno-caller-saves ; avoid spill-code growth and retain the 32 KiB filesystem + -fno-guess-branch-probability + --param=max-crossjump-edges=1000 ; share repeated CLI tails without cutting features or storage -fipa-pta -fmerge-all-constants -fno-ipa-cp-clone