From 1f76b79fa4f360a5948dab657cdba704913f261f Mon Sep 17 00:00:00 2001 From: mjgodat Date: Thu, 29 Oct 2026 15:47:59 -0700 Subject: [PATCH] feat(presets): MESH_ENABLE_RADIO_PRESETS build switch (off on STM32 and ESP-NOW-only) Co-Authored-By: Claude Opus 5.5 --- docs/cli_command_availability.md | 6 +- docs/cli_commands.md | 6 + examples/companion_radio/MyMesh.cpp | 10 ++ examples/companion_radio/MyMesh.h | 7 + examples/simple_repeater/MyMesh.cpp | 2 + examples/simple_room_server/MyMesh.cpp | 2 + examples/simple_sensor/SensorMesh.cpp | 2 + src/helpers/CommonCLI.cpp | 10 ++ src/helpers/CommonCLI.h | 7 + src/helpers/RadioPresetCLI.cpp | 6 + src/helpers/RadioPresetConfig.h | 15 ++ test/fixtures/cli_reply_text.json | 6 +- test/fixtures/morse_reply_loop.cpp | 1 + test/test_local_cli_access.py | 1 + test/test_morse_loop_policy.py | 1 + test/test_radio_presets.py | 151 +++++++++++++++++- test/test_repeater_guest_access.py | 1 + test/test_room_login_integration.py | 1 + .../ui-cardputer/CardputerUITask.cpp | 14 ++ .../ui-cardputer/screen/MainScreen.cpp | 4 + 20 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 src/helpers/RadioPresetConfig.h diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index de3d53bb7..b120f6bc3 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -105,7 +105,7 @@ over the normal binary USB, BLE, or TCP connection: | [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 Companion terminal and command `0x42`; unidentifiable metadata returns `unknown` | | [`get/set sdcard`](test-results/companion_cli_parity_2026-10-23.md#shared-sd-commands) | nRF52 builds with SD OTA storage; maintenance retains OTA and Ethernet ownership guards | | `erase` | Local terminal and binary command `0x42`; reboot after erasing stored settings | -| [`get preset [n]`; `add preset`; `load preset`; `del preset`](cli_commands.md#save-and-switch-named-radio-presets) | Local terminal and binary command `0x42` only; LoRa CLI cannot use presets on a Companion. `load preset` saves and applies at once with no revert window | +| [`get preset [n]`; `add preset`; `load preset`; `del preset`](cli_commands.md#save-and-switch-named-radio-presets) | Local terminal and binary command `0x42` only; LoRa CLI cannot use presets on a Companion. `load preset` saves and applies at once with no revert window; omitted on STM32 and ESP-NOW-only builds (`MESH_ENABLE_RADIO_PRESETS`) | | `set freq ` | Local terminal, binary command `0x42`, and authorized LoRa CLI; applies automatically (infrastructure waits two seconds and for the reply to drain) | | [`get public.key`](cli_commands.md#view-this-nodes-public-key) | Companion text terminal and binary command `0x42`; read-only, no private-key export feature required | | `get prv.key` | Local terminal and binary command `0x42`; requires private key export enabled | @@ -173,7 +173,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Radio | [`get tx`; `set tx `](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply; busy changes apply automatically | Yes | Yes | Yes | | Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | | Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | -| Radio | [`get preset [n]`; `add preset`; `load preset`; `del preset`](cli_commands.md#save-and-switch-named-radio-presets) | Admin only remotely; remote `load preset` reverts after 10 min unless an admin logs in again | Yes | Yes | Yes | +| Radio | [`get preset [n]`; `add preset`; `load preset`; `del preset`](cli_commands.md#save-and-switch-named-radio-presets) | Admin only remotely; remote `load preset` reverts after 10 min unless an admin logs in again; omitted on STM32 and ESP-NOW-only builds (`MESH_ENABLE_RADIO_PRESETS`) | Yes | Yes | Yes | | Radio | [`get freq`; `set freq `](cli_commands.md#view-or-change-this-nodes-frequency) | Local or authorized LoRa CLI | Yes | Yes | Yes | | Radio | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Supported radio | Feature | Feature | Feature | | Radio | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Controllable LoRa FEM | Feature | Feature | Feature | @@ -354,7 +354,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Radio | [`get tx`; `set tx `](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply; busy changes apply automatically | Yes | Yes | Yes | Yes | Yes | | Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes | | Radio | [`get/set/del radioat`; `get/set/del tempradioat`](cli_commands.md#schedule-radio-parameter-changes) | Full parser | Yes | Yes | Yes | Yes | Yes | -| Radio | [`get preset [n]`; `add preset`; `load preset`; `del preset`](cli_commands.md#save-and-switch-named-radio-presets) | Admin only remotely; remote `load preset` reverts after 10 min unless an admin logs in again | Yes | Yes | Yes | Yes | Yes | +| Radio | [`get preset [n]`; `add preset`; `load preset`; `del preset`](cli_commands.md#save-and-switch-named-radio-presets) | Admin only remotely; remote `load preset` reverts after 10 min unless an admin logs in again; omitted on STM32 and ESP-NOW-only builds (`MESH_ENABLE_RADIO_PRESETS`) | Yes | Yes | Yes | Yes | Yes | | Radio | [`get freq`; `set freq `](cli_commands.md#view-or-change-this-nodes-frequency) | Local or authorized LoRa CLI | Yes | Yes | Yes | Yes | Yes | | Radio | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Supported radio | Feature | Feature | Feature | Feature | Feature | | Radio | [`get/set espnow.channel`](cli_commands.md#view-or-change-the-primary-esp-nowwifi-channel) | Primary ESP-NOW mesh radio | Feature | Feature | Feature | Feature | Feature | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index db25f55cd..fee3c0e5e 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2206,6 +2206,12 @@ remote load is refused. A remote load is also refused while a TempRadio session is active. If the revert itself fails, it retries every minute and at every boot. +**Build switch:** `MESH_ENABLE_RADIO_PRESETS`. Flash-constrained STM32WL builds +(such as `wio-e5-mini_repeater`) and ESP-NOW-only builds (`MESH_PRIMARY_ESPNOW` +without a LoRa primary radio) omit radio presets by default; there the preset +commands return the ordinary unknown-command reply. Other builds include them; +set `-D MESH_ENABLE_RADIO_PRESETS=0` or `=1` to override. + --- #### View or change this node's transmit power diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8d49c2221..2a09251aa 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -31,7 +31,9 @@ #include #include "NotificationSettingsFile.h" #include "MyMesh.h" +#if MESH_ENABLE_RADIO_PRESETS #include +#endif #include #if defined(RAK_4631) && defined(COMPANION_RADIO_FULL) \ || (defined(STM32_PLATFORM) && defined(STM32WLxx) && COMPANION_FEATURE_TEXT_TERMINAL) @@ -3461,6 +3463,7 @@ void MyMesh::begin(bool has_display, bool radio_available, _clock_sync.begin(nullptr); #endif +#if MESH_ENABLE_RADIO_PRESETS // After prefs are loaded, HIL-adjusted and sanitised. /presets is shared with // infrastructure roles, so a node reflashed to Companion can carry a pending // remote load; begin() reverts it through presetApply, which saves _prefs. @@ -3471,6 +3474,7 @@ void MyMesh::begin(bool has_display, bool radio_available, }; _radio_presets.begin(preset_hooks); } +#endif configureRadioFromPrefs(); #if MESH_ENABLE_HOST_KISS mesh::hostkiss::begin(_store->getPrimaryFS(), *_radio, _radio_profiles, *this); @@ -3687,6 +3691,7 @@ MyMesh::RadioSettingResult MyMesh::applyAndSaveRadioParams( return preamble_restored ? RadioSettingResult::SaveFailed : RadioSettingResult::SaveUnconfirmed; } +#if MESH_ENABLE_RADIO_PRESETS bool MyMesh::presetLoad(void* ctx, uint8_t* buf, size_t cap, size_t& len) { MyMesh* self = static_cast(ctx); return mesh::loadRadioPresetFile(self->_store->getPrimaryFS(), buf, cap, len); @@ -3734,6 +3739,7 @@ bool MyMesh::presetTempRadioActive(void* ctx) { return false; #endif } +#endif bool MyMesh::applyRxPowerSavingRfRxDisabled(const char* value, char* reply, size_t capacity) { if (strcmp(value, "on") && strcmp(value, "off")) { @@ -11380,10 +11386,12 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, if (mesh::handleMorseReplyCommand(_morse_reply, _radio, command, reply, reply_capacity, millis())) return true; if (mesh::handleMorseCommand(_radio, command, reply, reply_capacity)) return true; +#if MESH_ENABLE_RADIO_PRESETS // Presets are local-only on companions: there is no revert path, so an // authorized LoRa CLI command (nonzero timestamp) is not consumed here. if (sender_timestamp == 0 && _radio_presets.handle(command, reply, reply_capacity, false)) return true; +#endif if (_radio_profiles.handle(command, reply, reply_capacity, sender_timestamp != 0)) return true; if (!strncmp(command, "set tempradio ", 14)) command += 4; if (!strcmp(command, "get tempradio")) command += 4; @@ -11998,7 +12006,9 @@ void MyMesh::loop() { #endif #endif +#if MESH_ENABLE_RADIO_PRESETS _radio_presets.loop(millis()); +#endif if (!mesh::morseTimingActive(_radio)) _radio_profiles.loop(); #if MESH_ENABLE_HOST_KISS if (!mesh::morseTimingActive(_radio)) mesh::hostkiss::loop(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 0aaf47680..41cf262eb 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -29,7 +29,10 @@ #include "DataStore.h" #include +#include +#if MESH_ENABLE_RADIO_PRESETS #include +#endif #include #include #include @@ -224,12 +227,14 @@ public: return handleCommand(command, 0, reply); } +#if MESH_ENABLE_RADIO_PRESETS // Named primary-radio presets (local operator only; Cardputer UI uses these). bool loadRadioPreset(uint8_t slot, char* reply, size_t cap) { return _radio_presets.loadSlot(slot, reply, cap); } uint8_t activeRadioPreset() const { return _radio_presets.activeSlot(); } const mesh::RadioPresetTable& radioPresetTable() const { return _radio_presets.table(); } +#endif void begin(bool has_display, bool radio_available = true, mesh::ui::StartupScreen* startup_screen = nullptr); @@ -869,6 +874,7 @@ private: DataStore* _store; mesh::RadioProfileCLI _radio_profiles; +#if MESH_ENABLE_RADIO_PRESETS // Hooks are captureless trampolines with this MyMesh as ctx; /presets lives // on the primary filesystem. mesh::RadioPresetCLI _radio_presets; @@ -879,6 +885,7 @@ private: uint16_t preamble); static bool presetApply(void* ctx, const mesh::RadioPreset& p, char* reply, size_t cap); static bool presetTempRadioActive(void* ctx); +#endif #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) mesh::cli::SdCardCLIState _sdcard_cli; #endif diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 83d18349f..38416cf16 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2845,10 +2845,12 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m reply_path_len = 0xFF; if (data[4] == 0 || data[4] >= ' ') { // is password, ie. a login request reply_len = handleLoginReq(sender, secret, timestamp, &data[4], packet->isRouteFlood()); +#if MESH_ENABLE_RADIO_PRESETS // A fresh full-admin login (LoRa or ESP-NOW bridge) proves the operator // can still reach this node: keep a remotely loaded preset. if (reply_len > 4 && reply_data[4] == RESP_SERVER_LOGIN_OK && login_full_admin) _cli.radioPresets().onAdminLogin(); +#endif } else if (data[4] == ANON_REQ_TYPE_REGIONS && packet->isRouteDirect()) { reply_len = handleAnonRegionsReq(sender, timestamp, &data[5], len - 5); } else if (data[4] == ANON_REQ_TYPE_OWNER && packet->isRouteDirect()) { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index c9cb8a045..c21817033 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1961,8 +1961,10 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m reply_data[7] = client->permissions; // NEW getRNG()->random(&reply_data[8], 4); // random blob to help packet-hash uniqueness reply_data[12] = FIRMWARE_VER_LEVEL; // New field +#if MESH_ENABLE_RADIO_PRESETS // A fresh strict-admin login keeps a remotely loaded radio preset. if (client->isAdmin()) _cli.radioPresets().onAdminLogin(); +#endif next_push = futureMillis(PUSH_NOTIFY_DELAY_MILLIS); // delay next push, give RESPONSE packet time to arrive first diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 960e98107..e099df08e 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -650,8 +650,10 @@ uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* getRNG()->random(&reply_data[8], 4); // random blob to help packet-hash uniqueness reply_data[12] = FIRMWARE_VER_LEVEL; +#if MESH_ENABLE_RADIO_PRESETS // A fresh strict-admin login keeps a remotely loaded radio preset. if (client->isAdmin()) _cli.radioPresets().onAdminLogin(); +#endif return 13; // reply length } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 9b0abdbe3..7ada7bc96 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -74,7 +74,9 @@ #elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) #include "ContactFileTransaction.h" #endif +#if MESH_ENABLE_RADIO_PRESETS #include "RadioPresetFile.h" +#endif #include #include #include @@ -250,7 +252,9 @@ void CommonCLI::loop() { servicePendingTxPower(); servicePendingRxSettings(); _radio_profiles.loop(); +#if MESH_ENABLE_RADIO_PRESETS _radio_presets.loop(millis()); +#endif #if MESH_ENABLE_HOST_KISS mesh::hostkiss::loop(); #endif @@ -290,6 +294,7 @@ bool CommonCLI::applyAndSavePrimaryRadioParams(float freq, float bw, uint8_t sf, return true; } +#if MESH_ENABLE_RADIO_PRESETS bool CommonCLI::presetLoad(void* ctx, uint8_t* buf, size_t cap, size_t& len) { return mesh::loadRadioPresetFile(static_cast(ctx)->_presets_fs, buf, cap, len); } @@ -327,6 +332,7 @@ bool CommonCLI::presetApply(void* ctx, const mesh::RadioPreset& p, char* reply, bool CommonCLI::presetTempRadioActive(void* ctx) { return static_cast(ctx)->_callbacks->isTempRadioActive(); } +#endif bool CommonCLI::primaryRadioApplyReady(bool reply_idle) { if (_primary_radio_apply_at @@ -1366,6 +1372,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { #endif _radio_profiles.adoptPrimaryPreamble(_prefs->primary_radio_preamble); _radio_profiles.stagePrimary(_prefs->primary_radio_preamble, false); +#if MESH_ENABLE_RADIO_PRESETS // After prefs are final. A preset left pending by a reboot is reverted here // through presetApply: that saves the previous tuple into _prefs, so the // role's boot-time radio apply (which runs after loadPrefs) starts on it. @@ -1375,6 +1382,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { presetTempRadioActive }; _radio_presets.begin(preset_hooks); +#endif mesh::hilStartupTrace("prefs_apply_ready"); } @@ -3731,8 +3739,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re command, reply, 160 - 3, millis())) return; if (mesh::handleMorseCommand(_callbacks->getProfileRadio(), command, reply, 160 - 3)) return; #endif +#if MESH_ENABLE_RADIO_PRESETS // Remote (sender_timestamp != 0) loads are provisional until a fresh admin login. if (_radio_presets.handle(command, reply, 160, sender_timestamp != 0)) return; +#endif if (_radio_profiles.handle(command, reply, 160, sender_timestamp != 0)) return; #if defined(ENABLE_OTA) const auto* ota_context = mesh::ota::ota_context_if_active(); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 41b4ab843..f27673854 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -15,7 +15,10 @@ #include #include #include +#include +#if MESH_ENABLE_RADIO_PRESETS #include +#endif #include #include #include @@ -1025,6 +1028,7 @@ class CommonCLI { uint32_t _rx_settings_retry_at = 0; uint32_t _prefs_save_failures = 0; mesh::RadioProfileCLI _radio_profiles; +#if MESH_ENABLE_RADIO_PRESETS // Named primary-radio presets. Hooks are captureless trampolines with this // CommonCLI as ctx; storage uses the filesystem handed to loadPrefs(). mesh::RadioPresetCLI _radio_presets; @@ -1036,6 +1040,7 @@ class CommonCLI { uint16_t preamble); static bool presetApply(void* ctx, const mesh::RadioPreset& p, char* reply, size_t cap); static bool presetTempRadioActive(void* ctx); +#endif #if MESH_ENABLE_TX_BEACONS mesh::BeaconManager* _beacons = nullptr; void beginBeacons(); @@ -1120,8 +1125,10 @@ public: uint16_t preamble); mesh::RadioProfileCLI& radioProfiles() { return _radio_profiles; } const mesh::RadioProfileCLI& radioProfiles() const { return _radio_profiles; } +#if MESH_ENABLE_RADIO_PRESETS mesh::RadioPresetCLI& radioPresets() { return _radio_presets; } const mesh::RadioPresetCLI& radioPresets() const { return _radio_presets; } +#endif // Bind only after the role has loaded its saved regions, so boot-time // radio2 scope reconciliation does not get replaced by the region image. void onRegionsLoaded(FILESYSTEM* fs, bool loaded); diff --git a/src/helpers/RadioPresetCLI.cpp b/src/helpers/RadioPresetCLI.cpp index 1838cf6ee..228eb00a2 100644 --- a/src/helpers/RadioPresetCLI.cpp +++ b/src/helpers/RadioPresetCLI.cpp @@ -1,3 +1,7 @@ +#include + +#if MESH_ENABLE_RADIO_PRESETS + #include "RadioPresetCLI.h" #include @@ -339,3 +343,5 @@ void RadioPresetCLI::loop(uint32_t now_ms) { } } // namespace mesh + +#endif // MESH_ENABLE_RADIO_PRESETS diff --git a/src/helpers/RadioPresetConfig.h b/src/helpers/RadioPresetConfig.h new file mode 100644 index 000000000..5f9dd378f --- /dev/null +++ b/src/helpers/RadioPresetConfig.h @@ -0,0 +1,15 @@ +#pragma once + +// Build switch for named primary-radio presets ("get/set/load/save preset"). +// Define MESH_ENABLE_RADIO_PRESETS=0/1 in build_flags to override the default. +#ifndef MESH_ENABLE_RADIO_PRESETS +#if defined(STM32_PLATFORM) +// STM32WL images have too little application flash (wio-e5-mini repeater overflows). +#define MESH_ENABLE_RADIO_PRESETS 0 +#elif defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW +// ESP-NOW-only builds have no LoRa primary radio for a preset to switch. +#define MESH_ENABLE_RADIO_PRESETS 0 +#else +#define MESH_ENABLE_RADIO_PRESETS 1 +#endif +#endif diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 31d3c36c9..897ab0b73 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1406,15 +1406,15 @@ } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "2f6da7a2ebd16fc45df8dff27bcc6ef2eef53fd554bc54f9d6cbb8c62c5cf619", - "examples/simple_repeater/MyMesh.cpp": "16f50f6a222042004731b4776e321c30d10bd7715bba56707beded1095a3ed2b", + "src/helpers/CommonCLI.cpp": "c335e314f95fc9f7406a96c397ada99e2edf75f361ad55fd6076886901c87688", + "examples/simple_repeater/MyMesh.cpp": "1d0341dd5206ff824502cb8916a0d3961569505349c70a3aacfe4f67c1f7a4b1", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", "src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000", "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18", - "examples/companion_radio/MyMesh.cpp": "44f6bc257edbd90c0534389d4c01c933d7b7b0cf502c9d51cdbf2aace8b397e9", + "examples/companion_radio/MyMesh.cpp": "0604e9a24c24b973987b5719cf21a2f5ac9795db20bc53fbd4355ceaa1a22ded", "src/helpers/CommonRadioPrefs.cpp": "16b1fb499426abde83566b6481df89b80302c72e00f0f4aa3843b6c9b9de4218", "examples/companion_radio/CompanionRetry.h": "e0ae10c7b2f778346f4645c4e147d87e1fbc7f8b99484752a8eed914bb21380f", "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873" diff --git a/test/fixtures/morse_reply_loop.cpp b/test/fixtures/morse_reply_loop.cpp index bd2663f71..7330aa74a 100644 --- a/test/fixtures/morse_reply_loop.cpp +++ b/test/fixtures/morse_reply_loop.cpp @@ -1,4 +1,5 @@ #include +#include static uint32_t now_ms = 100; uint32_t millis() { return now_ms; } #define PUB_KEY_SIZE 32 diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index 522b5f087..90408114f 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -14,6 +14,7 @@ SANITIZER_FLAGS = [] if os.name == "nt" else ["-fsanitize=address,undefined", "- PREAMBLE = r''' #include #include +#include #include #include #include diff --git a/test/test_morse_loop_policy.py b/test/test_morse_loop_policy.py index 67a0b3280..989f203e3 100644 --- a/test/test_morse_loop_policy.py +++ b/test/test_morse_loop_policy.py @@ -152,6 +152,7 @@ bool checkRadioReference() { @EXPRESSION@ return morse_timing; } code = FIXTURE.replace("@REDRAW@", "").replace("@CUTOFF@", "") + r''' #define MESH_ENABLE_MORSE_RX 0 #define USER_GPIO_CONTROL 1 +#include struct UserGpio { struct Completion { uint8_t pin=4,state=0; uint32_t request_id=99; }; unsigned loops=0; bool pending=true; diff --git a/test/test_radio_presets.py b/test/test_radio_presets.py index 7dde31747..5bcd505d4 100644 --- a/test/test_radio_presets.py +++ b/test/test_radio_presets.py @@ -588,7 +588,8 @@ class RadioPresetWiringContractTest(unittest.TestCase): self.assertSourceMatches(login, r'login_full_admin\s*=\s*client->isAdmin\(\)\s*;\s*\n\s*return 13;') self.assertSourceMatches( cpp, - r'reply_len\s*=\s*handleLoginReq\([^;]*;\s*\n(?:\s*//[^\n]*\n)*\s*if\s*\(reply_len\s*>\s*4\s*' + r'reply_len\s*=\s*handleLoginReq\([^;]*;\s*\n(?:#if MESH_ENABLE_RADIO_PRESETS\n)?' + r'(?:\s*//[^\n]*\n)*\s*if\s*\(reply_len\s*>\s*4\s*' r'&&\s*reply_data\[4\]\s*==\s*RESP_SERVER_LOGIN_OK\s*&&\s*login_full_admin\)\s*\n?\s*' r'_cli\.radioPresets\(\)\.onAdminLogin\(\);') self.assertEqual(cpp.count('onAdminLogin()'), 1) @@ -609,7 +610,8 @@ class RadioPresetWiringContractTest(unittest.TestCase): login = _function_body(sensor, 'uint8_t SensorMesh::handleLoginReq(') self.assertIsNotNone(login) self.assertSourceMatches(login, r'RESP_SERVER_LOGIN_OK[\s\S]*if \(client->isAdmin\(\)\) ' - r'_cli\.radioPresets\(\)\.onAdminLogin\(\);\s*\n\s*return 13;') + r'_cli\.radioPresets\(\)\.onAdminLogin\(\);\s*\n' + r'#endif\s*\n\s*return 13;') self.assertEqual(sensor.count('onAdminLogin()'), 1) docs = _read('docs/cli_commands.md') self.assertNotIn('remote load always reverts', docs) @@ -734,7 +736,9 @@ class CompanionPresetContractTest(unittest.TestCase): depth_guards.pop() elif t == '#include ': found = True - self.assertEqual(depth_guards, [], 'RadioPresetFile.h include is guarded') + # Only the feature switch may guard it, never a platform. + self.assertEqual(depth_guards, ['#if MESH_ENABLE_RADIO_PRESETS'], + 'RadioPresetFile.h include is platform-guarded') self.assertTrue(found, 'companion MyMesh.cpp must include RadioPresetFile.h') def test_docs_list_companion_presets(self): @@ -745,5 +749,146 @@ class CompanionPresetContractTest(unittest.TestCase): self.assertIn('load preset', section) +CONFIG_PROBE = r''' +#include +#include +int main() { std::printf("presets=%d\n", (int)MESH_ENABLE_RADIO_PRESETS); return 0; } +''' + +# Tokens that only exist for the presets feature. With the switch off none may +# survive preprocessing in these files (RadioPresetConfig.h itself is allowed). +_PRESET_TOKENS = re.compile( + r'_radio_presets|radioPresets\(\)|RadioPreset(?!Config)|preset_hooks' + r'|preset(?:Load|Save|Current|Accepts|Apply|TempRadioActive)\b' + r'|loadRadioPreset|activeRadioPreset|radioPresetTable|_presets_fs|onAdminLogin') + +_GATED_FILES = ( + 'src/helpers/CommonCLI.h', + 'src/helpers/CommonCLI.cpp', + 'src/helpers/RadioPresetCLI.cpp', + 'examples/companion_radio/MyMesh.h', + 'examples/companion_radio/MyMesh.cpp', + 'examples/simple_repeater/MyMesh.cpp', + 'examples/simple_room_server/MyMesh.cpp', + 'examples/simple_sensor/SensorMesh.cpp', + 'variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp', + 'variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp', +) + + +def _lines_live_when_disabled(text): + """Yield (lineno, line) for lines kept with MESH_ENABLE_RADIO_PRESETS=0. + + Only the switch's own #if/#if ! blocks are evaluated; every other + conditional is treated as possibly live, which is the conservative choice. + """ + stack = [] # per #if: None (other condition) or bool (switch block live?) + for n, line in enumerate(text.splitlines(), 1): + t = line.strip() + if t.startswith('#if'): + if re.match(r'#if\s+MESH_ENABLE_RADIO_PRESETS\b', t): + stack.append(False) + elif re.match(r'#if\s+!\s*MESH_ENABLE_RADIO_PRESETS\b', t): + stack.append(True) + else: + stack.append(None) + continue + if t.startswith('#else') and stack: + if stack[-1] is not None: + stack[-1] = not stack[-1] + continue + if t.startswith('#elif') and stack: + continue + if t.startswith('#endif') and stack: + stack.pop() + continue + if any(s is False for s in stack): + continue + yield n, line + + +class RadioPresetBuildSwitchTest(unittest.TestCase): + """MESH_ENABLE_RADIO_PRESETS: off on STM32 and ESP-NOW-only, on elsewhere.""" + + def probe(self, *defines): + compiler = shutil.which('g++') or shutil.which('clang++') + if compiler is None: + self.skipTest('a host C++17 compiler is required') + with tempfile.TemporaryDirectory(prefix='radio-preset-switch-') as directory: + work = Path(directory) + cpp = work / 'probe.cpp' + cpp.write_text(CONFIG_PROBE, encoding='ascii') + binary = work / ('probe' + ('.exe' if os.name == 'nt' else '')) + built = subprocess.run([ + compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', *defines, + '-I', str(ROOT / 'src'), str(cpp), '-o', str(binary), + ], capture_output=True, text=True, timeout=120) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + return int(ran.stdout.strip().split('=')[1]) + + def test_default_resolution(self): + self.assertEqual(self.probe(), 1) + self.assertEqual(self.probe('-DESP32=1'), 1) + self.assertEqual(self.probe('-DNRF52_PLATFORM=1'), 1) + self.assertEqual(self.probe('-DSTM32_PLATFORM'), 0) + self.assertEqual(self.probe('-DSTM32_PLATFORM=1'), 0) + self.assertEqual(self.probe('-DMESH_PRIMARY_ESPNOW=1'), 0) + self.assertEqual(self.probe('-DESP32=1', '-DMESH_PRIMARY_ESPNOW=1'), 0) + self.assertEqual(self.probe('-DMESH_PRIMARY_ESPNOW=0'), 1) + + def test_explicit_override_wins(self): + self.assertEqual(self.probe('-DSTM32_PLATFORM', '-DMESH_ENABLE_RADIO_PRESETS=1'), 1) + self.assertEqual(self.probe('-DMESH_PRIMARY_ESPNOW=1', '-DMESH_ENABLE_RADIO_PRESETS=1'), 1) + self.assertEqual(self.probe('-DMESH_ENABLE_RADIO_PRESETS=0'), 0) + + def test_preset_cli_translation_unit_compiles_empty_when_disabled(self): + # The src glob still builds RadioPresetCLI.cpp; disabled it must be an + # empty, warning-free object so nothing preset-related links. + compiler = shutil.which('g++') or shutil.which('clang++') + if compiler is None: + self.skipTest('a host C++17 compiler is required') + with tempfile.TemporaryDirectory(prefix='radio-preset-off-') as directory: + obj = Path(directory) / 'off.o' + built = subprocess.run([ + compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', '-c', + '-DMESH_ENABLE_RADIO_PRESETS=0', '-DNAME_MAX=255', + '-I', str(ROOT / 'src'), '-I', str(ROOT / 'test'), + '-isystem', str(ROOT / 'test' / 'mocks'), + str(ROOT / 'src/helpers/RadioPresetCLI.cpp'), '-o', str(obj), + ], capture_output=True, text=True, timeout=120, cwd=str(ROOT / 'test')) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + pre = subprocess.run([ + compiler, '-std=c++17', '-E', '-P', '-DMESH_ENABLE_RADIO_PRESETS=0', + '-I', str(ROOT / 'src'), str(ROOT / 'src/helpers/RadioPresetCLI.cpp'), + ], capture_output=True, text=True, timeout=120) + self.assertEqual(pre.returncode, 0, pre.stderr) + self.assertNotIn('RadioPresetCLI', pre.stdout) + + def test_every_preset_use_is_behind_the_switch(self): + for rel in _GATED_FILES: + text = _read(rel) + self.assertIn('MESH_ENABLE_RADIO_PRESETS', text, rel) + leaks = ['%s:%d: %s' % (rel, n, line.strip()) + for n, line in _lines_live_when_disabled(text) + if _PRESET_TOKENS.search(line)] + self.assertEqual(leaks, [], 'preset code outside MESH_ENABLE_RADIO_PRESETS') + + def test_headers_pull_in_the_switch(self): + for rel in ('src/helpers/CommonCLI.h', 'examples/companion_radio/MyMesh.h'): + text = _read(rel) + inc = text.find('#include ') + self.assertGreaterEqual(inc, 0, rel) + self.assertLess(inc, text.find('#if MESH_ENABLE_RADIO_PRESETS'), rel) + + def test_docs_mention_the_switch(self): + docs = _read('docs/cli_commands.md') + self.assertIn('MESH_ENABLE_RADIO_PRESETS', docs) + self.assertIn('ESP-NOW-only', docs) + avail = _read('docs/cli_command_availability.md') + self.assertGreaterEqual(avail.count('MESH_ENABLE_RADIO_PRESETS'), 3) + + if __name__ == '__main__': unittest.main() diff --git a/test/test_repeater_guest_access.py b/test/test_repeater_guest_access.py index bb0efe62c..46c49779d 100644 --- a/test/test_repeater_guest_access.py +++ b/test/test_repeater_guest_access.py @@ -31,6 +31,7 @@ HARNESS = r''' #include #include #include +#include #define PUB_KEY_SIZE 32 #define MAX_PATH_SIZE 64 #define MAX_PACKET_PAYLOAD 184 diff --git a/test/test_room_login_integration.py b/test/test_room_login_integration.py index 2ba62740a..944ef01f9 100644 --- a/test/test_room_login_integration.py +++ b/test/test_room_login_integration.py @@ -23,6 +23,7 @@ HARNESS = r''' #include #include "filesystem.h" #include +#include #include #include #include diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp index c52fac61d..3ad54a476 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/CardputerUITask.cpp @@ -11,7 +11,10 @@ #include #include +#include +#if MESH_ENABLE_RADIO_PRESETS #include +#endif void CardputerUITask::begin(DisplayDriver *display, SensorManager *sensors, CompanionNodePrefs *node_prefs, CustomNodePrefs *custom_prefs) { @@ -488,6 +491,16 @@ static void formatTuple(char *out, size_t cap, const char *name, float freq, flo // slot 1..5 loads and shows that preset; slot 0 shows the active preset, or "custom" and the live radio. void CardputerUITask::showPresetAlert(uint8_t slot) { char buf[80]; +#if !MESH_ENABLE_RADIO_PRESETS + // Built without presets (MESH_ENABLE_RADIO_PRESETS=0): Fn+0 still shows the live radio. + if (slot != 0) { + showAlert("Presets not in this build", 2000); + return; + } + formatTuple(buf, sizeof(buf), "custom", _node_prefs->freq, _node_prefs->bw, _node_prefs->sf, + _node_prefs->cr, 0); + showAlert(buf, 3000); +#else const mesh::RadioPreset *p = nullptr; if (slot == 0) { uint8_t active = the_mesh_cp.activeRadioPreset(); @@ -515,6 +528,7 @@ void CardputerUITask::showPresetAlert(uint8_t slot) { } formatTuple(buf, sizeof(buf), p->name, p->freq, p->bw, p->sf, p->cr, p->preamble); showAlert(buf, 3000); +#endif } void CardputerUITask::pingLastRepeater() { diff --git a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp index aff44b688..a11a461de 100644 --- a/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp +++ b/variants/m5stack_cardputer_adv/companion_radio/ui-cardputer/screen/MainScreen.cpp @@ -1,5 +1,7 @@ #include "MainScreen.h" +#include + #include "helpers.h" #include "main/ChannelsPage.h" #include "main/ChatPage.h" @@ -56,6 +58,7 @@ void MainScreen::renderStatusIcons(CardputerDisplay &lcd) { lcd.setColor(CardputerDisplay::P_BLACK); lcd.drawTextCentered(iconX + 2 + iconWidth / 2 - 1, 0, tmp); +#if MESH_ENABLE_RADIO_PRESETS // active radio preset name (or "custom") just left of the battery icon { uint8_t active = the_mesh_cp.activeRadioPreset(); @@ -63,6 +66,7 @@ void MainScreen::renderStatusIcons(CardputerDisplay &lcd) { lcd.setColor(CardputerDisplay::P_GREEN); lcd.drawTextRightAlign(iconX - 4, 0, preset ? preset->name : "custom"); } +#endif iconX = 3; iconY = 5;