From c9de5c835005cd90b4b6217089421e757fd0c8a1 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 23 Oct 2026 11:04:11 -0700 Subject: [PATCH] Omit unsupported tuning controls on integrated STM32WL radios --- src/helpers/RadioProfileCLI.cpp | 10 ++++ src/helpers/RadioProfileCLI.h | 8 +++ src/helpers/RadioSwitchTuning.h | 11 ++++ src/helpers/radiolib/RadioLibWrappers.cpp | 4 +- src/helpers/radiolib/RadioLibWrappers.h | 4 ++ .../switch_tuning_unsupported_test.cpp | 37 ++++++++++++ test/test_radio_switch_tuning_cli.py | 59 +++++++++++++++++++ 7 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/radio_profiles/switch_tuning_unsupported_test.cpp diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 87adbbc2e..5bb9806c6 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -36,9 +36,11 @@ const char* const TempPath = "/radio_profiles.tmp"; // Never considered a boot-time committed image. Losing power before the // acknowledgement leaves the previous saved radio untouched. const char* const ReplyPath = "/radio_profiles.reply"; +#if MESH_RADIO_SWITCH_TUNING const char* const FastPath = "/radio_fast"; const char* const FastBackup = "/radio_fast.bak"; const char* const FastTemp = "/radio_fast.tmp"; +#endif uint32_t checksum(const uint8_t* p, size_t n) { return storage::updateCRC32(0xffffffffU, p, n); } @@ -223,6 +225,7 @@ bool RadioProfileCLI::commitImage(const char* path, const char* active, return true; } +#if MESH_RADIO_SWITCH_TUNING bool RadioProfileCLI::validFastImage(const uint8_t* bytes, RadioSwitchTuning& value) const { if (bytes[2] != 1) return false; for (size_t n = 5; n < ImageSize - 4; ++n) if (bytes[n]) return false; @@ -367,10 +370,13 @@ __attribute__((noinline)) bool RadioProfileCLI::handleFastSettings(const char* k ? "radio busy; retry" : "radio tuning could not be applied/saved"); return true; } +#endif void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool infrastructure_replies) { fs_ = fs; radio_ = radio; rtc_ = rtc; last_ms_ = millis(); +#if MESH_RADIO_SWITCH_TUNING loadFastSettings(); +#endif hold_ = false; cross_ = RadioCrossMode::Off; recoverable_corrupt_store_ = false; @@ -734,12 +740,14 @@ bool RadioProfileCLI::applyReplyMutation() { void RadioProfileCLI::loop() { const uint32_t now = millis(); +#if MESH_RADIO_SWITCH_TUNING if (fast_pending_ && radio_ && (int32_t)(now - fast_retry_ms_) >= 0) { // A saved setting may fail RX verification as well as be temporarily busy. // Avoid repeatedly applying and rolling it back at the main-loop rate. fast_pending_ = radio_->trySetSwitchTuning(fast_saved_) != RadioParamApplyResult::APPLIED; fast_retry_ms_ = now + 1000; } +#endif if (!radio_ || !radio_->profiles()) return; const uint32_t elapsed = now - last_ms_; last_ms_ = now; @@ -949,7 +957,9 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, memcpy(key, text, n); key[n] = 0; const char* args = text + n; while (*args == ' ' || *args == '\t') ++args; +#if MESH_RADIO_SWITCH_TUNING if (handleFastSettings(key, args, verb, remote_origin, reply, capacity)) return true; +#endif enum { Base, Temporary, Scheduled, ScheduledTemporary, Crossing, Replies, Status, Scan, Timing, TimingAlias, KeyCount }; static const char keys[] = "radio2\0tempradio2\0radioat2\0tempradioat2\0radio2.cross\0" diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h index f84bbba83..7f85108fd 100644 --- a/src/helpers/RadioProfileCLI.h +++ b/src/helpers/RadioProfileCLI.h @@ -56,9 +56,11 @@ class RadioProfileCLI { // readable backup. Read/I/O failures and an intact backup remain read-only. bool recoverable_corrupt_store_ = false; bool publish_pending_ = false; +#if MESH_RADIO_SWITCH_TUNING RadioSwitchTuning fast_saved_; uint32_t fast_retry_ms_ = 0; bool fast_pending_ = false, fast_hold_ = false; +#endif enum class ImageReadResult : uint8_t { Missing, Valid, Invalid, Unreadable }; enum class RemoteMutation : uint8_t { None, Saved, Temporary, Off, TempOff, DeleteTemp @@ -88,12 +90,14 @@ class RadioProfileCLI { bool commitSavedImage(const char* path); bool commitImage(const char* staged, const char* active, const char* backup, bool& hold, bool preserve_backup = false); +#if MESH_RADIO_SWITCH_TUNING bool validFastImage(const uint8_t* bytes, RadioSwitchTuning& value) const; void loadFastSettings(bool boot = false); bool saveFastSettings(const RadioSwitchTuning& value); static bool commitFastSettings(void* context, const RadioSwitchTuning& value); bool handleFastSettings(const char* key, const char* args, int verb, bool remote, char* reply, size_t capacity); +#endif bool discardCorruptSavedImagesForWrite(); bool applyReplyMutation(); ImageReadResult readImage(const char* path, uint8_t* bytes, size_t size); @@ -108,7 +112,11 @@ class RadioProfileCLI { void appendSecondaryWarnings(char* reply, size_t capacity, const RadioProfiles& preview) const; public: // Call after the settings filesystem mounts and before radio_init(). +#if MESH_RADIO_SWITCH_TUNING static void configureBootTuning(FILESYSTEM* fs, Radio& radio); +#else + static void configureBootTuning(FILESYSTEM*, Radio&) {} +#endif void begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool infrastructure_replies = false); void loop(); bool handle(const char* command, char* reply, size_t capacity = 160, bool remote_origin = false); diff --git a/src/helpers/RadioSwitchTuning.h b/src/helpers/RadioSwitchTuning.h index 566c2aace..4eb7f6546 100644 --- a/src/helpers/RadioSwitchTuning.h +++ b/src/helpers/RadioSwitchTuning.h @@ -1,6 +1,17 @@ #pragma once #include +// The integrated STM32WL driver has neither the cached retune paths nor a +// tunable SPI HAL. Do not retain saved controls it cannot apply on small WL +// images. Other radios retain every supported acceleration by default. +#ifndef MESH_RADIO_SWITCH_TUNING +#if defined(STM32_PLATFORM) && defined(STM32WLxx) +#define MESH_RADIO_SWITCH_TUNING 0 +#else +#define MESH_RADIO_SWITCH_TUNING 1 +#endif +#endif + namespace mesh { // Saved policy for one physical radio, shared by both RF profiles. SPI MHz 0 diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index f04d2441e..cb9f0525d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -6,7 +6,7 @@ #include "ProfileSwitchTiming.h" #include "NrfRadioPriorityLease.h" #include "NrfRadioUsbIrqGuard.h" -#if defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) +#if MESH_RADIO_SWITCH_TUNING && (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM)) #include "TunableRadioHal.h" #define MESH_TUNABLE_RADIO_HAL 1 #endif @@ -447,6 +447,7 @@ void RadioLibWrapper::endReconfigure(bool resume_rx) { if (resume_rx) startRecv(); } +#if MESH_RADIO_SWITCH_TUNING mesh::RadioSwitchCapabilities RadioLibWrapper::switchTuningCapabilities() const { mesh::RadioSwitchCapabilities caps; #if MESH_TUNABLE_RADIO_HAL @@ -509,6 +510,7 @@ bool RadioLibWrapper::configureBootSwitchTuning(const mesh::RadioSwitchTuning& v setDriverSwitchFeatures(value.flags); return true; } +#endif bool RadioLibWrapper::restoreAfterDeepInit() { #if MESH_ENABLE_TX_BEACONS diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 482395c13..d14c82f30 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -328,9 +328,11 @@ protected: virtual bool radioDeepInit() { return false; } virtual bool supportsRadioDeepInit() const { return false; } virtual bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) = 0; +#if MESH_RADIO_SWITCH_TUNING virtual uint8_t getDriverSwitchFeatures() const { return 0; } virtual uint8_t driverSwitchCapabilities() const { return 0; } virtual void setDriverSwitchFeatures(uint8_t) {} +#endif virtual int16_t applyCachedTxPower(int8_t dbm) { return _radio->setOutputPower(dbm); } @@ -486,6 +488,7 @@ public: #endif mesh::RadioProfiles* profiles() override { return &_profiles; } const mesh::RadioProfiles* profiles() const override { return &_profiles; } +#if MESH_RADIO_SWITCH_TUNING mesh::RadioSwitchCapabilities switchTuningCapabilities() const override; mesh::RadioSwitchTuning switchTuning() const override; uint32_t switchSpiHz() const override; @@ -493,6 +496,7 @@ public: mesh::RadioParamApplyResult trySetSwitchTuning(const mesh::RadioSwitchTuning& tuning, bool (*persist)(void*, const mesh::RadioSwitchTuning&) = nullptr, void* context = nullptr) override; +#endif bool validateProfile(const mesh::RadioProfileParams& params) const override; uint8_t receiveProfile() const override { return _active_profile; } uint32_t receiveProfileGeneration() const override { return _profile_generation; } diff --git a/test/fixtures/radio_profiles/switch_tuning_unsupported_test.cpp b/test/fixtures/radio_profiles/switch_tuning_unsupported_test.cpp new file mode 100644 index 000000000..42c442eb0 --- /dev/null +++ b/test/fixtures/radio_profiles/switch_tuning_unsupported_test.cpp @@ -0,0 +1,37 @@ +// Integrated STM32WL has no tunable transport or acknowledged fast retune +// cache. Its shared boot hook must not inspect an irrelevant saved record. +#define main existing_cli_main +#include "cli_test.cpp" +#undef main + +int main() { + static_assert(MESH_RADIO_SWITCH_TUNING == 0, "Integrated STM32WL tuning must be absent"); + MemoryFS fs; + Clock clock; + Radio radio; + const std::vector record(24, 0x55); + fs.files["/radio_fast"] = record; + mesh::RadioProfileCLI::configureBootTuning(&fs, radio); + assert(fs.opened_paths.empty()); + + mesh::RadioProfileCLI cli; + cli.begin(&fs, &radio, &clock); + for (const auto& path : fs.opened_paths) assert(path.find("/radio_fast") != 0); + fs.opened_paths.clear(); + cli.loop(); + char reply[160]; + for (const char* command : {"get radio.fast", "get radio.fast.spi", "get radio.fast.caps", + "set radio.fast on", "set radio.fast.spi 8"}) { + assert(!cli.handle(command, reply, sizeof(reply))); + } + assert(fs.opened_paths.empty()); + assert(fs.files["/radio_fast"] == record); + assert(fs.files.size() == 1); + const auto capabilities = radio.switchTuningCapabilities(); + assert(capabilities.flags == 0 && capabilities.spi_clocks == 0); + assert(!radio.configureBootSwitchTuning({})); + assert(radio.trySetSwitchTuning({}) == mesh::RadioParamApplyResult::FAILED); + // Existing radio2 configuration commands remain available. + assert(cli.handle("get radio2", reply, sizeof(reply))); + assert(!strncmp(reply, "> off", 5)); +} diff --git a/test/test_radio_switch_tuning_cli.py b/test/test_radio_switch_tuning_cli.py index 84ba606a7..69b493bd9 100644 --- a/test/test_radio_switch_tuning_cli.py +++ b/test/test_radio_switch_tuning_cli.py @@ -37,6 +37,65 @@ class RadioSwitchTuningCLITest(unittest.TestCase): if role == 'simple_secure_chat': self.assertLess(radio_init.start(), setup.index('fast_rng.begin(')) + def test_capability_gate_only_defaults_off_for_integrated_stm32wl(self): + compiler = shutil.which('g++') or shutil.which('clang++') + self.assertIsNotNone(compiler) + for defines, expected in (((), 1), (('NRF52_PLATFORM',), 1), (('ESP32_PLATFORM',), 1), + (('RP2040_PLATFORM',), 1), (('STM32_PLATFORM',), 1), + (('STM32WLxx',), 1), (('STM32_PLATFORM', 'STM32WLxx'), 0), + (('STM32_PLATFORM', 'STM32WLxx', 'MESH_RADIO_SWITCH_TUNING=1'), 1)): + with self.subTest(defines=defines): + source = '#include \n' + source += f'static_assert(MESH_RADIO_SWITCH_TUNING == {expected}, "capability gate");\n' + built = subprocess.run([compiler, '-std=c++11', '-fsyntax-only', '-x', 'c++', + '-I', str(ROOT / 'src'), *['-D' + value for value in defines], '-'], + input=source, capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + + def test_integrated_stm32wl_omits_unsupported_controls_and_boot_storage_reads(self): + compiler = shutil.which('g++') or shutil.which('clang++') + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + helpers = work / 'helpers' + helpers.mkdir() + # Instrument the existing filesystem mock, rather than accepting + # an untouched file as proof that no startup read occurred. + mock = (ROOT / 'test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h').read_text() + mock = mock.replace(' std::map> files;', + ' std::map> files;\n' + ' std::vector opened_paths;') + mock = mock.replace(' File open(const char* path, const char* mode = "r", bool = false) {', + ' File open(const char* path, const char* mode = "r", bool = false) {\n' + ' opened_paths.push_back(path);') + mock = mock.replace(' File open(const char* path, const char* mode = "r", bool = false) {', + ' File open(const char* path, int mode) { return open(path, mode ? "w" : "r"); }\n' + ' File open(const char* path, const char* mode = "r", bool = false) {') + (helpers / 'IdentityStore.h').write_text(mock, encoding='ascii') + flags = [compiler, '-std=c++17', '-O2', '-Wall', '-Wextra', '-DSTM32_PLATFORM', + '-DSTM32WLxx', '-DLFS_ERR_NOENT=-2', '-I', str(work), + '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'), + '-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src')] + source = ROOT / 'src/helpers/RadioProfileCLI.cpp' + executable = work / 'unsupported' + built = subprocess.run(flags + [str(source), str(ROOT / 'test/fixtures/radio_profiles/' + 'switch_tuning_unsupported_test.cpp'), '-o', str(executable)], + capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + subprocess.run([str(executable)], check=True) + # Inspect real production objects. The inactive branch must omit + # both saved-file names and all parser/persistence entry points. + for enabled in (False, True): + obj = work / f'cli-{int(enabled)}.o' + override = ['-DMESH_RADIO_SWITCH_TUNING=1'] if enabled else [] + subprocess.run(flags + override + ['-c', str(source), '-o', str(obj)], + check=True, capture_output=True, text=True) + raw = obj.read_bytes() + for marker in (b'/radio_fast', b'handleFastSettings', b'loadFastSettings', + b'saveFastSettings', b'validFastImage'): + self.assertEqual(marker in raw, enabled, marker) + self.assertIn(b'/radio_profiles', raw) + def test_saved_controls_and_failure_transactions(self): compiler = shutil.which('g++') or shutil.which('clang++') self.assertIsNotNone(compiler)