From e896eec4302185a16de84191bf825d2eaddce189 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 19 Oct 2026 18:41:53 -0700 Subject: [PATCH] Add scheduled dual LoRa profiles Support radio2, temporary profiles and one schedule slot per command family. Preserve reply affinity, shared airtime and session expiry with guarded warm switching on SX1262 and LR1110. Validate native routing, scheduling, persistence and driver failure paths; build Companion, repeater, room and sensor roles on ESP32, nRF52 and STM32. --- .github/workflows/run-unit-tests.yml | 3 + docs/cli_commands.md | 2 + docs/radio_profiles.md | 63 +++ examples/companion_radio/MyMesh.cpp | 25 +- examples/companion_radio/MyMesh.h | 9 +- examples/companion_radio/NodePrefs.h | 2 + examples/simple_repeater/MyMesh.cpp | 7 + examples/simple_repeater/MyMesh.h | 5 +- examples/simple_room_server/MyMesh.cpp | 5 + examples/simple_room_server/MyMesh.h | 5 +- examples/simple_sensor/SensorMesh.cpp | 5 + examples/simple_sensor/SensorMesh.h | 5 +- platformio.ini | 1 + src/Dispatcher.cpp | 137 ++++- src/Dispatcher.h | 68 ++- src/Mesh.cpp | 7 +- src/Packet.cpp | 7 +- src/Packet.h | 5 + src/RadioProfiles.h | 251 +++++++++ src/helpers/BaseChatMesh.cpp | 1 + src/helpers/BaseChatMesh.h | 1 + src/helpers/CommonCLI.cpp | 1 - src/helpers/CommonCLI.h | 2 + src/helpers/CommonRadioPrefs.cpp | 1 + src/helpers/CommonRadioPrefs.h | 9 +- src/helpers/RadioProfileCLI.cpp | 428 ++++++++++++++++ src/helpers/RadioProfileCLI.h | 67 +++ src/helpers/StaticPoolPacketManager.cpp | 43 +- src/helpers/StaticPoolPacketManager.h | 8 +- src/helpers/radiolib/CustomLLCC68Wrapper.h | 18 +- src/helpers/radiolib/CustomLR1110.h | 238 ++++++++- src/helpers/radiolib/CustomLR1110Wrapper.h | 25 +- src/helpers/radiolib/CustomLR1121Wrapper.h | 20 +- src/helpers/radiolib/CustomLR2021.h | 18 + src/helpers/radiolib/CustomLR2021Wrapper.h | 38 +- src/helpers/radiolib/CustomSTM32WLxWrapper.h | 17 +- src/helpers/radiolib/CustomSX1262.h | 382 +++++++++++++- src/helpers/radiolib/CustomSX1262Wrapper.h | 29 +- src/helpers/radiolib/CustomSX1268Wrapper.h | 17 +- src/helpers/radiolib/CustomSX1276Wrapper.h | 15 +- src/helpers/radiolib/ESP32BufferedRadioHal.h | 18 + .../radiolib/LR1110ProfileSwitchState.h | 68 +++ src/helpers/radiolib/Nrf52BufferedRadioHal.h | 16 + src/helpers/radiolib/RadioInterruptEvents.h | 27 + src/helpers/radiolib/RadioLibWrappers.cpp | 475 +++++++++++++----- src/helpers/radiolib/RadioLibWrappers.h | 56 ++- src/helpers/radiolib/RadioProfileChip.h | 35 ++ .../radiolib/SX1262ProfileSwitchState.h | 99 ++++ test/fixtures/radio_profiles/chirp_math.cpp | 238 +++++++++ test/fixtures/radio_profiles/cli.cpp | 142 ++++++ test/fixtures/radio_profiles/cli_backend.h | 27 + test/fixtures/radio_profiles/dispatcher.cpp | 330 ++++++++++++ test/radio_profile_test_support.py | 29 ++ .../test_companion_node_prefs.cpp | 47 ++ test/test_radio_chirp_math.py | 28 ++ test/test_radio_lr1110_profile_switch.py | 345 +++++++++++++ test/test_radio_lr2021_profiles.py | 133 +++++ test/test_radio_profile_cli.py | 53 ++ test/test_radio_profile_clock.py | 75 +++ test/test_radio_profile_dispatcher.py | 67 +++ test/test_radio_profile_driver.py | 181 +++++++ test/test_radio_sx1262_batched_modulation.py | 199 ++++++++ test/test_radio_sx1262_modem_cache.py | 170 +++++++ test/test_radio_sx1262_profile_switch.py | 213 ++++++++ variants/heltec_v4/target.cpp | 8 + variants/minewsemi_me25ls01/target.cpp | 4 +- variants/station_g3_esp32/target.cpp | 8 + variants/t1000-e/target.cpp | 4 +- variants/thinknode_m3/target.cpp | 4 +- variants/thinknode_m7/target.cpp | 4 +- variants/thinknode_m9/target.cpp | 4 +- variants/wio_wm1110/target.cpp | 4 +- variants/xiao_s3_wio/target.cpp | 4 +- 73 files changed, 4832 insertions(+), 273 deletions(-) create mode 100644 docs/radio_profiles.md create mode 100644 src/RadioProfiles.h create mode 100644 src/helpers/RadioProfileCLI.cpp create mode 100644 src/helpers/RadioProfileCLI.h create mode 100644 src/helpers/radiolib/ESP32BufferedRadioHal.h create mode 100644 src/helpers/radiolib/LR1110ProfileSwitchState.h create mode 100644 src/helpers/radiolib/Nrf52BufferedRadioHal.h create mode 100644 src/helpers/radiolib/RadioInterruptEvents.h create mode 100644 src/helpers/radiolib/RadioProfileChip.h create mode 100644 src/helpers/radiolib/SX1262ProfileSwitchState.h create mode 100644 test/fixtures/radio_profiles/chirp_math.cpp create mode 100644 test/fixtures/radio_profiles/cli.cpp create mode 100644 test/fixtures/radio_profiles/cli_backend.h create mode 100644 test/fixtures/radio_profiles/dispatcher.cpp create mode 100644 test/radio_profile_test_support.py create mode 100644 test/test_radio_chirp_math.py create mode 100644 test/test_radio_lr1110_profile_switch.py create mode 100644 test/test_radio_lr2021_profiles.py create mode 100644 test/test_radio_profile_cli.py create mode 100644 test/test_radio_profile_clock.py create mode 100644 test/test_radio_profile_dispatcher.py create mode 100644 test/test_radio_profile_driver.py create mode 100644 test/test_radio_sx1262_batched_modulation.py create mode 100644 test/test_radio_sx1262_modem_cache.py create mode 100644 test/test_radio_sx1262_profile_switch.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67..26f5ea25f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -22,6 +22,9 @@ jobs: - name: Run Unit Tests run: pio test -e native -e native_kiss_modem -vv + - name: Test Radio Profiles + run: python3 -B -m unittest discover -s test -p "test_radio*.py" -v + - name: Upload Test Results # Upload test results even if the test step failed. if: always() diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9fde4d436..8c0cf1e2d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -198,6 +198,8 @@ This document provides an overview of CLI commands that can be sent to MeshCore ### Radio +See [Radio profiles](radio_profiles.md) for `radio2`, temporary profiles and one-slot timed changes on either profile. + #### View or change this node's radio parameters **Usage:** - `get radio` diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md new file mode 100644 index 000000000..21f8a3666 --- /dev/null +++ b/docs/radio_profiles.md @@ -0,0 +1,63 @@ +# Radio profiles + +Companion, repeater, room server and sensor firmware can alternate two LoRa profiles on one transceiver. They share airtime, packet storage, identity and duplicate suppression. Other backends retain their existing primary commands. + +Frequencies are MHz; bandwidths are kHz. The driver validates each tuple against the fitted chip. An optional final preamble field accepts `auto`/`0`, or 8–65528 symbols within the radio's airtime limits. + +| Command | Action | +| --- | --- | +| `get radio` | Read saved primary settings. | +| `set radio freq,bw,sf,cr[,preamble]` | Save primary settings; reboot to apply. | +| `get radio2` | Read saved secondary settings. | +| `set radio2 freq,bw,sf,cr,mode[,preamble]` | Save and enable the secondary. | +| `set radio2 off` | Disable it and cancel its temporary activity and schedules. | +| `set tempradio freq,bw,sf,cr,minutes[,preamble]` | Temporarily replace the primary. | +| `set tempradio2 freq,bw,sf,cr,mode,minutes[,preamble]` | Temporarily replace the secondary. | +| `get tempradio` / `get tempradio2` | Read pending or active temporary settings. | +| `set tempradio off` / `set tempradio2 off` | Restore saved settings and cancel that temporary schedule. | +| `get radio2.status` / `get radio2.timing` | Read switching counters, pending work and effective timing. | +| `get radio2.cross` / `set radio2.cross auto\|on\|off` | Read or save crossover policy. | + +Modes are `rx`, `rxtx` and `off`; `rx&tx` aliases `rxtx`. Bare `tempradio ...` remains supported. Temporary durations are 1–10080 minutes. + +`cross auto` permits crossover when both profiles are permanent or both are temporary. `on` always permits it; `off` keeps traffic on its originating profile. An RX-only profile never transmits. Replies follow receive affinity and crossover policy; queued packets from replaced sessions are discarded. + +```text +set radio2 915,125,8,5,rxtx +set tempradio2 916,250,7,5,rx,10,40 +set tempradio2 off +``` + +## Scheduled changes + +Each command family has **one RAM-only slot**. Setting it again replaces its unstarted event. + +| Command | Action | +| --- | --- | +| `set radioat freq,bw,sf,cr,start[,preamble]` | Apply and save primary settings. | +| `set tempradioat freq,bw,sf,cr,start,end[,preamble]` | Temporarily replace the primary. | +| `set radio2at freq,bw,sf,cr,mode,start[,preamble]` | Apply and save secondary settings. | +| `set tempradio2at freq,bw,sf,cr,mode,start,end[,preamble]` | Temporarily replace the secondary. | + +Times are UTC Unix seconds or `+N` whole minutes from acceptance. A valid clock is required. Endpoints must be in the future, within 24.8 days, with `end` after `start`. + +```text +set radioat 915,125,8,5,+5 +set tempradio2at 916,125,8,5,rx,+10,+20,40 +get tempradio2at +del tempradio2at +``` + +Use `get family` and `del family` to inspect or cancel; optional selectors `1` and `all` are accepted. Cancel an active temporary session before scheduling another on that profile. `radioat2` and `tempradioat2` are aliases. + +Overdue events apply in start order; equal starts apply permanent settings first. Immediate setters cancel already-due unstarted events on that profile and retain future slots. Monotonic deadlines prevent clock rollback or delayed replies from extending temporary sessions. + +## Switching and persistence + +Live changes wait at least two seconds and for queued transmissions to drain. Remote changes also require a successful transmission of their exact CLI response; this confirms local transmission, not delivery. Failed responses cancel the change (`reply_failed=1` in status). Expiry retires the old session before new transmissions and restores the radio when safe. + +SX1262 and LR1110 switches reuse acknowledged RX settings and keep the oscillator warm. TX, CAD, reset, sleep and errors revoke cached state. + +Scanning and pending timers keep the MCU awake. One transceiver cannot receive both profiles simultaneously; acquisition still depends on peer preambles, traffic and application delays. + +Permanent settings use existing preferences JSON. Missing fields default to secondary off and automatic timing/crossover. Temporary settings and schedules disappear on reboot. Scheduled permanent settings are saved only after application. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 99a9aa30d..c9878f262 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1003,6 +1003,7 @@ void MyMesh::begin() { _store->loadChannels(this); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->profileCLI().begin(*_prefs.getRadioPrefs(), radio_driver, *getRTCClock(), this); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1440,14 +1441,21 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && bw <= 500000) { - _prefs.sf = sf; - _prefs.cr = cr; - _prefs.freq = (float)freq / 1000.0; - _prefs.bw = (float)bw / 1000.0; + auto& profiles = _prefs.getRadioPrefs()->profileCLI(); + mesh::RadioProfileParams params = profiles.savedPrimary(); + params.freq = (float)freq / 1000.0f; params.bw = (float)bw / 1000.0f; + params.sf = sf; params.cr = cr; + if (radio_driver.profiles() && (profiles.awaitingReply() || hasPendingTransmissions() + || !radio_driver.validateProfile(params) + || radio_driver.trySetPrimaryParams(params) != mesh::RadioParamApplyResult::APPLIED)) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } + if (!radio_driver.profiles()) radio_driver.setParams(params.freq, params.bw, params.sf, params.cr); + profiles.primaryChanged(); + _prefs.sf = sf; _prefs.cr = cr; _prefs.freq = params.freq; _prefs.bw = params.bw; _prefs.setRepeatEn(repeat != 0); savePrefs(); - - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, (uint32_t)cr); @@ -2413,7 +2421,10 @@ void MyMesh::checkSerialInterface() { } void MyMesh::loop() { + auto& profiles = _prefs.getRadioPrefs()->profileCLI(); + profiles.loop(!hasPendingTransmissions()); BaseChatMesh::loop(); + if (profiles.saveDue()) savePrefs(); if (_cli_rescue) { checkCLIRescueCmd(); @@ -2450,5 +2461,7 @@ bool MyMesh::advert() { // To check if there is pending work bool MyMesh::hasPendingWork() const { + if ((radio_driver.profiles() && radio_driver.profiles()->enabled()) + || _prefs.getRadioPrefs()->profileCLI().needsService()) return true; return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 944790df9..265b67cdb 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -119,6 +119,12 @@ public: uint32_t getBLEPin(); void setBLEPin(uint32_t active_pin); + void onTransmitResult(uint32_t token, bool success) override { + _prefs.getRadioPrefs()->profileCLI().onTransmitResult(token, success); + } + void onCLIReplyCreated(mesh::Packet* packet) override { + _prefs.getRadioPrefs()->profileCLI().attachReply(packet); + } void loop(); void handleCmdFrame(size_t len); bool advert(); @@ -197,8 +203,7 @@ public: void savePrefs() { _prefs.node_lat = sensors.node_lat; _prefs.node_lon = sensors.node_lon; - _store->savePrefs(_prefs); - _prefs.clearDirty(); + if (_store->savePrefs(_prefs)) _prefs.clearDirty(); } #if ENV_INCLUDE_GPS == 1 diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 3cceed566..7162fec5c 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -64,6 +64,7 @@ private: NodePrefs* _parent; protected: void structure() override { + CommonRadioPrefs::structure(); def("freq", _parent->freq); def("bw", _parent->bw); def("sf", _parent->sf); @@ -220,6 +221,7 @@ public: void setRepeatEn(bool en) { repeat.disable_fwd = en ? 0 : 1; } CommonRadioPrefs* getRadioPrefs() { return &radio; } + const CommonRadioPrefs* getRadioPrefs() const { return &radio; } KeyValueStore* getCustom() { return &custom; } bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 61b6c149e..a96c15f21 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -749,6 +749,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + _prefs.getRadioPrefs()->profileCLI().attachReply(reply); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); @@ -978,6 +979,7 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->profileCLI().begin(*_prefs.getRadioPrefs(), radio_driver, *getRTCClock(), this); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1291,7 +1293,10 @@ void MyMesh::loop() { bridge.loop(); #endif + auto& profiles = _prefs.getRadioPrefs()->profileCLI(); + profiles.loop(!hasPendingTransmissions()); mesh::Mesh::loop(); + if (profiles.saveDue()) savePrefs(); if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { mesh::Packet *pkt = createSelfAdvert(); @@ -1333,6 +1338,8 @@ void MyMesh::loop() { // To check if there is pending work bool MyMesh::hasPendingWork() const { + if (_prefs.getRadioPrefs()->profileCLI().needsService()) return true; + if (radio_driver.profiles() && radio_driver.profiles()->enabled()) return true; #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index dc8932586..9cba28555 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -192,7 +192,7 @@ public: } void savePrefs() override { - _cli.savePrefs(_fs); + if (_cli.savePrefs(_fs)) _prefs.clearDirty(); } void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); @@ -227,6 +227,9 @@ public: void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void onTransmitResult(uint32_t token, bool success) override { + _prefs.getRadioPrefs()->profileCLI().onTransmitResult(token, success); + } void loop(); #if defined(WITH_BRIDGE) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index da4c776c0..da26cc039 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -524,6 +524,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, // PUB_KEY_SIZE); auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + _prefs.getRadioPrefs()->profileCLI().attachReply(reply); if (reply) { if (client->out_path_len == OUT_PATH_UNKNOWN) { sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()); @@ -723,6 +724,7 @@ void MyMesh::begin(FILESYSTEM *fs) { } radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->profileCLI().begin(*_prefs.getRadioPrefs(), radio_driver, *getRTCClock(), this); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -995,7 +997,10 @@ bool MyMesh::saveFilter(ClientInfo* client) { } void MyMesh::loop() { + auto& profiles = _prefs.getRadioPrefs()->profileCLI(); + profiles.loop(!hasPendingTransmissions()); mesh::Mesh::loop(); + if (profiles.saveDue()) savePrefs(); if (millisHasNowPassed(next_push) && acl.getNumClients() > 0) { // check for ACK timeouts diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 4dee8bdbf..5dc3adbfe 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -189,7 +189,7 @@ public: } void savePrefs() override { - _cli.savePrefs(_fs); + if (_cli.savePrefs(_fs)) _prefs.clearDirty(); } void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); @@ -228,5 +228,8 @@ public: void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void onTransmitResult(uint32_t token, bool success) override { + _prefs.getRadioPrefs()->profileCLI().onTransmitResult(token, success); + } void loop(); }; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b18606681..3d910c059 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -683,6 +683,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i temp[4] = (TXT_TYPE_CLI_DATA << 2); auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, from->id, secret, temp, 5 + text_len); + _prefs.getRadioPrefs()->profileCLI().attachReply(reply); if (reply) { if (from->out_path_len == OUT_PATH_UNKNOWN) { sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); @@ -856,6 +857,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { } radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->profileCLI().begin(*_prefs.getRadioPrefs(), radio_driver, *getRTCClock(), this); radio_driver.setTxPower(_prefs.tx_power_dbm); board.attachDynamicPrefs(_prefs.getCustom()); @@ -999,7 +1001,10 @@ bool SensorMesh::getGPS(uint8_t channel, float& lat, float& lon, float& alt) { } void SensorMesh::loop() { + auto& profiles = _prefs.getRadioPrefs()->profileCLI(); + profiles.loop(!hasPendingTransmissions()); mesh::Mesh::loop(); + if (profiles.saveDue()) savePrefs(); if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { mesh::Packet* pkt = createSelfAdvert(); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 590ca0c76..75121e51e 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -50,6 +50,9 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { public: SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); void begin(FILESYSTEM* fs); + void onTransmitResult(uint32_t token, bool success) override { + _prefs.getRadioPrefs()->profileCLI().onTransmitResult(token, success); + } void loop(); void handleCommand(ClientInfo* from, uint32_t sender_timestamp, char* command, char* reply); @@ -59,7 +62,7 @@ public: const char* getRole() override { return FIRMWARE_ROLE; } const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; } - void savePrefs() override { _cli.savePrefs(_fs); } + void savePrefs() override { if (_cli.savePrefs(_fs)) _prefs.clearDirty(); } bool formatFileSystem() override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; diff --git a/platformio.ini b/platformio.ini index f296f8382..b3ae75bfa 100644 --- a/platformio.ini +++ b/platformio.ini @@ -174,6 +174,7 @@ build_src_filter = +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/RadioProfileCLI.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index c0610b7f8..ac89a38ad 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -37,7 +37,7 @@ float Dispatcher::getAirtimeBudgetFactor() const { void Dispatcher::updateTxBudget() { unsigned long now = _ms->getMillis(); - unsigned long elapsed = now - last_budget_update; + uint32_t elapsed = uint32_t(now) - uint32_t(last_budget_update); float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor()); unsigned long max_budget = (unsigned long)(getDutyCycleWindowMs() * duty_cycle); @@ -107,6 +107,11 @@ void Dispatcher::loop() { _radio->onSendFinished(); logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); + if (outbound->transmit_token) { + onTransmitResult(outbound->transmit_token, true); + outbound->transmit_token = 0; + } + if (auto* p = _radio->profiles()) ++p->tx_packets[outbound->radio_profile]; if (outbound->isRouteFlood()) { n_sent_flood++; } else { @@ -147,8 +152,14 @@ void Dispatcher::loop() { } bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) { + if (len < 2) return false; int i = 0; + pkt->radio_profile = pkt->radio_origin = _radio->receiveProfile(); + pkt->radio_generation = pkt->radio_origin_generation = _radio->receiveProfileGeneration(); + pkt->radio_bound = false; + pkt->transmit_token = 0; + pkt->header = raw[i++]; if (pkt->getPayloadVer() > PAYLOAD_VER_1) { MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): unsupported packet version", getLogDateTime()); @@ -203,12 +214,13 @@ void Dispatcher::checkRecv() { MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): WARNING: received data, no unused packets available!", getLogDateTime()); } else { if (tryParsePacket(pkt, raw, len)) { + if (auto* p = _radio->profiles()) ++p->rx_packets[pkt->radio_profile]; pkt->_snr = _radio->getLastSNR() * 4.0f; score = _radio->packetScore(_radio->getLastSNR(), len); air_time = _radio->getEstAirtimeFor(len); rx_air_time += air_time; } else { - _mgr->free(pkt); // put back into pool + releasePacket(pkt); // put back into pool pkt = NULL; } } @@ -259,25 +271,44 @@ void Dispatcher::checkRecv() { } void Dispatcher::processRecvPacket(Packet* pkt) { - DispatcherAction action = onRecvPacket(pkt); + const auto* profiles = _radio->profiles(); + if (profiles && (pkt->radio_profile > 1 + || (pkt->radio_profile == 1 && !profiles->enabled()) + || pkt->radio_generation != profiles->generation[pkt->radio_profile])) { + releasePacket(pkt); + return; + } + DispatcherAction action; + { + ReceiveProfileScope context(*this, pkt->radio_profile, pkt->radio_generation); + action = onRecvPacket(pkt); + } if (action == ACTION_RELEASE) { - _mgr->free(pkt); + releasePacket(pkt); } else if (action == ACTION_MANUAL_HOLD) { // sub-class is wanting to manually hold Packet instance, and call releasePacket() at appropriate time } else { // ACTION_RETRANSMIT* uint8_t priority = (action >> 24) - 1; uint32_t _delay = action & 0xFFFFFF; - _mgr->queueOutbound(pkt, priority, futureMillis(_delay)); + sendPacket(pkt, priority, _delay); } } void Dispatcher::checkSend() { - if (_mgr->getOutboundCount(_ms->getMillis()) == 0) return; + const uint32_t now = _ms->getMillis(); + Packet* pending = _mgr->peekNextOutbound(now); + if (pending == nullptr) return; + if (!isPacketRadioCurrent(pending)) { + releasePacket(_mgr->getNextOutbound(now)); + return; + } updateTxBudget(); - uint32_t est_airtime = _radio->getEstAirtimeFor(MAX_TRANS_UNIT); + uint32_t est_airtime = _radio->profiles() + ? _radio->getProfileAirtime(pending->radio_profile, MAX_TRANS_UNIT) + : _radio->getEstAirtimeFor(MAX_TRANS_UNIT); if (tx_budget_ms < est_airtime / MIN_TX_BUDGET_AIRTIME_DIV) { float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor()); unsigned long needed = est_airtime / MIN_TX_BUDGET_AIRTIME_DIV - tx_budget_ms; @@ -286,23 +317,42 @@ void Dispatcher::checkSend() { } if (!millisHasNowPassed(next_tx_time)) return; + if (const auto* profiles = _radio->profiles()) { + const uint8_t profile = pending->radio_profile; + if (profile_cad_generation[profile] != profiles->generation[profile]) { + profile_cad_busy_active[profile] = false; + profile_cad_generation[profile] = profiles->generation[profile]; + } + } + // Keep scanning while waiting for duty-cycle credit. Select the TX tuple + // only when this queue entry is eligible, and preserve a pending RX packet. + const auto prepared = _radio->prepareTransmitProfile(pending->radio_profile); + if (prepared == RadioParamApplyResult::BUSY) return; + if (prepared != RadioParamApplyResult::APPLIED) { + releasePacket(_mgr->getNextOutbound(now)); + return; + } if (_radio->isReceiving()) { - if (cad_busy_start == 0) { - cad_busy_start = _ms->getMillis(); // record when CAD busy state started + const uint8_t profile = pending->radio_profile; + if (!profile_cad_busy_active[profile]) { + profile_cad_busy_active[profile] = true; + profile_cad_busy_start[profile] = now; } - if (_ms->getMillis() - cad_busy_start > getCADFailMaxDuration()) { + if (uint32_t(now - profile_cad_busy_start[profile]) > getCADFailMaxDuration()) { _err_flags |= ERR_EVENT_CAD_TIMEOUT; MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): CAD busy max duration reached!", getLogDateTime()); // channel activity has gone on too long... (Radio might be in a bad state) // force the pending transmit below... } else { - next_tx_time = futureMillis(getCADFailRetryDelay()); + const uint32_t retry_at = futureMillis(getCADFailRetryDelay()); + if (!isDualRadioActive() || !_mgr->deferOutbound(pending, retry_at)) next_tx_time = retry_at; return; } } cad_busy_start = 0; // reset busy state + profile_cad_busy_active[pending->radio_profile] = false; outbound = _mgr->getNextOutbound(_ms->getMillis()); if (outbound) { @@ -319,7 +369,7 @@ void Dispatcher::checkSend() { if (len + outbound->payload_len > MAX_TRANS_UNIT) { MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): FATAL: Invalid packet queued... too long, len=%d", getLogDateTime(), len + outbound->payload_len); - _mgr->free(outbound); + releasePacket(outbound); outbound = NULL; } else { memcpy(&raw[len], outbound->payload, outbound->payload_len); len += outbound->payload_len; @@ -360,31 +410,84 @@ Packet* Dispatcher::obtainNewPacket() { } else { pkt->payload_len = pkt->path_len = 0; pkt->_snr = 0; + pkt->radio_profile = pkt->radio_origin = receive_context_active ? receive_context_profile : 0; + pkt->radio_generation = pkt->radio_origin_generation = receive_context_active ? receive_context_generation : 0; + pkt->radio_bound = false; + pkt->transmit_token = 0; } return pkt; } void Dispatcher::releasePacket(Packet* packet) { + if (packet && packet->transmit_token) { + onTransmitResult(packet->transmit_token, false); + packet->transmit_token = 0; + } _mgr->free(packet); } void Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis) { + if (packet == nullptr) return; if (!Packet::isValidPathLen(packet->path_len) || packet->payload_len > MAX_PACKET_PAYLOAD) { MESH_DEBUG_PRINTLN("%s Dispatcher::sendPacket(): ERROR: invalid packet... path_len=%d, payload_len=%d", getLogDateTime(), (uint32_t) packet->path_len, (uint32_t) packet->payload_len); - _mgr->free(packet); + releasePacket(packet); } else { - _mgr->queueOutbound(packet, priority, futureMillis(delay_millis)); + auto* profiles = _radio->profiles(); + if (packet->radio_profile > 1 || (profiles && !packet->radio_bound + && packet->radio_generation + && packet->radio_generation != profiles->generation[packet->radio_profile])) { + releasePacket(packet); + return; + } + if (!profiles || packet->radio_bound) { + if (!isPacketRadioCurrent(packet) || !_mgr->queueOutbound(packet, priority, futureMillis(delay_millis))) + releasePacket(packet); + return; + } + const uint8_t origin = packet->radio_profile; + const uint8_t mask = profiles->transmitMask(origin); + if (!mask) { releasePacket(packet); return; } + packet->radio_origin = origin; + packet->radio_origin_generation = profiles->generation[origin]; + if (!(mask & (1U << origin))) packet->radio_profile ^= 1; + packet->radio_generation = profiles->generation[packet->radio_profile]; + packet->radio_bound = true; + if (!_mgr->queueOutbound(packet, priority, futureMillis(delay_millis))) { + releasePacket(packet); + return; + } + const uint8_t other = packet->radio_profile ^ 1; + if (mask & (1U << other)) { + Packet* copy = obtainNewPacket(); + if (copy) { + *copy = *packet; + copy->radio_profile = other; + copy->radio_generation = profiles->generation[other]; + if (!_mgr->queueOutbound(copy, priority, futureMillis(delay_millis))) releasePacket(copy); + } + } } } +bool Dispatcher::isPacketRadioCurrent(const Packet* packet) const { + const auto* profiles = _radio->profiles(); + if (!profiles || !packet->radio_bound) return packet->radio_profile == 0; + const uint8_t target = packet->radio_profile, origin = packet->radio_origin; + return target < 2 && origin < 2 && profiles->canTransmit(target) + && (origin == 0 || profiles->enabled()) + && packet->radio_generation == profiles->generation[target] + && packet->radio_origin_generation == profiles->generation[origin] + && (target == origin || profiles->canCross()); +} + // Utility function -- handles the case where millis() wraps around back to zero // 2's complement arithmetic will handle any unsigned subtraction up to HALF the word size (32-bits in this case) bool Dispatcher::millisHasNowPassed(unsigned long timestamp) const { - return (long)(_ms->getMillis() - timestamp) > 0; + return int32_t(uint32_t(_ms->getMillis()) - uint32_t(timestamp)) > 0; } unsigned long Dispatcher::futureMillis(int millis_from_now) const { - return _ms->getMillis() + millis_from_now; + return uint32_t(_ms->getMillis() + uint32_t(millis_from_now)); } -} \ No newline at end of file +} diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3e..ab5aa44af 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace mesh { @@ -22,6 +23,25 @@ public: class Radio { public: virtual void begin() { } + virtual RadioProfiles* profiles() { return nullptr; } + virtual const RadioProfiles* profiles() const { return nullptr; } + virtual bool validateProfile(const RadioProfileParams&) const { return false; } + virtual uint8_t receiveProfile() const { return 0; } + virtual uint32_t receiveProfileGeneration() const { + const auto* p = profiles(); + return p ? p->generation[receiveProfile()] : 0; + } + virtual RadioParamApplyResult prepareTransmitProfile(uint8_t profile) { + return profile == 0 ? RadioParamApplyResult::APPLIED : RadioParamApplyResult::FAILED; + } + virtual RadioParamApplyResult trySetPrimaryParams(const RadioProfileParams&, bool = false, + const uint32_t* = nullptr) { + return RadioParamApplyResult::FAILED; + } + virtual uint16_t profilePreamble(uint8_t) const { return 0; } + virtual uint32_t getProfileAirtime(uint8_t, int len_bytes, uint8_t = 0) { + return getEstAirtimeFor(len_bytes); + } /** * \brief polls for incoming raw packet. @@ -89,7 +109,10 @@ public: virtual Packet* allocNew() = 0; virtual void free(Packet* packet) = 0; - virtual void queueOutbound(Packet* packet, uint8_t priority, uint32_t scheduled_for) = 0; + // On rejection the caller retains ownership of the packet. + virtual bool queueOutbound(Packet* packet, uint8_t priority, uint32_t scheduled_for) = 0; + virtual Packet* peekNextOutbound(uint32_t now) const = 0; + virtual bool deferOutbound(Packet* packet, uint32_t scheduled_for) = 0; virtual Packet* getNextOutbound(uint32_t now) = 0; // by priority virtual int getOutboundCount(uint32_t now) const = 0; virtual int getOutboundTotal() const = 0; @@ -128,11 +151,39 @@ class Dispatcher { unsigned long tx_budget_ms; unsigned long last_budget_update; unsigned long duty_cycle_window_ms; + uint8_t receive_context_profile = 0; + uint32_t receive_context_generation = 0; + bool receive_context_active = false; + uint32_t profile_cad_busy_start[2] = {}; + uint32_t profile_cad_generation[2] = {}; + bool profile_cad_busy_active[2] = {}; + uint32_t next_transmit_token = 0; void processRecvPacket(Packet* pkt); void updateTxBudget(); protected: + class ReceiveProfileScope { + Dispatcher& owner; + uint8_t old_profile; + uint32_t old_generation; + bool old_active; + public: + ReceiveProfileScope(Dispatcher& dispatcher, uint8_t profile, uint32_t generation) + : owner(dispatcher), old_profile(owner.receive_context_profile), + old_generation(owner.receive_context_generation), old_active(owner.receive_context_active) { + owner.receive_context_profile = profile; + owner.receive_context_generation = generation; + owner.receive_context_active = true; + } + ~ReceiveProfileScope() { + owner.receive_context_profile = old_profile; + owner.receive_context_generation = old_generation; + owner.receive_context_active = old_active; + } + ReceiveProfileScope(const ReceiveProfileScope&) = delete; + ReceiveProfileScope& operator=(const ReceiveProfileScope&) = delete; + }; PacketManager* _mgr; Radio* _radio; MillisecondClock* _ms; @@ -161,6 +212,9 @@ protected: virtual void logRx(Packet* packet, int len, float score) { } // hooks for custom logging virtual void logTx(Packet* packet, int len) { } virtual void logTxFail(Packet* packet, int len) { } + // One result per physical copy. A crossed response can fail on one profile + // and succeed on the other; callers commit only after an observed success. + virtual void onTransmitResult(uint32_t token, bool success) { } virtual const char* getLogDateTime() { return ""; } virtual float getAirtimeBudgetFactor() const; @@ -175,6 +229,17 @@ protected: public: void begin(); void loop(); + Radio* getProfileRadio() { return _radio; } + bool isDualRadioActive() const { return _radio->profiles() && _radio->profiles()->enabled(); } + bool hasOutbound() const { return outbound != nullptr; } + bool hasPendingTransmissions() const { return outbound != nullptr || _mgr->getOutboundTotal() != 0; } + uint32_t reserveTransmitToken() { + if (++next_transmit_token == 0) ++next_transmit_token; + return next_transmit_token; + } + void setTransmitToken(Packet* packet, uint32_t token) { + if (packet) packet->transmit_token = token; + } Packet* obtainNewPacket(); void releasePacket(Packet* packet); @@ -199,6 +264,7 @@ public: bool tryParsePacket(Packet* pkt, const uint8_t* raw, int len); private: + bool isPacketRadioCurrent(const Packet* packet) const; void checkRecv(); void checkSend(); }; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 9dc42f0b0..26ac1034a 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -15,7 +15,10 @@ bool Mesh::allowPacketForward(const mesh::Packet* packet) { return false; // by default, Transport NOT enabled } uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52 / 50) / 2; + uint32_t airtime = _radio->profiles() + ? _radio->getProfileAirtime(packet->radio_profile, packet->getRawLength()) + : _radio->getEstAirtimeFor(packet->getRawLength()); + uint32_t t = (airtime * 52 / 50) / 2; return _rng->nextInt(0, 5)*t; } @@ -738,4 +741,4 @@ void Mesh::sendZeroHop(Packet* packet, uint16_t* transport_codes, uint32_t delay sendPacket(packet, 0, delay_millis); } -} \ No newline at end of file +} diff --git a/src/Packet.cpp b/src/Packet.cpp index aad3e2f48..44021b0f6 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -63,6 +63,11 @@ uint8_t Packet::writeTo(uint8_t dest[]) const { } bool Packet::readFrom(const uint8_t src[], uint8_t len) { + radio_generation = radio_origin_generation = 0; + radio_profile = radio_origin = 0; + radio_bound = false; + transmit_token = 0; + if (len < 2) return false; uint8_t i = 0; header = src[i++]; if (hasTransportCodes()) { @@ -84,4 +89,4 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) { return true; // success } -} \ No newline at end of file +} diff --git a/src/Packet.h b/src/Packet.h index c19d9e9d8..a10d653e0 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -49,6 +49,11 @@ public: uint8_t path[MAX_PATH_SIZE]; uint8_t payload[MAX_PACKET_PAYLOAD]; int8_t _snr; + // Local routing/session metadata; deliberately absent from the wire format. + uint32_t radio_generation = 0, radio_origin_generation = 0; + uint8_t radio_profile = 0, radio_origin = 0; + bool radio_bound = false; // queued copies retain one target, without fanout + uint32_t transmit_token = 0; // local completion tag, also shared by fanout copies /** * \brief calculate the hash of payload + type diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h new file mode 100644 index 000000000..5b277fec7 --- /dev/null +++ b/src/RadioProfiles.h @@ -0,0 +1,251 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +enum class RadioProfileMode : uint8_t { Off = 0, Rx = 1, RxTx = 2 }; +enum class RadioCrossMode : uint8_t { Auto = 0, On = 1, Off = 2 }; +enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, FAILED }; + +// These are modulation profiles of ONE transceiver, never additional radios. +struct RadioProfileParams { + float freq = 0; + float bw = 0; + uint16_t preamble = 0; // 0 = automatic, otherwise symbols + uint8_t sf = 0; + uint8_t cr = 0; + + bool operator==(const RadioProfileParams& other) const { + return freq == other.freq && bw == other.bw && sf == other.sf + && cr == other.cr && preamble == other.preamble; + } + bool operator!=(const RadioProfileParams& other) const { return !(*this == other); } +}; + +struct RadioProfileConfig { + RadioProfileParams params; + RadioProfileMode mode = RadioProfileMode::Off; +}; + +// Portable timing/routing model, also used by the native tests. Hardware access +// remains in RadioLibWrapper; timers and persisted settings remain in the CLI. +class RadioProfiles { + public: + // Minimum continuous-RX listen window, measured in LoRa symbols. + static constexpr double MinListenSymbols = 4.6; + static constexpr double SlowListenSymbols = MinListenSymbols; + static constexpr double MinFastListenSymbols = MinListenSymbols; + static constexpr uint8_t AcquisitionSymbols = 8; + static constexpr uint16_t MaxPreamble = 65528; + static constexpr uint16_t MaxAutomaticPreamble = 512; + // Successful RX-to-RX hops self-test the board-specific switch allowance. + // Packet receptions can extend a visit. No fixed loop allowance is added to + // the self-tested, guarded switch time in the idle-scan timing model. + static constexpr uint32_t LoopBudgetUs = 0; + static constexpr uint8_t SwitchTestSamplesPerDirection = 4; + + struct ChirpTiming { + bool valid = false; + uint8_t slow = 0; + double symbol_us[2] = {}; + uint32_t listen_us[2] = {}; + double preamble[2] = {}; // Rounded recommendations, not silently capped. + double cycle_us = 0; + uint32_t switch_us = 0, loop_us = 0; + }; + + RadioProfileParams primary; + RadioProfileConfig secondary; + bool primary_temporary = false; + bool secondary_temporary = false; + RadioCrossMode cross = RadioCrossMode::Auto; + uint32_t generation[2] = {1, 1}; + uint32_t switches = 0; + uint32_t rx_packets[2] = {}; + uint32_t tx_packets[2] = {}; + uint32_t switch_failures = 0; + uint32_t longest_switch_us = 0; + uint32_t switch_test_max_us[2] = {}; + uint8_t switch_test_samples[2] = {}; + + void resetSwitchTest() { + switch_test_max_us[0] = switch_test_max_us[1] = 0; + switch_test_samples[0] = switch_test_samples[1] = 0; + } + void sampleSwitch(uint8_t from, uint8_t to, uint32_t elapsed_us) { + if (!enabled() || from > 1 || to > 1 || from == to || !elapsed_us) return; + if (switch_test_samples[from] < SwitchTestSamplesPerDirection) ++switch_test_samples[from]; + if (elapsed_us > switch_test_max_us[from]) switch_test_max_us[from] = elapsed_us; + } + bool switchTestReady() const { + return switch_test_samples[0] >= SwitchTestSamplesPerDirection + && switch_test_samples[1] >= SwitchTestSamplesPerDirection; + } + uint32_t switchBudgetUs() const { + const uint32_t observed = switch_test_max_us[0] > switch_test_max_us[1] + ? switch_test_max_us[0] : switch_test_max_us[1]; + if (!observed) return 0; + const uint64_t guarded = (uint64_t(observed) * 11 + 9) / 10; + return guarded > UINT32_MAX ? UINT32_MAX : uint32_t(guarded); + } + + bool enabled() const { return secondary.mode != RadioProfileMode::Off; } + bool canTransmit(uint8_t profile) const { + return profile == 0 || (profile == 1 && secondary.mode == RadioProfileMode::RxTx); + } + bool canCross() const { + return cross == RadioCrossMode::On || (cross == RadioCrossMode::Auto + && primary_temporary == secondary_temporary); + } + uint8_t transmitMask(uint8_t origin) const { + if (origin > 1) origin = 0; + uint8_t result = canTransmit(origin) ? (1U << origin) : 0; + if (enabled() && canCross() && canTransmit(origin ^ 1)) result |= 1U << (origin ^ 1); + return result; + } + const RadioProfileParams& params(uint8_t profile) const { + return profile == 1 ? secondary.params : primary; + } + static bool valid(const RadioProfileParams& p) { + if (!isfinite(p.freq) || !isfinite(p.bw) || p.freq < 137 || p.freq > 2500 + || p.sf < 5 || p.sf > 12 || p.cr < 5 || p.cr > 8 + || (p.preamble != 0 && (p.preamble < 8 || p.preamble > MaxPreamble))) return false; + static constexpr float bandwidths[] = {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, + 41.67f, 62.5f, 83.34f, 101.56f, 125, 250, 500, 1000, 203.125f, 406.25f, 812.5f, 1625}; + for (float bw : bandwidths) if (fabsf(p.bw - bw) < 0.01f) return true; + return false; + } + static double symbolUs(const RadioProfileParams& p) { + return p.bw > 0 && p.sf <= 12 ? (double)(1U << p.sf) * 1000.0 / p.bw : 0; + } +#if defined(STM32_PLATFORM) && defined(__GNUC__) && !defined(__clang__) + // Share the complete validation on flash-constrained STM32 targets. + __attribute__((noinline, noclone)) +#endif + static bool safePreamble(const RadioProfileParams& p, uint16_t symbols) { + const double symbol = symbolUs(p); + if (symbol <= 0) return false; + const int de = symbol >= 16000 ? 1 : 0; + const int denominator = 4 * (p.sf - 2 * de); + // The payload expression is integral apart from this small rational + // ceiling. Preserve even invalid-SF behavior: a zero denominator was + // infinite (and failed the limit), while signed division rounds a + // negative quotient toward zero, exactly its mathematical ceiling. + if (!denominator) return false; + const int numerator = 8 * 255 - 4 * p.sf + (p.sf <= 6 ? 20 : 28) + 16; + const double payload = 8 + (denominator > 0 + ? (numerator + denominator - 1) / denominator : numerator / denominator) * 8; + // RadioLib's microsecond airtime calculation multiplies by four before + // dividing. Reject settings that overflow that intermediate for a full + // packet, including a retry using coding rate 4/8. + return (symbols + (p.sf <= 6 ? 6.25 : 4.25) + payload) * symbol * 4 <= UINT32_MAX; + } + uint8_t slowerProfile() const { + return symbolUs(primary) >= symbolUs(secondary.params) ? 0 : 1; + } + static double roundPreamble(double symbols) { + return ceil((symbols < 32 ? 32 : symbols) / 8.0) * 8.0; + } + static uint32_t minimumListenUs(const RadioProfileParams& p, bool slow = false) { + const double symbol = symbolUs(p); + const double symbols = slow ? SlowListenSymbols : MinFastListenSymbols; + return isfinite(symbol) && symbol > 0 && symbol <= UINT32_MAX / symbols + ? (uint32_t)ceil(symbols * symbol) : 0; + } + static double preambleForVisits(const RadioProfileParams& p, bool slow, + double own_us, double other_us, double overhead_us) { + const double symbol = symbolUs(p); + if (symbol <= 0) return 32; + return slow ? roundPreamble(2.0 * (own_us + other_us + overhead_us) / symbol) + : roundPreamble((other_us + overhead_us) / symbol + 2 * AcquisitionSymbols); + } + // Reserve two return opportunities on the slow profile and enough preamble + // for blind time plus acquisition on the faster profile. Packet activity or + // a delayed main loop can extend a visit, so idle timing is not a loss guarantee. + static ChirpTiming calculateChirpTiming(const RadioProfileParams& a, const RadioProfileParams& b, + uint32_t visit_a_us = 0, uint32_t visit_b_us = 0, + uint32_t switch_us = 0, uint32_t loop_us = LoopBudgetUs) { + ChirpTiming t; + if (!valid(a) || !valid(b)) return t; + t.symbol_us[0] = symbolUs(a); t.symbol_us[1] = symbolUs(b); + t.slow = t.symbol_us[0] >= t.symbol_us[1] ? 0 : 1; + const uint32_t minimum_a = minimumListenUs(a, t.slow == 0); + const uint32_t minimum_b = minimumListenUs(b, t.slow == 1); + t.listen_us[0] = visit_a_us > minimum_a ? visit_a_us : minimum_a; + t.listen_us[1] = visit_b_us > minimum_b ? visit_b_us : minimum_b; + t.switch_us = switch_us; t.loop_us = loop_us; + const double overhead = 2.0 * switch_us + loop_us; + t.cycle_us = double(t.listen_us[0]) + t.listen_us[1] + overhead; + t.preamble[0] = preambleForVisits(a, t.slow == 0, t.listen_us[0], t.listen_us[1], overhead); + t.preamble[1] = preambleForVisits(b, t.slow == 1, t.listen_us[1], t.listen_us[0], overhead); + t.valid = true; + return t; + } + double automaticPreamble(uint8_t profile) const { + if (profile > 1) return 32; + // Keep the hot retune path to one profile's calculation; the full + // validated diagnostic structure is only needed by CLI/reporting. + const uint8_t slow = slowerProfile(); + return preambleForVisits(params(profile), profile == slow, + minimumListenUs(params(profile), profile == slow), minimumListenUs(params(profile ^ 1), (profile ^ 1) == slow), + 2.0 * switchBudgetUs() + LoopBudgetUs); + } + uint32_t listenUs(uint8_t profile, uint16_t slow_preamble = 0) const { + const uint8_t slow = slowerProfile(); + const double minimum = minimumListenUs(params(profile), enabled() && profile == slow); + if (!enabled() || profile == slow) return (uint32_t)ceil(minimum); + if (!slow_preamble) slow_preamble = preamble(slow, 32); + const double available = double(slow_preamble) / 2.0 * symbolUs(params(slow)) + - minimumListenUs(params(slow), true) - 2.0 * switchBudgetUs() - LoopBudgetUs; + // Rejected previews must not cause an out-of-range floating-to-int cast. + return available > UINT32_MAX ? UINT32_MAX + : available > minimum ? (uint32_t)floor(available) : (uint32_t)minimum; + } + ChirpTiming chirpTiming() const { + if (!enabled()) return {}; + return calculateChirpTiming(primary, secondary.params, listenUs(0), listenUs(1), switchBudgetUs()); + } + uint16_t preamble(uint8_t profile, uint16_t single_profile_default) const { + const auto& p = params(profile); + if (p.preamble) return p.preamble; + if (!enabled()) return single_profile_default; + // Do not expose an unmeasured short preamble while the startup test runs. + if (!switchTestReady()) return MaxAutomaticPreamble; + double symbols = automaticPreamble(profile); + if (symbols < single_profile_default) symbols = single_profile_default; + symbols = roundPreamble(symbols); + if (symbols > MaxAutomaticPreamble) symbols = MaxAutomaticPreamble; + return (uint16_t)symbols; + } + bool automaticPreambleFits() const { + for (uint8_t i = 0; i < (enabled() ? 2 : 1); ++i) { + if (!valid(params(i)) || !safePreamble(params(i), preamble(i, 32))) return false; + } + // Recommendations above the automatic cap remain visible, but the profile can + // still run at the cap when its physical preamble is legal. + if (enabled()) { + const uint8_t slow = slowerProfile(); + const double available = double(preamble(slow, 32)) / 2.0 * symbolUs(params(slow)) + - minimumListenUs(params(slow), true) - 2.0 * switchBudgetUs() - LoopBudgetUs; + if (available < minimumListenUs(params(slow ^ 1)) + && params(slow).preamble) return false; + } + return true; + } + void setPrimary(const RadioProfileParams& p, bool temporary) { + if (primary != p || primary_temporary != temporary) { ++generation[0]; resetSwitchTest(); } + primary = p; + primary_temporary = temporary; + } + void setSecondary(const RadioProfileConfig& p, bool temporary) { + if (secondary.params != p.params || secondary.mode != p.mode + || secondary_temporary != temporary) { ++generation[1]; resetSwitchTest(); } + secondary = p; + secondary_temporary = temporary; + } +}; + +} // namespace mesh diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 28592415c..0bc584433 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -279,6 +279,7 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender temp[4] = (TXT_TYPE_CLI_DATA << 2); auto reply_pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, from.id, secret, temp, 5 + text_len); + onCLIReplyCreated(reply_pkt); if (reply_pkt) { if (from.out_path_len == OUT_PATH_UNKNOWN) { sendFloodScoped(from, reply_pkt, CLI_REPLY_DELAY_MILLIS); diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 835384b74..f14b9d86e 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -115,6 +115,7 @@ protected: virtual void onMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; virtual void onCommandDataRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; virtual void onCLICommandRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text, char* reply) = 0; + virtual void onCLIReplyCreated(mesh::Packet* packet) { } virtual void onSignedMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) = 0; virtual uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const = 0; virtual uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const = 0; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 04199e24d..9777f91f9 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -164,7 +164,6 @@ void CommonCLI::savePrefs() { _prefs->advert_interval = 0; // turn it off, now that device has been manually configured } _callbacks->savePrefs(); - _prefs->clearDirty(); } uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2759b0c4f..08cad50e9 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -78,6 +78,7 @@ private: NodePrefs* _parent; protected: void structure() override { + CommonRadioPrefs::structure(); def("freq", _parent->freq); def("bw", _parent->bw); def("sf", _parent->sf); @@ -234,6 +235,7 @@ public: } CommonRadioPrefs* getRadioPrefs() { return &radio; } + const CommonRadioPrefs* getRadioPrefs() const { return &radio; } KeyValueStore* getCustom() { return &custom; } bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 41b179932..d07dfbd35 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -29,6 +29,7 @@ bool CommonRadioPrefs::setByKey(const char* key, const char* value) { } bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + if (_profiles.handle(command, reply, 160, sender_timestamp != 0)) return true; if (strcmp(command, "get radio") == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(getFreq())); diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index 0e83ca9df..69e55c244 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -1,15 +1,20 @@ #pragma once #include "ConfigSerializer.h" #include "KeyValueStore.h" +#include "RadioProfileCLI.h" class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { bool _is_dirty = false; + mesh::RadioProfileCLI _profiles; protected: CommonRadioPrefs() { } + void structure() override { def("profiles", _profiles); } public: void markDirty() { _is_dirty = true; } - void clearDirty() { _is_dirty = false; } - bool isDirty() const { return _is_dirty; } + void clearDirty() override { _is_dirty = false; _profiles.clearDirty(); } + bool isDirty() const override { return _is_dirty || _profiles.isDirty(); } + mesh::RadioProfileCLI& profileCLI() { return _profiles; } + const mesh::RadioProfileCLI& profileCLI() const { return _profiles; } virtual float getFreq() const = 0; virtual void setFreq(float f) = 0; diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp new file mode 100644 index 000000000..46a1a5da8 --- /dev/null +++ b/src/helpers/RadioProfileCLI.cpp @@ -0,0 +1,428 @@ +#include "RadioProfileCLI.h" +#include "CommonRadioPrefs.h" +#include +#include +#include +#include +#include +#include +#include + +namespace mesh { +namespace { +constexpr uint32_t ReplyDelay = 2000; +constexpr uint32_t MaxMinutes = 10080; +bool due(uint32_t now, uint32_t deadline) { return int32_t(now - deadline) >= 0; } +bool integer(const char* text, uint32_t& value) { + if (!text || !*text) return false; + value = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9' || value > (UINT32_MAX - uint32_t(*text-'0')) / 10) return false; + value = value * 10 + uint32_t(*text-'0'); + } + return true; +} +bool decimal(const char* text, float& value) { + if (!text || !*text) return false; + // Decimal RF values only: no NaN, infinity, exponent, sign or hex aliases. + unsigned dots = 0, digits = 0; + for (const char* p = text; *p; ++p) { + if (*p == '.') { if (++dots > 1) return false; } + else if (*p >= '0' && *p <= '9') ++digits; + else return false; + } + char* end; + errno = 0; + value = strtof(text, &end); + return digits && !*end && errno != ERANGE && isfinite(value); +} +unsigned split(char* text, char* parts[], unsigned capacity) { + unsigned count = 0; + do { + if (!*text || count == capacity) return 0; + parts[count++] = text; + char* comma = strchr(text, ','); + if (!comma) break; + *comma = 0; text = comma + 1; + } while (true); + return count; +} +bool preamble(const char* text, uint16_t& result) { + if (!strcmp(text, "auto")) { result = 0; return true; } + uint32_t value; + if (!integer(text, value) || value > RadioProfiles::MaxPreamble || (value && value < 8)) return false; + result = uint16_t(value); return true; +} +bool timestamp(const char* text, uint32_t epoch, uint32_t now, uint32_t& result, uint32_t& tick) { + uint32_t value; + if (!epoch || !integer(text + (*text == '+'), value)) return false; + uint64_t absolute = *text == '+' ? uint64_t(epoch) + uint64_t(value) * 60 : value; + if (absolute > UINT32_MAX || absolute <= epoch || (absolute - epoch) * 1000 > INT32_MAX) return false; + result = uint32_t(absolute); + tick = now + uint32_t((absolute - epoch) * 1000); + return true; +} +const char* modeName(RadioProfileMode mode) { + return mode == RadioProfileMode::RxTx ? "rxtx" : mode == RadioProfileMode::Rx ? "rx" : "off"; +} +bool parseMode(const char* text, RadioProfileMode& mode) { + if (!strcmp(text, "rx")) mode = RadioProfileMode::Rx; + else if (!strcmp(text, "rxtx") || !strcmp(text, "rx&tx")) mode = RadioProfileMode::RxTx; + else if (!strcmp(text, "off")) mode = RadioProfileMode::Off; + else return false; + return true; +} +} + +void RadioProfileCLI::structure() { + def("freq", saved_.params.freq); def("bw", saved_.params.bw); + def("sf", saved_.params.sf); def("cr", saved_.params.cr); + def("preamble", saved_.params.preamble); + uint8_t mode = uint8_t(saved_.mode); + def("mode", mode); saved_.mode = RadioProfileMode(mode); + def("primary_pre", primary_preamble_); def("cross", cross_); +} + +RadioProfileParams RadioProfileCLI::savedPrimary() const { + RadioProfileParams params; + if (prefs_) { + params.freq = prefs_->getFreq(); params.bw = prefs_->getBandwidth(); + params.sf = prefs_->getSpreadFactor(); params.cr = prefs_->getCodingRate(); + } + params.preamble = primary_preamble_; + return params; +} + +void RadioProfileCLI::begin(CommonRadioPrefs& prefs, Radio& radio, RTCClock& clock, Dispatcher* dispatcher) { + dispatcher_ = dispatcher; + prefs_ = &prefs; radio_ = &radio; rtc_ = &clock; + if (!radio.profiles()) return; + if (primary_preamble_ && (primary_preamble_ < 8 || primary_preamble_ > RadioProfiles::MaxPreamble)) primary_preamble_ = 0; + if (uint8_t(saved_.mode) > uint8_t(RadioProfileMode::RxTx) + || (saved_.mode != RadioProfileMode::Off && !radio.validateProfile(saved_.params))) saved_ = {}; + if (cross_ > uint8_t(RadioCrossMode::Off)) cross_ = 0; + radio.profiles()->cross = cross_ <= uint8_t(RadioCrossMode::Off) ? RadioCrossMode(cross_) : RadioCrossMode::Auto; + RadioProfileConfig primary; primary.params = savedPrimary(); primary.mode = RadioProfileMode::RxTx; + queue(0, primary, false, false, uint32_t(millis())); + if (uint8_t(saved_.mode) <= uint8_t(RadioProfileMode::RxTx) + && (saved_.mode == RadioProfileMode::Off || radio.validateProfile(saved_.params))) + radio.profiles()->setSecondary(saved_, false); +} + +void RadioProfileCLI::queue(uint8_t profile, const RadioProfileConfig& config, bool temporary, + bool save, uint32_t at, uint32_t end) { + auto& pending = pending_[profile]; + pending.config = config; pending.due = at; pending.end = end; + pending.temporary = temporary; pending.save = save; pending.retry = at; pending.active = true; + pending.schedule = -1; +} + +void RadioProfileCLI::restore(uint8_t profile, uint32_t at) { + RadioProfileConfig config = saved_; + if (!profile) { config.params = savedPrimary(); config.mode = RadioProfileMode::RxTx; } + queue(profile, config, false, false, at); +} + +bool RadioProfileCLI::apply(uint8_t profile, const RadioProfileConfig& config, bool temporary) { + if (profile) { radio_->profiles()->setSecondary(config, temporary); return true; } + return radio_->trySetPrimaryParams(config.params, temporary) == RadioParamApplyResult::APPLIED; +} + +void RadioProfileCLI::attachReply(Packet* packet) { + if (!remote_command_[0] || reply_attached_) return; + reply_attached_ = true; + if (packet && dispatcher_) dispatcher_->setTransmitToken(packet, remote_token_); + else { remote_command_[0] = 0; reply_failed_ = true; } +} + +void RadioProfileCLI::onTransmitResult(uint32_t token, bool success) { + if (remote_command_[0] && token == remote_token_ && success) reply_sent_ = true; +} + +void RadioProfileCLI::primaryChanged() { + if (leases_[0].schedule >= 0) schedules_[leases_[0].schedule] = {}; + if (pending_[0].schedule >= 0) schedules_[pending_[0].schedule] = {}; + pending_[0].active = leases_[0].active = restoring_[0] = false; + cancelOverdue(0, uint32_t(millis()), rtc_ ? rtc_->getCurrentTime() : 0); +} + +bool RadioProfileCLI::needsService() const { + if (pending() || restoring_[0] || restoring_[1] || leases_[0].active || leases_[1].active || isDirty()) return true; + for (const auto& schedule : schedules_) if (schedule.occupied) return true; + return false; +} + +void RadioProfileCLI::cancelOverdue(uint8_t profile, uint32_t now, uint32_t epoch) { + for (unsigned i = unsigned(profile) * 2; i < unsigned(profile) * 2 + 2; ++i) { + auto& schedule = schedules_[i]; + if (schedule.occupied && !schedule.started && (due(now, schedule.start_ms) || epoch >= schedule.start)) + schedule = {}; + } +} + +bool RadioProfileCLI::saveDue() { + const uint32_t now = uint32_t(millis()); + if (!isDirty()) { save_waiting_ = false; return false; } + if (save_waiting_ && !due(now, save_retry_)) return false; + save_waiting_ = true; save_retry_ = now + 1000; + return true; +} + +void RadioProfileCLI::loop(bool can_apply) { + if (!radio_ || !radio_->profiles()) return; + const uint32_t now = uint32_t(millis()); + const uint32_t epoch = rtc_ ? rtc_->getCurrentTime() : 0; + if (remote_command_[0] && reply_attached_ && can_apply) { + char command[sizeof(remote_command_)]; strcpy(command, remote_command_); + remote_command_[0] = 0; + if (reply_sent_) { + char ignored[160]; replaying_ = true; + handle(command, ignored, sizeof(ignored)); + replaying_ = false; + } else reply_failed_ = true; + } + // Expiry is independent of wall-clock changes and outgoing queue load. + for (uint8_t profile = 0; profile < 2; ++profile) { + if (leases_[profile].active && due(now, leases_[profile].end)) { + if (leases_[profile].schedule >= 0) schedules_[leases_[profile].schedule] = {}; + leases_[profile].active = false; + if (!profile) radio_->profiles()->setPrimary(savedPrimary(), false); + restoring_[profile] = true; + } + auto& pending = pending_[profile]; + if (pending.active && pending.temporary && due(now, pending.end)) { + pending.active = false; + if (leases_[profile].active) { leases_[profile].active = false; restoring_[profile] = true; } + } + } + // An expired generation must retire even if its replacement is waiting + // for a response or a busy queue. Keep the replacement in its own slot. + for (uint8_t profile = 0; profile < 2; ++profile) { + if (!restoring_[profile]) continue; + RadioProfileConfig baseline = saved_; + if (!profile) { baseline.params = savedPrimary(); baseline.mode = RadioProfileMode::RxTx; } + if (apply(profile, baseline, false)) restoring_[profile] = false; + } + // Catch up in scheduled start order so a late loop cannot let an older + // temporary start overwrite a newer permanent change. Equal starts apply + // the permanent baseline first, then its temporary overlay. + for (uint8_t profile = 0; profile < 2; ++profile) { + int selected = -1; + for (unsigned i = unsigned(profile) * 2; i < unsigned(profile) * 2 + 2; ++i) { + auto& schedule = schedules_[i]; + const bool temporary = i & 1; + if (!schedule.occupied) continue; + if (temporary && !schedule.started && (due(now, schedule.end_ms) || epoch >= schedule.end)) { + schedule = {}; continue; + } + if (schedule.started || pending_[profile].active || !can_apply + || (!due(now, schedule.start_ms) && epoch < schedule.start)) continue; + if (selected < 0 || schedule.start < schedules_[selected].start) selected = int(i); + } + if (selected >= 0) { + auto& schedule = schedules_[selected]; + const bool temporary = selected & 1; + queue(profile, schedule.config, temporary, !temporary, now, schedule.end_ms); + pending_[profile].schedule = int8_t(selected); + if (temporary) schedule.started = true; + else schedule = {}; + } + } + for (uint8_t profile = 0; profile < 2; ++profile) { + auto& pending = pending_[profile]; + if (restoring_[profile] || !pending.active || !due(now, pending.due) || !due(now, pending.retry)) continue; + if (!can_apply) continue; + if (!apply(profile, pending.config, pending.temporary)) { + pending.retry = now + 1000; continue; + } + if (leases_[profile].active && leases_[profile].schedule >= 0 + && leases_[profile].schedule != pending.schedule) + schedules_[leases_[profile].schedule] = {}; + leases_[profile].active = pending.temporary; + leases_[profile].end = pending.end; + leases_[profile].schedule = pending.schedule; + if (pending.save) { + if (profile) saved_ = pending.config; + else { + prefs_->setFreq(pending.config.params.freq); prefs_->setBandwidth(pending.config.params.bw); + prefs_->setSpreadFactor(pending.config.params.sf); prefs_->setCodingRate(pending.config.params.cr); + primary_preamble_ = pending.config.params.preamble; + } + markDirty(); + } + pending.active = false; + } +} + +void RadioProfileCLI::format(char* reply, size_t capacity, const RadioProfileConfig& config) const { + if (config.mode == RadioProfileMode::Off) { snprintf(reply, capacity, "> off"); return; } + snprintf(reply, capacity, "> %.6f,%.3f,%u,%u,%s,preamble=%u", double(config.params.freq), + double(config.params.bw), unsigned(config.params.sf), unsigned(config.params.cr), + modeName(config.mode), unsigned(config.params.preamble)); +} + +bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, bool remote) { + enum Verb { Get, Set, Delete } verb; + const char* text; + if (!strncmp(command, "get ", 4)) { verb = Get; text = command + 4; } + else if (!strncmp(command, "set ", 4)) { verb = Set; text = command + 4; } + else if (!strncmp(command, "del ", 4)) { verb = Delete; text = command + 4; } + else if (!strncmp(command, "tempradio ", 10)) { verb = Set; text = command; } + else return false; + char key[24]; + const char* space = strchr(text, ' '); + const size_t length = space ? size_t(space - text) : strlen(text); + if (length >= sizeof(key)) return false; + memcpy(key, text, length); key[length] = 0; + const char* args = space ? space + 1 : ""; + if (!strcmp(key, "radioat2")) strcpy(key, "radio2at"); + if (!strcmp(key, "tempradioat2")) strcpy(key, "tempradio2at"); + int schedule_index = !strcmp(key, "radioat") ? 0 : !strcmp(key, "tempradioat") ? 1 + : !strcmp(key, "radio2at") ? 2 : !strcmp(key, "tempradio2at") ? 3 : -1; + const bool secondary = !strcmp(key, "radio2") || !strcmp(key, "tempradio2") || schedule_index >= 2; + const bool temporary = !strcmp(key, "tempradio") || !strcmp(key, "tempradio2") + || (schedule_index >= 0 && (schedule_index & 1)); + const bool primary = !strcmp(key, "radio"); + const bool cross = !strcmp(key, "radio2.cross"); + const bool status = !strcmp(key, "radio2.status"); + const bool timing = !strcmp(key, "radio2.timing"); + if (!primary && !secondary && !temporary && schedule_index < 0 && !cross && !status && !timing) return false; + auto error = [&](const char* message) { snprintf(reply, capacity, "Error: %s", message); return true; }; + if (!prefs_ || !radio_ || !radio_->profiles()) { + if (primary || !strncmp(command, "tempradio ", 10)) return false; + return error("radio profiles unavailable"); + } + const uint8_t profile = secondary ? 1 : 0; + if (verb != Get && !validating_ && !replaying_) { + if (remote_command_[0]) return error("radio response still pending"); + if (remote) { + if (!dispatcher_ || strlen(command) >= sizeof(remote_command_)) return error("remote radio command unavailable"); + accepted_ms_ = uint32_t(millis()); accepted_epoch_ = rtc_ ? rtc_->getCurrentTime() : 0; + validating_ = true; + const bool handled = handle(command, reply, capacity); + validating_ = false; + if (handled && !strncmp(reply, "OK", 2)) { + strcpy(remote_command_, command); remote_token_ = dispatcher_->reserveTransmitToken(); + reply_attached_ = reply_sent_ = reply_failed_ = false; + } + return handled; + } + } + const uint32_t now = (validating_ || replaying_) ? accepted_ms_ : uint32_t(millis()); + if (cross) { + if (verb == Get && !*args) snprintf(reply, capacity, "> %s", cross_ == 1 ? "on" : cross_ == 2 ? "off" : "auto"); + else if (verb == Set && (!strcmp(args, "auto") || !strcmp(args, "on") || !strcmp(args, "off"))) { + if (validating_) { snprintf(reply, capacity, "OK"); return true; } + cross_ = !strcmp(args, "on") ? 1 : !strcmp(args, "off") ? 2 : 0; + radio_->profiles()->cross = RadioCrossMode(cross_); markDirty(); snprintf(reply, capacity, "OK"); + } else return error("use radio2.cross auto|on|off"); + return true; + } + if (status || timing) { + if (verb != Get || *args) return error("read-only"); + const auto& p = *radio_->profiles(); + if (timing) snprintf(reply, capacity, "> switch=%luus preamble=%u,%u", (unsigned long)p.switchBudgetUs(), + unsigned(radio_->profilePreamble(0)), unsigned(radio_->profilePreamble(1))); + else snprintf(reply, capacity, "> %s; switches=%lu errors=%lu max=%luus pending=%u reply_failed=%u", modeName(p.secondary.mode), + (unsigned long)p.switches, (unsigned long)p.switch_failures, (unsigned long)p.longest_switch_us, + unsigned(pending()), unsigned(reply_failed_)); + return true; + } + if (schedule_index >= 0 && (verb == Get || verb == Delete)) { + if (*args && strcmp(args, "1") && strcmp(args, "all")) return error("one schedule slot"); + auto& schedule = schedules_[schedule_index]; + if (verb == Delete) { + if (validating_) { snprintf(reply, capacity, "OK"); return true; } + schedule = {}; + if (pending_[profile].active && pending_[profile].schedule == schedule_index) pending_[profile].active = false; + if (leases_[profile].active && leases_[profile].schedule == schedule_index) { + restore(profile, now + ReplyDelay); + } + snprintf(reply, capacity, "OK"); + } + else if (!schedule.occupied) snprintf(reply, capacity, "> off"); + else snprintf(reply, capacity, "> start=%lu end=%lu %.6f,%.3f,%u,%u,%s,preamble=%u", + (unsigned long)schedule.start, (unsigned long)schedule.end, double(schedule.config.params.freq), + double(schedule.config.params.bw), unsigned(schedule.config.params.sf), unsigned(schedule.config.params.cr), + modeName(schedule.config.mode), unsigned(schedule.config.params.preamble)); + return true; + } + if (verb == Get) { + if (*args) return error("unexpected argument"); + if (primary) { + auto p = savedPrimary(); + snprintf(reply, capacity, "> %.6f,%.3f,%u,%u,preamble=%u", double(p.freq), double(p.bw), + unsigned(p.sf), unsigned(p.cr), unsigned(p.preamble)); + } else if (!temporary) format(reply, capacity, saved_); + else if (pending_[profile].active && pending_[profile].temporary) format(reply, capacity, pending_[profile].config); + else if (leases_[profile].active) { + RadioProfileConfig active; active.mode = profile ? radio_->profiles()->secondary.mode : RadioProfileMode::RxTx; + active.params = profile ? radio_->profiles()->secondary.params : radio_->profiles()->primary; + format(reply, capacity, active); + } else snprintf(reply, capacity, "> off"); + return true; + } + if (verb != Set || !*args || strlen(args) >= 128) return error("invalid radio command"); + if (!strcmp(args, "off") && schedule_index < 0 && !primary) { + if (validating_) { snprintf(reply, capacity, "OK - restore pending"); return true; } + if (!temporary) { saved_ = {}; markDirty(); } + schedules_[profile * 2 + 1] = {}; + if (!temporary) schedules_[profile * 2] = {}; + restore(profile, now + ReplyDelay); + snprintf(reply, capacity, "OK - restore pending"); return true; + } + char input[128]; strcpy(input, args); + char* parts[9]; const unsigned count = split(input, parts, 9); + const unsigned expected = secondary ? 5 : 4; + const unsigned extra = schedule_index >= 0 ? (temporary ? 2 : 1) : temporary ? 1 : 0; + RadioProfileConfig config; config.mode = RadioProfileMode::RxTx; + uint32_t sf, cr; + if ((count != expected + extra && count != expected + extra + 1) + || !decimal(parts[0], config.params.freq) || !decimal(parts[1], config.params.bw) + || !integer(parts[2], sf) || sf > UINT8_MAX || !integer(parts[3], cr) || cr > UINT8_MAX + || (secondary && !parseMode(parts[4], config.mode))) return error("invalid radio tuple"); + config.params.sf = uint8_t(sf); config.params.cr = uint8_t(cr); + if (count > expected + extra && !preamble(parts[expected + extra], config.params.preamble)) + return error("invalid preamble"); + if (!radio_->validateProfile(config.params)) return error("unsupported radio tuple"); + auto preview = *radio_->profiles(); + if (secondary) preview.setSecondary(config, temporary); else preview.setPrimary(config.params, temporary); + if (!preview.automaticPreambleFits()) return error("automatic preamble exceeds limit"); + if (schedule_index >= 0) { + Schedule next; next.config = config; + const uint32_t epoch = (validating_ || replaying_) ? accepted_epoch_ : rtc_ ? rtc_->getCurrentTime() : 0; + if (!timestamp(parts[expected], epoch, now, next.start, next.start_ms) + || (temporary && (!timestamp(parts[expected+1], epoch, now, next.end, next.end_ms) || next.end <= next.start))) + return error("future UTC or +minutes required, within 24 days"); + if (temporary && (leases_[profile].active || (pending_[profile].active && pending_[profile].temporary))) + return error("end current temporary session first"); + if (schedules_[schedule_index].started) return error("cancel active schedule first"); + if (validating_) { snprintf(reply, capacity, "OK - schedule queued (one slot)"); return true; } + next.occupied = true; schedules_[schedule_index] = next; + snprintf(reply, capacity, "OK - schedule queued (one slot)"); return true; + } + if (temporary) { + uint32_t minutes; + if (!integer(parts[expected], minutes) || !minutes || minutes > MaxMinutes) return error("minutes must be 1..10080"); + if (schedules_[profile*2+1].occupied) return error("clear temporary schedule first"); + if (validating_) { snprintf(reply, capacity, "OK - temporary for %lu min; apply pending", (unsigned long)minutes); return true; } + cancelOverdue(profile, now, (replaying_ ? accepted_epoch_ : rtc_->getCurrentTime())); + queue(profile, config, true, false, now + ReplyDelay, now + ReplyDelay + minutes * 60000UL); + snprintf(reply, capacity, "OK - temporary for %lu min; apply pending", (unsigned long)minutes); + } else if (primary) { + if (validating_) { snprintf(reply, capacity, "OK - reboot to apply"); return true; } + cancelOverdue(profile, now, (replaying_ ? accepted_epoch_ : rtc_->getCurrentTime())); + prefs_->setFreq(config.params.freq); prefs_->setBandwidth(config.params.bw); + prefs_->setSpreadFactor(config.params.sf); prefs_->setCodingRate(config.params.cr); + primary_preamble_ = config.params.preamble; markDirty(); + snprintf(reply, capacity, "OK - reboot to apply"); + } else { + if (validating_) { snprintf(reply, capacity, "OK - saved; apply pending"); return true; } + cancelOverdue(profile, now, (replaying_ ? accepted_epoch_ : rtc_->getCurrentTime())); + saved_ = config; markDirty(); + queue(1, config, false, false, now + ReplyDelay); + snprintf(reply, capacity, "OK - saved; apply pending"); + } + return true; +} +} diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h new file mode 100644 index 000000000..5034bce95 --- /dev/null +++ b/src/helpers/RadioProfileCLI.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include "ConfigSerializer.h" + +class CommonRadioPrefs; + +namespace mesh { +class Radio; +class RTCClock; +class Dispatcher; +class Packet; + +// One scheduler/controller shared by the four mesh roles. Only the saved +// secondary, crossover setting and primary preamble enter the existing prefs. +// Leases, pending applications and the four one-slot schedules stay in RAM. +class RadioProfileCLI : public ConfigSerializer { + struct Pending { + RadioProfileConfig config; + uint32_t due = 0, end = 0, retry = 0; + int8_t schedule = -1; + bool active = false, temporary = false, save = false; + } pending_[2]; + struct Lease { uint32_t end = 0; int8_t schedule = -1; bool active = false; } leases_[2]; + struct Schedule { + RadioProfileConfig config; + uint32_t start = 0, end = 0, start_ms = 0, end_ms = 0; + bool occupied = false, started = false; + } schedules_[4]; + + bool restoring_[2] = {}; + RadioProfileConfig saved_; + uint16_t primary_preamble_ = 0; + uint8_t cross_ = 0; + CommonRadioPrefs* prefs_ = nullptr; + Radio* radio_ = nullptr; + RTCClock* rtc_ = nullptr; + Dispatcher* dispatcher_ = nullptr; + char remote_command_[160] = {}; + uint32_t remote_token_ = 0, accepted_ms_ = 0, accepted_epoch_ = 0, save_retry_ = 0; + bool save_waiting_ = false; + bool validating_ = false, replaying_ = false, reply_attached_ = false, reply_sent_ = false, reply_failed_ = false; + bool apply(uint8_t profile, const RadioProfileConfig& config, bool temporary); + void restore(uint8_t profile, uint32_t due); + void queue(uint8_t profile, const RadioProfileConfig& config, bool temporary, + bool save, uint32_t due, uint32_t end = 0); + void cancelOverdue(uint8_t profile, uint32_t now, uint32_t epoch); + void format(char* reply, size_t capacity, const RadioProfileConfig& config) const; +protected: + void structure() override; +public: + void begin(CommonRadioPrefs& prefs, Radio& radio, RTCClock& clock, Dispatcher* dispatcher = nullptr); + // Starts wait for old outgoing work to drain. Remote starts additionally + // require a successful transmission of their exact tagged CLI response. + void loop(bool can_apply = true); + bool handle(const char* command, char* reply, size_t capacity = 160, bool remote = false); + void attachReply(Packet* packet); + void onTransmitResult(uint32_t token, bool success); + bool saveDue(); + bool needsService() const; + bool awaitingReply() const { return remote_command_[0] != 0; } + void primaryChanged(); + RadioProfileParams savedPrimary() const; + const RadioProfileConfig& savedSecondary() const { return saved_; } + bool pending() const { return pending_[0].active || pending_[1].active || awaitingReply(); } +}; +} diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index 8a8eefe8c..0995f6f1f 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -20,6 +20,15 @@ int PacketQueue::countBefore(uint32_t now) const { } mesh::Packet* PacketQueue::get(uint32_t now) { + mesh::Packet* selected = peek(now); + if (selected == NULL) return NULL; + for (int i = 0; i < _num; ++i) { + if (_table[i] == selected) return removeByIdx(i); + } + return NULL; +} + +mesh::Packet* PacketQueue::peek(uint32_t now) const { uint8_t min_pri = 0xFF; int best_idx = -1; for (int j = 0; j < _num; j++) { @@ -31,16 +40,16 @@ mesh::Packet* PacketQueue::get(uint32_t now) { } if (best_idx < 0) return NULL; // empty, or all items are still in the future - mesh::Packet* top = _table[best_idx]; - int i = best_idx; - _num--; - while (i < _num) { - _table[i] = _table[i+1]; - _pri_table[i] = _pri_table[i+1]; - _schedule_table[i] = _schedule_table[i+1]; - i++; + return _table[best_idx]; +} + +bool PacketQueue::reschedule(mesh::Packet* packet, uint32_t scheduled_for) { + for (int i = 0; i < _num; ++i) { + if (_table[i] != packet) continue; + _schedule_table[i] = scheduled_for; + return true; } - return top; + return false; } mesh::Packet* PacketQueue::removeByIdx(int i) { @@ -86,12 +95,16 @@ void StaticPoolPacketManager::free(mesh::Packet* packet) { } } -void StaticPoolPacketManager::queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) { - if (packet == NULL) return; - if (!send_queue.add(packet, priority, scheduled_for)) { - MESH_DEBUG_PRINTLN("queueOutbound: send queue full, dropping packet"); - free(packet); - } +bool StaticPoolPacketManager::queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) { + return packet != NULL && send_queue.add(packet, priority, scheduled_for); +} + +mesh::Packet* StaticPoolPacketManager::peekNextOutbound(uint32_t now) const { + return send_queue.peek(now); +} + +bool StaticPoolPacketManager::deferOutbound(mesh::Packet* packet, uint32_t scheduled_for) { + return send_queue.reschedule(packet, scheduled_for); } mesh::Packet* StaticPoolPacketManager::getNextOutbound(uint32_t now) { diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index f63700e51..7fb24a81b 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -11,6 +11,8 @@ class PacketQueue { public: PacketQueue(int max_entries); mesh::Packet* get(uint32_t now); + mesh::Packet* peek(uint32_t now) const; + bool reschedule(mesh::Packet* packet, uint32_t scheduled_for); bool add(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for); int count() const { return _num; } int countBefore(uint32_t now) const; @@ -29,7 +31,9 @@ public: mesh::Packet* allocNew() override; void free(mesh::Packet* packet) override; - void queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override; + bool queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override; + mesh::Packet* peekNextOutbound(uint32_t now) const override; + bool deferOutbound(mesh::Packet* packet, uint32_t scheduled_for) override; mesh::Packet* getNextOutbound(uint32_t now) override; int getOutboundCount(uint32_t now) const override; int getOutboundTotal() const override; @@ -38,4 +42,4 @@ public: mesh::Packet* removeOutboundByIdx(int i) override; void queueInbound(mesh::Packet* packet, uint32_t scheduled_for) override; mesh::Packet* getNextInbound(uint32_t now) override; -}; \ No newline at end of file +}; diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index ae0fe0a25..91e4cf6d9 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -8,16 +8,16 @@ class CustomLLCC68Wrapper : public RadioLibWrapper { public: CustomLLCC68Wrapper(CustomLLCC68& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomLLCC68 *)_radio)->setFrequency(freq); - ((CustomLLCC68 *)_radio)->setSpreadingFactor(sf); - ((CustomLLCC68 *)_radio)->setBandwidth(bw); - ((CustomLLCC68 *)_radio)->setCodingRate(cr); - updatePreamble(sf); - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + if (((CustomLLCC68 *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE) return false; + if (((CustomLLCC68 *)_radio)->setSpreadingFactor(sf) != RADIOLIB_ERR_NONE) return false; + if (((CustomLLCC68 *)_radio)->setBandwidth(bw) != RADIOLIB_ERR_NONE) return false; + if (((CustomLLCC68 *)_radio)->setCodingRate(cr) != RADIOLIB_ERR_NONE) return false; + if (!updatePreamble(sf)) return false; + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, currentPreambleLength()); ((CustomLLCC68 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomLLCC68 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); - + return true; } bool isReceivingPacket() override { @@ -26,8 +26,6 @@ public: float getCurrentRSSI() override { return ((CustomLLCC68 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomLLCC68 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLLCC68 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomLLCC68 *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index f0fde4354..32df8dbe5 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -2,6 +2,12 @@ #include #include "MeshCore.h" +#include +#include "LR1110ProfileSwitchState.h" + +#ifndef MC_LR1110_FAST_PROFILE_SWITCH +#define MC_LR1110_FAST_PROFILE_SWITCH 1 +#endif class CustomLR1110 : public LR1110 { uint32_t _preambleMillis = 66; @@ -10,12 +16,202 @@ class CustomLR1110 : public LR1110 { bool _headerSeen = false; bool _rx_boosted = false; + LR1110ProfileSwitchState _profileSwitch{MC_LR1110_FAST_PROFILE_SWITCH != 0}; + bool _profileStandbyWarm = false; + bool _profilePacketDirty = false; + uint32_t _profileFastResumes = 0; + public: CustomLR1110(Module *mod) : LR1110(mod) { } + void setProfileSwitchOptimization(bool enabled) { + _profileSwitch.enabled = enabled; + _profileSwitch.invalidate(); + _profilePacketDirty = false; + } + + void setProfileStandbyWarm(bool enabled) { + _profileStandbyWarm = enabled; + if (!enabled) _profileSwitch.invalidate(); + } + + void beginProfileSwitch(bool continuousRx) { + _profileSwitch.begin(continuousRx, _profileStandbyWarm); + } + + void endProfileSwitch(bool success) { _profileSwitch.end(success); } + + bool profileSwitchFailed() const { return _profileSwitch.failed(); } + + uint32_t getOptimizedProfileSwitches() const { return _profileFastResumes; } + + int16_t snapshotIrqAfterRxExit(uint32_t* irq) { + uint8_t reply[6] = {}; + auto& statusWidth = mod->spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS]; + const auto savedWidth = statusWidth; + statusWidth = Module::BITS_0; + const int16_t rc = mod->SPItransferStream(nullptr, 0, false, nullptr, + reply, sizeof(reply), true); + statusWidth = savedWidth; + if (rc == RADIOLIB_ERR_NONE) { + *irq = (uint32_t(reply[2]) << 24) | (uint32_t(reply[3]) << 16) + | (uint32_t(reply[4]) << 8) | uint32_t(reply[5]); + } + return rc; + } + + int16_t standby() override { + const bool warm = _profileSwitch.active && _profileSwitch.rxValid; + if (!warm) _profileSwitch.invalidate(); + // RadioLib 7.7.1 defines STANDBY_XOSC as 0, the same as STANDBY_RC. + // The LR1110 command's XOSC selector is 1 (Semtech UM §2.1.1). + bool useFs = warm; + int16_t rc; + if (useFs) { + mod->setRfSwitchState(Module::MODE_IDLE); + rc = setFs(); + } else if (warm) { + rc = LR1110::standby(0x01, false); + } else { + rc = LR1110::standby(); + } + _profileSwitch.standbyResult(rc); + return rc; + } + + int16_t standby(uint8_t mode) override { + _profileSwitch.invalidate(); + return LR1110::standby(mode); + } + + int16_t sleep() override { + _profileSwitch.invalidate(); + return LR1110::sleep(); + } + + int16_t reset() { + _profileSwitch.invalidate(); + return LR1110::reset(); + } + + int16_t stageMode(RadioModeType_t mode, RadioModeConfig_t* cfg) override { + _profileSwitch.invalidate(); + return LR1110::stageMode(mode, cfg); + } + + int16_t setPreambleLength(size_t symbols) override { + if (_profileSwitch.canResumeFast()) { + // Keep the acknowledged RX packet tuple until its preamble changes. + if (preambleLengthLoRa != symbols) _profilePacketDirty = true; + preambleLengthLoRa = symbols; + return RADIOLIB_ERR_NONE; + } + _profileSwitch.invalidate(); + _profilePacketDirty = false; + return LR1110::setPreambleLength(symbols); + } + + int16_t setFrequency(float freq) { + if (_profileSwitch.canResumeFast() && freq == freqMHz) return RADIOLIB_ERR_NONE; + if (!_profileSwitch.canResumeFast()) _profileSwitch.invalidate(); + const int16_t rc = LR1110::setFrequency(freq); + if (rc != RADIOLIB_ERR_NONE) _profileSwitch.invalidate(); + return rc; + } + + int16_t setBandwidth(float bw, bool high = false) { + _profileSwitch.modulationValid = false; + return LR1110::setBandwidth(bw, high); + } + + int16_t setSpreadingFactor(uint8_t sf, bool legacy = false) { + _profileSwitch.modulationValid = false; + return LR1110::setSpreadingFactor(sf, legacy); + } + + int16_t setCodingRate(uint8_t cr, bool longInterleave = false) { + _profileSwitch.modulationValid = false; + return LR1110::setCodingRate(cr, longInterleave); + } + + // Ordinary packet setters revoke the acknowledged continuous-RX tuple, + // including failed writes. A later hop must rebuild normal RX first. + int16_t setCRC(uint8_t len, uint32_t initial = 0x00001D0FUL, + uint32_t polynomial = 0x00001021UL, bool inverted = true) { + _profileSwitch.invalidate(); + return LR1110::setCRC(len, initial, polynomial, inverted); + } + int16_t implicitHeader(size_t len) { + _profileSwitch.invalidate(); + return LR1110::implicitHeader(len); + } + int16_t explicitHeader() { + _profileSwitch.invalidate(); + return LR1110::explicitHeader(); + } + int16_t invertIQ(bool enabled) override { + _profileSwitch.invalidate(); + return LR1110::invertIQ(enabled); + } + int16_t setModem(ModemType_t modem) override { + _profileSwitch.invalidate(); + return LR1110::setModem(modem); + } + + int16_t setLoRaModulationParams(float bw, uint8_t sf, uint8_t cr) { + if (sf < 5 || sf > 12) return RADIOLIB_ERR_INVALID_SPREADING_FACTOR; + if (cr < 4 || cr > 8) return RADIOLIB_ERR_INVALID_CODING_RATE; + uint8_t bwCode; + if (fabsf(bw - 62.5f) < 0.01f) bwCode = RADIOLIB_LR11X0_LORA_BW_62_5; + else if (fabsf(bw - 125.0f) < 0.01f) bwCode = RADIOLIB_LR11X0_LORA_BW_125_0; + else if (fabsf(bw - 250.0f) < 0.01f) bwCode = RADIOLIB_LR11X0_LORA_BW_250_0; + else if (fabsf(bw - 500.0f) < 0.01f) bwCode = RADIOLIB_LR11X0_LORA_BW_500_0; + else return RADIOLIB_ERR_INVALID_BANDWIDTH; + if (!_profileSwitch.canResumeFast()) { + uint8_t type = RADIOLIB_LR11X0_PACKET_TYPE_NONE; + const int16_t rc = getPacketType(&type); + if (rc != RADIOLIB_ERR_NONE || type != RADIOLIB_LR11X0_PACKET_TYPE_LORA) { + _profileSwitch.invalidate(); + return rc != RADIOLIB_ERR_NONE ? rc : RADIOLIB_ERR_WRONG_MODEM; + } + } + const uint8_t effectiveLdro = ldroAuto + ? (float(uint32_t(1) << sf) / bw >= 16.0f + ? RADIOLIB_LR11X0_LORA_LDRO_ENABLED : RADIOLIB_LR11X0_LORA_LDRO_DISABLED) + : ldrOptimize; + const bool unchanged = _profileSwitch.matchesModulation(sf, bwCode, cr - 4, effectiveLdro); + const auto oldSf = spreadingFactor, oldBw = bandwidth, oldCr = codingRate, oldLdro = ldrOptimize; + const auto oldBwKhz = bandwidthKhz; + spreadingFactor = sf; + bandwidth = bwCode; + bandwidthKhz = bw; + codingRate = cr - 4; + if (unchanged) { + ldrOptimize = effectiveLdro; + return RADIOLIB_ERR_NONE; + } + const int16_t rc = setModulationParamsLoRa(sf, bwCode, codingRate, effectiveLdro); + _profileSwitch.modulationResult(rc, sf, bwCode, codingRate, ldrOptimize); + if (rc != RADIOLIB_ERR_NONE) { + spreadingFactor = oldSf; + bandwidth = oldBw; + bandwidthKhz = oldBwKhz; + codingRate = oldCr; + ldrOptimize = oldLdro; + } + return rc; + } + + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, uint8_t syncWord = RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, int8_t power = 10, uint16_t preambleLength = 8, float tcxoVoltage = 1.6) { + _profileSwitch.invalidate(); + _profilePacketDirty = false; int16_t state = LR1110::begin(freq, bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); // RadioLib begin() defaults to LDO; use the LR1110 DC/DC regulator. @@ -46,11 +242,49 @@ class CustomLR1110 : public LR1110 { bool getRxBoostedGainMode() const { return _rx_boosted; } int16_t startReceive() override { - // include the PREAMBLE_DETECTED irq bit in reported flags. - return LR1110::startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + if (_profileSwitch.canResumeFast()) { + _profileSwitch.rxValid = false; // fail closed until the RX command ACKs + int16_t rc = RADIOLIB_ERR_NONE; + if (rc == RADIOLIB_ERR_NONE) + { + uint32_t pending = 0; + rc = snapshotIrqAfterRxExit(&pending); + if (rc == RADIOLIB_ERR_NONE && pending) + rc = clearIrqState(RADIOLIB_LR11X0_IRQ_ALL); + } + if (rc == RADIOLIB_ERR_NONE && _profilePacketDirty) { + rc = setPacketParamsLoRa(preambleLengthLoRa, headerType, + RADIOLIB_LR11X0_MAX_PACKET_LENGTH, crcTypeLoRa, invertIQEnabled); + } + if (rc == RADIOLIB_ERR_NONE) { + rxTimeout = RADIOLIB_LR11X0_RX_TIMEOUT_INF; + mod->setRfSwitchState(Module::MODE_RX); + rc = setRx(rxTimeout); + } + stagedMode = RADIOLIB_RADIO_MODE_NONE; + if (rc == RADIOLIB_ERR_NONE) ++_profileFastResumes; + if (rc == RADIOLIB_ERR_NONE) _profilePacketDirty = false; + _profileSwitch.rxResult(rc, true); + return rc; + } + // Keep preamble detection visible to CAD and route header errors to DIO1 + // so the wrapper can reset the RX path before another packet is read. + const int16_t rc = LR1110::startReceive( + RADIOLIB_LR11X0_RX_TIMEOUT_INF, + RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), + RADIOLIB_IRQ_RX_DEFAULT_MASK | (1UL << RADIOLIB_IRQ_HEADER_ERR), 0); + _profileSwitch.rxResult(rc, true); + if (rc == RADIOLIB_ERR_NONE) _profilePacketDirty = false; + return rc; + } + + // Query airtime from the acknowledged LoRa software tuple without SPI. + RadioLibTime_t getTimeOnAir(size_t len) override { + return getToA(len, ModemType_t::RADIOLIB_MODEM_LORA); } bool isReceiving() { + if (isChipBusy()) return false; uint32_t irq = getIrqStatus(); bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 bool header = irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID; // bit 5 diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index e7aaeb937..27098b0c5 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -5,18 +5,25 @@ #include "LR11x0Reset.h" class CustomLR1110Wrapper : public RadioLibWrapper { +protected: + void beginProfileRetune(bool continuousRx) override { static_cast(_radio)->beginProfileSwitch(continuousRx); } + void endProfileRetune(bool success) override { static_cast(_radio)->endProfileSwitch(success); } + void setProfileStandbyWarm(bool enabled) override { static_cast(_radio)->setProfileStandbyWarm(enabled); } + bool isChipBusy() override { return static_cast(_radio)->isChipBusy(); } + public: + uint32_t minimumAirtimeMillis() const override { return 200; } CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomLR1110 *)_radio)->setFrequency(freq); - ((CustomLR1110 *)_radio)->setSpreadingFactor(sf); - ((CustomLR1110 *)_radio)->setBandwidth(bw); - ((CustomLR1110 *)_radio)->setCodingRate(cr); - updatePreamble(sf); - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + if (((CustomLR1110 *)_radio)->profileSwitchFailed()) return false; + if (((CustomLR1110 *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE) return false; + if (((CustomLR1110 *)_radio)->setLoRaModulationParams(bw, sf, cr) != RADIOLIB_ERR_NONE) return false; + if (!updatePreamble(sf)) return false; + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, currentPreambleLength()); ((CustomLR1110 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomLR1110 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); + return true; } bool isReceivingPacket() override { @@ -35,11 +42,9 @@ public: void onSendFinished() override { RadioLibWrapper::onSendFinished(); - _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts + _radio->setPreambleLength(currentPreambleLength()); // overcomes weird issues with small and big pkts } - float getLastRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); } uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } diff --git a/src/helpers/radiolib/CustomLR1121Wrapper.h b/src/helpers/radiolib/CustomLR1121Wrapper.h index 5a754cee8..9121396d9 100644 --- a/src/helpers/radiolib/CustomLR1121Wrapper.h +++ b/src/helpers/radiolib/CustomLR1121Wrapper.h @@ -6,17 +6,19 @@ class CustomLR1121Wrapper : public RadioLibWrapper { public: + uint32_t minimumAirtimeMillis() const override { return 200; } CustomLR1121Wrapper(CustomLR1121& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomLR1121 *)_radio)->setFrequency(freq); - ((CustomLR1121 *)_radio)->setSpreadingFactor(sf); - ((CustomLR1121 *)_radio)->setBandwidth(bw); - ((CustomLR1121 *)_radio)->setCodingRate(cr); - updatePreamble(sf); - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + if (((CustomLR1121 *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE) return false; + if (((CustomLR1121 *)_radio)->setSpreadingFactor(sf) != RADIOLIB_ERR_NONE) return false; + if (((CustomLR1121 *)_radio)->setBandwidth(bw, bw == 203.125f || bw == 406.25f || bw == 812.5f) != RADIOLIB_ERR_NONE) return false; + if (((CustomLR1121 *)_radio)->setCodingRate(cr) != RADIOLIB_ERR_NONE) return false; + if (!updatePreamble(sf)) return false; + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, currentPreambleLength()); ((CustomLR1121 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomLR1121 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); + return true; } void doResetAGC() override { lr11x0ResetAGC((LR11x0 *)_radio, ((CustomLR1121 *)_radio)->getFreqMHz(), getRxBoostedGainMode()); } @@ -31,7 +33,7 @@ public: void onSendFinished() override { RadioLibWrapper::onSendFinished(); - _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts + _radio->setPreambleLength(currentPreambleLength()); // overcomes weird issues with small and big pkts } uint32_t getEstAirtimeFor(int len_bytes) override { @@ -39,8 +41,6 @@ public: return airtime < 200 ? 200 : airtime; // at least 200 millis } - float getLastRSSI() const override { return ((CustomLR1121 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLR1121 *)_radio)->getSNR(); } uint8_t getSpreadingFactor() const override { return ((CustomLR1121 *)_radio)->getSpreadingFactor(); } diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 0255b93cf..6b9e1b52a 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -9,6 +9,8 @@ class CustomLR2021 : public LR2021 { uint32_t _activityAt = 0; bool _headerSeen = false; bool _rx_boosted = false; + int8_t _txPower = 0; + bool _txPowerSet = false; public: CustomLR2021(Module *mod) : LR2021(mod) { irqDioNum = LR2021_IRQ_DIO; } @@ -66,6 +68,22 @@ class CustomLR2021 : public LR2021 { return true; // success } + using LR2021::setOutputPower; + using LR2021::setFrequency; + int16_t setOutputPower(int8_t power) override { + int8_t clipped = power; + checkOutputPower(power, &clipped); + const int16_t rc = LR2021::setOutputPower(clipped); + if (rc == RADIOLIB_ERR_NONE) { _txPower = power; _txPowerSet = true; } + return rc; + } + int16_t setFrequency(float frequency) override { + const int16_t rc = LR2021::setFrequency(frequency); + // The PA table and output path depend on the current LF/HF band. + // Retain the requested level and clip to the new band's native range. + return rc == RADIOLIB_ERR_NONE && _txPowerSet ? setOutputPower(_txPower) : rc; + } + float getFreqMHz() const { return freqMHz; } bool getRxBoostedGainMode() const { return _rx_boosted; } diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index 8299e2bf5..ed970b055 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -15,20 +15,21 @@ class CustomLR2021Wrapper : public RadioLibWrapper { public: CustomLR2021Wrapper(CustomLR2021& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomLR2021 *)_radio)->setFrequency(freq); - ((CustomLR2021 *)_radio)->setSpreadingFactor(sf); - ((CustomLR2021 *)_radio)->setBandwidth(bw); - ((CustomLR2021 *)_radio)->setCodingRate(cr); - updatePreamble(sf); - applySideDetectorConfig(); - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + if (((CustomLR2021 *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE) return false; + if (((CustomLR2021 *)_radio)->setSpreadingFactor(sf) != RADIOLIB_ERR_NONE) return false; + if (((CustomLR2021 *)_radio)->setBandwidth(bw) != RADIOLIB_ERR_NONE) return false; + if (((CustomLR2021 *)_radio)->setCodingRate(cr) != RADIOLIB_ERR_NONE) return false; + if (!updatePreamble(sf)) return false; + if (applySideDetectorConfig(bw) != RADIOLIB_ERR_NONE) return false; + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, currentPreambleLength()); ((CustomLR2021 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomLR2021 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); - + return true; } bool configSideDetectors(const uint8_t* sideDetSFs, uint8_t num, float bw) override { + if (_profiles.enabled() || num > 3 || (num && sideDetSFs == nullptr)) return false; LR2021LoRaSideDetector_t tmp[3]; uint8_t sf = getSpreadingFactor(); @@ -62,10 +63,17 @@ public: return true; } - int16_t applySideDetectorConfig() { - int16_t status = ((CustomLR2021 *)_radio)->setSideDetector(_sideDet, _numSideDet); - RadioLibWrapper::idle(); // trigger startReceive() - return status; + int16_t applySideDetectorConfig(float bw) { + // Side detector SFs belong to the saved single-profile configuration. + // Suspend them while scanning profiles so a different SF/BW tuple cannot + // inherit incompatible detector settings. Restore them when scanning ends. + if (_profiles.enabled()) return ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0); + LR2021LoRaSideDetector_t detectors[3]; + for (size_t i = 0; i < _numSideDet; ++i) { + detectors[i] = _sideDet[i]; + detectors[i].ldro = calcTsym(detectors[i].sf, bw) >= 16.0f; + } + return ((CustomLR2021 *)_radio)->setSideDetector(_numSideDet ? detectors : nullptr, _numSideDet); } float calcTsym(uint8_t sf, float bw) { @@ -85,11 +93,9 @@ public: void onSendFinished() override { RadioLibWrapper::onSendFinished(); - _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts + _radio->setPreambleLength(currentPreambleLength()); // overcomes weird issues with small and big pkts } - float getLastRSSI() const override { return ((CustomLR2021 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLR2021 *)_radio)->getSNR(); } uint8_t getSpreadingFactor() const override { return ((CustomLR2021 *)_radio)->getSpreadingFactor(); } diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index a4ba4971d..631749154 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -9,15 +9,16 @@ class CustomSTM32WLxWrapper : public RadioLibWrapper { public: CustomSTM32WLxWrapper(CustomSTM32WLx& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomSTM32WLx *)_radio)->setFrequency(freq); - ((CustomSTM32WLx *)_radio)->setSpreadingFactor(sf); - ((CustomSTM32WLx *)_radio)->setBandwidth(bw); - ((CustomSTM32WLx *)_radio)->setCodingRate(cr); - updatePreamble(sf); - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + if (((CustomSTM32WLx *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE) return false; + if (((CustomSTM32WLx *)_radio)->setSpreadingFactor(sf) != RADIOLIB_ERR_NONE) return false; + if (((CustomSTM32WLx *)_radio)->setBandwidth(bw) != RADIOLIB_ERR_NONE) return false; + if (((CustomSTM32WLx *)_radio)->setCodingRate(cr) != RADIOLIB_ERR_NONE) return false; + if (!updatePreamble(sf)) return false; + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, currentPreambleLength()); ((CustomSTM32WLx *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomSTM32WLx *)_radio)->setMaxPayloadMillis(pm.payloadMillis); + return true; } bool isReceivingPacket() override { @@ -26,8 +27,6 @@ public: float getCurrentRSSI() override { return ((CustomSTM32WLx *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSTM32WLx *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSTM32WLx *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSTM32WLx *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b4ee6c97a..9fe702578 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -2,6 +2,40 @@ #include #include "MeshCore.h" +#include +#include "SX1262ProfileSwitchState.h" + +#ifndef MC_SX1262_FAST_PROFILE_SWITCH +// CustomSX1262 targets use direct radio I/O unless a board opts out. Keep +// the owned RX-to-RX path available across those boards, including roles that +// did not previously need a local profile scanner. +#define MC_SX1262_FAST_PROFILE_SWITCH 1 +#endif + +#ifndef MC_SX1262_FAST_FS +#define MC_SX1262_FAST_FS MC_SX1262_FAST_PROFILE_SWITCH +#endif + +#ifndef MC_SX1262_FAST_PACKET_CACHE +#define MC_SX1262_FAST_PACKET_CACHE MC_SX1262_FAST_PROFILE_SWITCH +#endif + +#ifndef MC_SX1262_FAST_IQ_CACHE +// An additional optimization, enabled per board only after hardware testing. +#define MC_SX1262_FAST_IQ_CACHE 0 +#endif + +#ifndef MC_SX1262_FAST_MODEM_CACHE +// Reuse the LoRa modem confirmation only inside an acknowledged owned RX hop. +// Keep this board-qualified optimization off until its hardware comparison. +#define MC_SX1262_FAST_MODEM_CACHE 0 +#endif + +#if (MC_SX1262_FAST_FS || MC_SX1262_FAST_PACKET_CACHE || MC_SX1262_FAST_IQ_CACHE || MC_SX1262_FAST_MODEM_CACHE) && !MC_SX1262_FAST_PROFILE_SWITCH +#error "SX1262 SetFs/packet/modem reuse requires the owned fast profile-switch path" +#endif + + class CustomSX1262 : public SX1262 { uint32_t _preambleMillis = 66; @@ -9,9 +43,241 @@ class CustomSX1262 : public SX1262 { uint32_t _activityAt = 0; bool _headerSeen = false; + SX1262ProfileSwitchState _profileSwitch{MC_SX1262_FAST_PROFILE_SWITCH != 0}; + uint32_t _profileFastResumes = 0; + bool _coldStandby = true; + bool _tcxoWakePending = false; + public: CustomSX1262(Module *mod) : SX1262(mod) { } + using SX1262::begin; + using SX1262::beginFSK; + using SX1262::beginLRFHSS; + void invalidateProfileCache() { + _profileSwitch.invalidate(); + _coldStandby = true; + _tcxoWakePending = false; + } + + int16_t begin(const ConfigLoRa_t& config) override { + invalidateProfileCache(); + return SX1262::begin(config); + } + + int16_t beginFSK(const ConfigFSK_t& config) override { + invalidateProfileCache(); + return SX1262::beginFSK(config); + } + + int16_t beginLRFHSS(const ConfigLRFHSS_t& config) override { + invalidateProfileCache(); + return SX1262::beginLRFHSS(config); + } + + int16_t setModem(ModemType_t modem) override { + invalidateProfileCache(); + return SX1262::setModem(modem); + } + + void setProfileSwitchOptimization(bool enabled) { + _profileSwitch.enabled = enabled; + _profileSwitch.invalidate(); + } + + void beginProfileSwitch(bool continuousRx) { _profileSwitch.begin(continuousRx, standbyXOSC); } + + void endProfileSwitch(bool success) { _profileSwitch.end(success); } + + bool profileSwitchFailed() const { return _profileSwitch.failed(); } + + uint32_t getOptimizedProfileSwitches() const { return _profileFastResumes; } + + int16_t restoreTcxoAfterSleep() { + if (!_tcxoWakePending) return RADIOLIB_ERR_NONE; + // Both RC and warm policies showed a wake-only XOSC error on the WIO + // module. Restore the existing DIO3 voltage/delay, never disable TCXO. + // RadioLib clears XOSC_START_ERR here; do not hide unrelated faults. + if (getDeviceErrors() & ~RADIOLIB_SX126X_XOSC_START_ERR) return RADIOLIB_ERR_SPI_CMD_FAILED; + _tcxoWakePending = false; // setTCXO calls our explicit standby override + const int16_t rc = SX1262::setTCXO(tcxoVoltage, tcxoDelay); + if (rc != RADIOLIB_ERR_NONE) _tcxoWakePending = true; + return rc; + } + + int16_t standby() override { + const bool alreadyAwake = !_coldStandby && _profileSwitch.active && _profileSwitch.rxValid; + if (!alreadyAwake) _profileSwitch.invalidate(); + int16_t rc; + bool useFs = MC_SX1262_FAST_FS != 0; + if (alreadyAwake && useFs) { + getMod()->setRfSwitchState(Module::MODE_IDLE); + rc = setFs(); + } else { + rc = alreadyAwake + ? SX1262::standby(RADIOLIB_SX126X_STANDBY_XOSC, false) + : _coldStandby ? SX1262::standby(RADIOLIB_SX126X_STANDBY_RC) : SX1262::standby(); + } + if (rc == RADIOLIB_ERR_NONE) rc = restoreTcxoAfterSleep(); + if (rc != RADIOLIB_ERR_NONE) _coldStandby = true; + _profileSwitch.standbyResult(rc); + return rc; + } + + int16_t standby(uint8_t mode) override { + _profileSwitch.invalidate(); + int16_t rc = SX1262::standby(mode); + if (rc == RADIOLIB_ERR_NONE) rc = restoreTcxoAfterSleep(); + if (mode == RADIOLIB_SX126X_STANDBY_RC || rc != RADIOLIB_ERR_NONE) _coldStandby = true; + _profileSwitch.standbyResult(rc); + return rc; + } + + int16_t sleep() override { return sleep(true); } + + int16_t sleep(bool retainConfig) { + _profileSwitch.invalidate(); + _coldStandby = true; + const int16_t rc = SX1262::standby(RADIOLIB_SX126X_STANDBY_RC); + _tcxoWakePending = tcxoVoltage > 0.0f; + return rc == RADIOLIB_ERR_NONE ? SX1262::sleep(retainConfig) : rc; + } + + int16_t reset(bool verify = true) { _profileSwitch.invalidate(); _coldStandby = true; _tcxoWakePending = false; return SX1262::reset(verify); } + + int16_t stageMode(RadioModeType_t mode, RadioModeConfig_t* cfg) override { + // All ordinary RX/TX staging rebuilds hardware state. In particular TX + // changes IRQ mapping, packet length and buffer contents/base settings. + _profileSwitch.invalidate(); + return SX1262::stageMode(mode, cfg); + } + + int16_t setPreambleLength(size_t symbols) override { + if (_profileSwitch.canResumeFast()) { + // The owned hop will commit the complete packet tuple once, in RX + // resume. A failed hop discards this capability before rollback. + preambleLengthLoRa = symbols; + return RADIOLIB_ERR_NONE; + } + _profileSwitch.invalidatePacket(); + return SX1262::setPreambleLength(symbols); + } + + int16_t setCRC(uint8_t len, uint16_t initial = 0x1D0F, + uint16_t polynomial = 0x1021, bool inverted = true) { + _profileSwitch.invalidatePacket(); + return SX1262::setCRC(len, initial, polynomial, inverted); + } + + int16_t implicitHeader(size_t len) { + _profileSwitch.invalidatePacket(); + return SX1262::implicitHeader(len); + } + + int16_t explicitHeader() { + _profileSwitch.invalidatePacket(); + return SX1262::explicitHeader(); + } + + int16_t invertIQ(bool enable) override { + _profileSwitch.invalidatePacket(); + return SX1262::invertIQ(enable); + } + + int16_t setBandwidth(float bw) override { + _profileSwitch.invalidateModulation(); + return SX1262::setBandwidth(bw); + } + + int16_t setSpreadingFactor(uint8_t sf) override { + _profileSwitch.invalidateModulation(); + return SX1262::setSpreadingFactor(sf); + } + + int16_t setCodingRate(uint8_t cr, bool longInterleave = false) { + _profileSwitch.invalidateModulation(); + return SX1262::setCodingRate(cr, longInterleave); + } + + int16_t forceLDRO(bool enabled) { + _profileSwitch.invalidateModulation(); + return SX1262::forceLDRO(enabled); + } + + int16_t setLoRaModulationParams(float bw, uint8_t sf, uint8_t cr) { + if (sf < 5 || sf > 12) return RADIOLIB_ERR_INVALID_SPREADING_FACTOR; + if (cr < 4 || cr > 8) return RADIOLIB_ERR_INVALID_CODING_RATE; + if (!isfinite(bw)) return RADIOLIB_ERR_INVALID_BANDWIDTH; + static constexpr float bandwidths[] = {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, + 41.7f, 62.5f, 125.0f, 250.0f, 500.0f}; + static constexpr uint8_t codes[] = { + RADIOLIB_SX126X_LORA_BW_7_8, RADIOLIB_SX126X_LORA_BW_10_4, + RADIOLIB_SX126X_LORA_BW_15_6, RADIOLIB_SX126X_LORA_BW_20_8, + RADIOLIB_SX126X_LORA_BW_31_25, RADIOLIB_SX126X_LORA_BW_41_7, + RADIOLIB_SX126X_LORA_BW_62_5, RADIOLIB_SX126X_LORA_BW_125_0, + RADIOLIB_SX126X_LORA_BW_250_0, RADIOLIB_SX126X_LORA_BW_500_0}; + size_t index = 0; + while (index < sizeof(bandwidths) / sizeof(bandwidths[0]) + && fabsf(bw - bandwidths[index]) >= 0.01f) ++index; + if (index == sizeof(bandwidths) / sizeof(bandwidths[0])) { + return RADIOLIB_ERR_INVALID_BANDWIDTH; + } + bool queryModem = true; +#if MC_SX1262_FAST_MODEM_CACHE + bool reuseModem = MC_SX1262_FAST_MODEM_CACHE != 0; + // rxValid is established only after full RX setup confirms LoRa, or by + // resuming that same owned context. A successful owned standby, warm + // oscillator and continuous RX are required. TX/CAD staging, ordinary + // standby, sleep, reset, duty cycling and command errors revoke it. + // A caller using base-qualified GODMODE/raw SPI to change packet type + // must revoke ownership first, just as for the packet/IQ caches. + queryModem = !reuseModem || !_profileSwitch.canResumeFast(); +#endif + if (queryModem && getPacketType() != RADIOLIB_SX126X_PACKET_TYPE_LORA) { + _profileSwitch.invalidate(); + return RADIOLIB_ERR_WRONG_MODEM; + } + + // Match the effective on-wire LDRO value, not merely the auto/manual + // policy. autoLDRO() changes policy without writing the chip; SF/BW can + // change its computed value. Keep the pinned RadioLib >=16 ms rule. + const uint8_t effectiveLdro = ldroAuto + ? ((float(uint32_t(1) << sf) / bw) >= 16.0f ? 1 : 0) + : ldrOptimize; + const bool unchanged = _profileSwitch.matchesModulation( + sf, codes[index], cr - 4, effectiveLdro); + const auto oldSf = spreadingFactor; + const auto oldBw = bandwidth; + const auto oldBwKhz = bandwidthKhz; + const auto oldCr = codingRate; + const auto oldLdro = ldrOptimize; + spreadingFactor = sf; + bandwidth = codes[index]; + bandwidthKhz = bw; + codingRate = cr - 4; // ordinary LoRa CR, not long-interleaving encoding + if (unchanged) { + ldrOptimize = effectiveLdro; + return RADIOLIB_ERR_NONE; // no SetModulationParams (0x8B) needed + } + const int16_t state = setModulationParams(sf, bandwidth, codingRate, ldrOptimize); + _profileSwitch.modulationResult(state, sf, bandwidth, codingRate, ldrOptimize); + if (state != RADIOLIB_ERR_NONE) { + // The wrapper restores the physical tuple after a failed command. + // Do not publish a new software tuple when hardware success is unknown. + spreadingFactor = oldSf; + bandwidth = oldBw; + bandwidthKhz = oldBwKhz; + codingRate = oldCr; + ldrOptimize = oldLdro; + } + return state; + } + + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + #ifdef RP2040_PLATFORM bool std_init(SPIClassRP2040* spi = NULL) #else @@ -100,12 +366,124 @@ class CustomSX1262 : public SX1262 { return true; // success } + #if MC_SX1262_FAST_IQ_CACHE + // Firmware-level direct register/packet writes are not an acknowledged + // owned RX tuple, even when they fail or restore the same software IQ. + int16_t writeRegister(uint16_t address, const uint8_t* data, uint8_t size) { + _profileSwitch.invalidatePacket(); + return SX1262::writeRegister(address, data, size); + } + int16_t setPacketParams(uint16_t preamble, uint8_t crc, uint8_t payload, + uint8_t header, uint8_t iq) { + _profileSwitch.invalidatePacket(); + return SX1262::setPacketParams(preamble, crc, payload, header, iq); + } + private: + int16_t writeProfilePacketParams(bool reuseIq) { + if (!reuseIq) { + return setPacketParams(preambleLengthLoRa, crcTypeLoRa, implicitLen, + headerType, invertIQEnabled); + } + // Only the acknowledged IQ workaround is retained. A changed preamble + // still needs the COMPLETE ordinary SetPacketParams command. Preserve + // its on-wire field order and Module BUSY/status/error handling. + const uint8_t data[] = { + static_cast(preambleLengthLoRa >> 8), + static_cast(preambleLengthLoRa), headerType, + static_cast(implicitLen), + crcTypeLoRa, invertIQEnabled}; + return getMod()->SPIwriteStream(RADIOLIB_SX126X_CMD_SET_PACKET_PARAMS, + data, sizeof(data)); + } + public: +#endif + + int16_t startReceive() override { - // include the PREAMBLE_DETECTED irq bit in reported flags - return SX1262::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + if (_profileSwitch.canResumeFast()) { + // Existing continuous RX already established buffer bases and the + // MeshCore IRQ map (including preamble detection). No TX/CAD/sleep/ + // reset/staging may intervene without revoking that context. + bool skipPacket = MC_SX1262_FAST_PACKET_CACHE != 0; + skipPacket = skipPacket && _profileSwitch.matchesPacket( + preambleLengthLoRa, crcTypeLoRa, implicitLen, + headerType, invertIQEnabled); +#if MC_SX1262_FAST_IQ_CACHE + bool reuseIq = MC_SX1262_FAST_IQ_CACHE != 0; + // Capture this capability BEFORE revoking rxValid for the command + // sequence. Packet/IQ setters, protocol changes, sleep, reset, TX/CAD, + // duty cycling and command failures revoke the acknowledged tuple. + reuseIq = reuseIq && _profileSwitch.matchesPacketIq(invertIQEnabled); +#endif + _profileSwitch.rxValid = false; // fail closed on any command error + int16_t rc = clearIrqStatus(); + if (rc == RADIOLIB_ERR_NONE && !skipPacket) { +#if MC_SX1262_FAST_IQ_CACHE + rc = writeProfilePacketParams(reuseIq); +#else + rc = setPacketParams(preambleLengthLoRa, crcTypeLoRa, implicitLen, + headerType, invertIQEnabled); +#endif + _profileSwitch.packetResult(rc, preambleLengthLoRa, crcTypeLoRa, + implicitLen, headerType, invertIQEnabled); + } + if (rc == RADIOLIB_ERR_NONE) { + rxTimeout = RADIOLIB_SX126X_RX_TIMEOUT_INF; + getMod()->setRfSwitchState(Module::MODE_RX); + rc = setRx(rxTimeout); // original BUSY waits and status checks + } + stagedMode = RADIOLIB_RADIO_MODE_NONE; + if (rc == RADIOLIB_ERR_NONE) ++_profileFastResumes; + _coldStandby = rc != RADIOLIB_ERR_NONE; + _profileSwitch.rxResult(rc, true); + return rc; + } + // Make preamble detection visible to CAD while retaining RadioLib's + // normal RX-complete and error events. + const int16_t rc = SX1262::startReceive( + RADIOLIB_SX126X_RX_TIMEOUT_INF, + RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), + RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + const bool reusable = rc == RADIOLIB_ERR_NONE && _profileSwitch.enabled + && getPacketType() == RADIOLIB_SX126X_PACKET_TYPE_LORA; + _coldStandby = rc != RADIOLIB_ERR_NONE; + _profileSwitch.rxResult(rc, reusable); + if (rc == RADIOLIB_ERR_NONE && reusable) { + _profileSwitch.packetResult(rc, preambleLengthLoRa, crcTypeLoRa, + implicitLen, headerType, invertIQEnabled); + } + return rc; + } + + // Query airtime from the acknowledged LoRa software tuple without SPI. + RadioLibTime_t getTimeOnAir(size_t len) override { + uint8_t cr = this->codingRate; + // RadioLib stores ordinary CR 4/5 through 4/8 as 0-4. Long-interleaving + // CR values 0-4 and 5-7 map to the same time-on-air denominators. + if (cr < 5) { + cr += 4; + } else if (cr == 7) { + cr += 1; + } + + DataRate_t data_rate = {}; + data_rate.lora.spreadingFactor = this->spreadingFactor; + data_rate.lora.bandwidth = this->bandwidthKhz; + data_rate.lora.codingRate = cr; + + PacketConfig_t packet_config = {}; + packet_config.lora.preambleLength = this->preambleLengthLoRa; + packet_config.lora.crcEnabled = (bool)this->crcTypeLoRa; + packet_config.lora.implicitHeader = + this->headerType == RADIOLIB_SX126X_LORA_HEADER_IMPLICIT; + packet_config.lora.ldrOptimize = (bool)this->ldrOptimize; + + return SX126x::calculateTimeOnAir( + ModemType_t::RADIOLIB_MODEM_LORA, data_rate, packet_config, len); } bool isReceiving() { + if (isChipBusy()) return false; uint32_t irq = getIrqFlags(); bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index be3144716..0b14cd106 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -9,18 +9,31 @@ #endif class CustomSX1262Wrapper : public RadioLibWrapper { + bool _warm_held = false, _saved_warm = false; +protected: + void beginProfileRetune(bool continuousRx) override { static_cast(_radio)->beginProfileSwitch(continuousRx); } + void endProfileRetune(bool success) override { static_cast(_radio)->endProfileSwitch(success); } + bool isChipBusy() override { return static_cast(_radio)->isChipBusy(); } + void setProfileStandbyWarm(bool enabled) override { + if (enabled == _warm_held) return; + auto* radio = static_cast(_radio); + if (enabled) { _saved_warm = radio->standbyXOSC; radio->standbyXOSC = true; } + else radio->standbyXOSC = _saved_warm; + _warm_held = enabled; + } + public: CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomSX1262 *)_radio)->setFrequency(freq); - ((CustomSX1262 *)_radio)->setSpreadingFactor(sf); - ((CustomSX1262 *)_radio)->setBandwidth(bw); - ((CustomSX1262 *)_radio)->setCodingRate(cr); - updatePreamble(sf); - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + if (((CustomSX1262 *)_radio)->profileSwitchFailed()) return false; + if (((CustomSX1262 *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE) return false; + if (((CustomSX1262 *)_radio)->setLoRaModulationParams(bw, sf, cr) != RADIOLIB_ERR_NONE) return false; + if (!updatePreamble(sf)) return false; + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, currentPreambleLength()); ((CustomSX1262 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomSX1262 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); + return true; } bool isReceivingPacket() override { @@ -29,8 +42,6 @@ public: float getCurrentRSSI() override { return ((CustomSX1262 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1262 *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 70f5dabdc..357ac8c60 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -12,15 +12,16 @@ class CustomSX1268Wrapper : public RadioLibWrapper { public: CustomSX1268Wrapper(CustomSX1268& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomSX1268 *)_radio)->setFrequency(freq); - ((CustomSX1268 *)_radio)->setSpreadingFactor(sf); - ((CustomSX1268 *)_radio)->setBandwidth(bw); - ((CustomSX1268 *)_radio)->setCodingRate(cr); - updatePreamble(sf); - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + if (((CustomSX1268 *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE) return false; + if (((CustomSX1268 *)_radio)->setSpreadingFactor(sf) != RADIOLIB_ERR_NONE) return false; + if (((CustomSX1268 *)_radio)->setBandwidth(bw) != RADIOLIB_ERR_NONE) return false; + if (((CustomSX1268 *)_radio)->setCodingRate(cr) != RADIOLIB_ERR_NONE) return false; + if (!updatePreamble(sf)) return false; + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, currentPreambleLength()); ((CustomSX1268 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomSX1268 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); + return true; } bool isReceivingPacket() override { @@ -29,8 +30,6 @@ public: float getCurrentRSSI() override { return ((CustomSX1268 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1268 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1268 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1268 *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index 9d75ce12a..f249e3a0c 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -11,12 +11,13 @@ class CustomSX1276Wrapper : public RadioLibWrapper { public: CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomSX1276 *)_radio)->setFrequency(freq); - ((CustomSX1276 *)_radio)->setSpreadingFactor(sf); - ((CustomSX1276 *)_radio)->setBandwidth(bw); - ((CustomSX1276 *)_radio)->setCodingRate(cr); - updatePreamble(sf); + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + if (((CustomSX1276 *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE) return false; + if (((CustomSX1276 *)_radio)->setSpreadingFactor(sf) != RADIOLIB_ERR_NONE) return false; + if (((CustomSX1276 *)_radio)->setBandwidth(bw) != RADIOLIB_ERR_NONE) return false; + if (((CustomSX1276 *)_radio)->setCodingRate(cr) != RADIOLIB_ERR_NONE) return false; + if (!updatePreamble(sf)) return false; + return true; } bool isReceivingPacket() override { @@ -25,8 +26,6 @@ public: float getCurrentRSSI() override { return ((CustomSX1276 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1276 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1276 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1276 *)_radio)->spreadingFactor; diff --git a/src/helpers/radiolib/ESP32BufferedRadioHal.h b/src/helpers/radiolib/ESP32BufferedRadioHal.h new file mode 100644 index 000000000..a68b2bfab --- /dev/null +++ b/src/helpers/radiolib/ESP32BufferedRadioHal.h @@ -0,0 +1,18 @@ +#pragma once +#include + +#ifndef MC_SX1262_SPI_HZ +#define MC_SX1262_SPI_HZ 8000000 +#endif + +// Opted-in ESP32 boards only. Keep RadioLib's transaction/CS, BUSY polling and +// status parsing unchanged; send each full-duplex buffer in one SPI operation. +// SX1262 boards retain their configured default; LR1110 boards pass 16 MHz. +class ESP32BufferedRadioHal : public ArduinoHal { + public: + explicit ESP32BufferedRadioHal(SPIClass& bus, uint32_t hz = MC_SX1262_SPI_HZ) + : ArduinoHal(bus, SPISettings(hz, MSBFIRST, SPI_MODE0)) {} + void spiTransfer(uint8_t* out, size_t len, uint8_t* in) override { + if (len) spi->transferBytes(out, in, len); + } +}; diff --git a/src/helpers/radiolib/LR1110ProfileSwitchState.h b/src/helpers/radiolib/LR1110ProfileSwitchState.h new file mode 100644 index 000000000..198bb8efa --- /dev/null +++ b/src/helpers/radiolib/LR1110ProfileSwitchState.h @@ -0,0 +1,68 @@ +#pragma once +#include + +// A short-lived, acknowledged continuous-RX context. It is never valid after +// sleep, TX/CAD staging, an unrelated standby, reset, or a failed command. +struct LR1110ProfileSwitchState { + bool enabled; + bool rxValid = false; + bool open = false; + bool active = false; + bool standbyReady = false; + int16_t error = 0; + bool modulationValid = false; + uint8_t modulationSf = 0, modulationBw = 0, modulationCr = 0, modulationLdro = 0; + + explicit LR1110ProfileSwitchState(bool enable) : enabled(enable) {} + void invalidate() { + rxValid = active = standbyReady = modulationValid = false; + } + void begin(bool continuousRx, bool warm) { + open = true; + error = 0; + standbyReady = false; + active = enabled && rxValid && continuousRx && warm; + } + void standbyResult(int16_t result) { + if (result != 0) { + if (open) error = result; + invalidate(); + } else if (active) { + standbyReady = true; + } + } + bool canResumeFast() const { + return open && active && rxValid && standbyReady && error == 0; + } + bool matchesModulation(uint8_t sf, uint8_t bw, uint8_t cr, uint8_t ldro) const { + return canResumeFast() && modulationValid && modulationSf == sf + && modulationBw == bw && modulationCr == cr && modulationLdro == ldro; + } + void modulationResult(int16_t result, uint8_t sf, uint8_t bw, uint8_t cr, uint8_t ldro) { + if (result != 0) { + if (open) error = result; + invalidate(); + } else { + modulationSf = sf; + modulationBw = bw; + modulationCr = cr; + modulationLdro = ldro; + modulationValid = true; + } + } + bool failed() const { return open && error != 0; } + void rxResult(int16_t result, bool reusable) { + if (result != 0) { + if (open) error = result; + invalidate(); + } else { + rxValid = enabled && reusable; + standbyReady = false; + } + } + void end(bool success) { + if (!success) invalidate(); + open = active = standbyReady = false; + error = 0; + } +}; diff --git a/src/helpers/radiolib/Nrf52BufferedRadioHal.h b/src/helpers/radiolib/Nrf52BufferedRadioHal.h new file mode 100644 index 000000000..e564b8f5f --- /dev/null +++ b/src/helpers/radiolib/Nrf52BufferedRadioHal.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +// nRF52840 SPIClass::transfer(uint8_t) starts one SPIM transaction per byte. +// RadioLib sends whole command buffers; keep its CS, BUSY, and status handling +// but transfer each buffer in one SPIM transaction. LR1110 supports 16 MHz SPI. +class Nrf52BufferedRadioHal : public ArduinoHal { +public: + explicit Nrf52BufferedRadioHal(SPIClass& bus, uint32_t hz = 16000000) + : ArduinoHal(bus, SPISettings(hz, MSBFIRST, SPI_MODE0)) {} + + void spiTransfer(uint8_t* out, size_t len, uint8_t* in) override { + if (len) spi->transfer(out, in, len); + } +}; diff --git a/src/helpers/radiolib/RadioInterruptEvents.h b/src/helpers/radiolib/RadioInterruptEvents.h new file mode 100644 index 000000000..c7cc04562 --- /dev/null +++ b/src/helpers/radiolib/RadioInterruptEvents.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +// One radio ISR publishes; its owning loop consumes. Aligned word loads/stores +// are sufficient even on Cortex-M0, where an atomic exchange needs a lock. A +// mode transition never writes the producer's generation, so an IRQ arriving +// during claim remains pending. Hardware IRQ handlers do not reenter themselves. +class RadioInterruptEvents { + uint32_t _published = 0; + uint32_t _consumed = 0; + +public: + __attribute__((always_inline)) inline void record() { + const uint32_t next = __atomic_load_n(&_published, __ATOMIC_RELAXED) + 1; + __atomic_store_n(&_published, next, __ATOMIC_RELEASE); + } + bool pending() const { + return __atomic_load_n(&_published, __ATOMIC_ACQUIRE) != _consumed; + } + bool claim() { + const uint32_t current = __atomic_load_n(&_published, __ATOMIC_ACQUIRE); + const bool ready = current != _consumed; + _consumed = current; + return ready; + } +}; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index e4d2ba1c2..0198a8054 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -1,51 +1,110 @@ - #define RADIOLIB_STATIC_ONLY 1 #include "RadioLibWrappers.h" +#include "RadioInterruptEvents.h" +#include "RadioProfileChip.h" -#define STATE_IDLE 0 -#define STATE_RX 1 -#define STATE_TX_WAIT 3 -#define STATE_TX_DONE 4 -#define STATE_INT_READY 16 +#define STATE_IDLE 0 +#define STATE_RX 1 +#define STATE_TX_WAIT 3 +#define STATE_TX_DONE 4 +#define NUM_NOISE_FLOOR_SAMPLES 64 +#define SAMPLING_THRESHOLD 14 -#define NUM_NOISE_FLOOR_SAMPLES 64 -#define SAMPLING_THRESHOLD 14 - -static volatile uint8_t state = STATE_IDLE; - -// this function is called when a complete packet -// is transmitted by the module +// The firmware has one physical transceiver. ISR publication is separate from +// loop-owned mode state: an interrupt during a mode change must remain pending. +static RadioInterruptEvents radio_interrupt; static #if defined(ESP8266) || defined(ESP32) - ICACHE_RAM_ATTR +ICACHE_RAM_ATTR +#endif +void setFlag() { radio_interrupt.record(); } + +// Extend the 24-bit RTC counter on each loop poll. A valid explicit preamble +// can require a visit longer than its 512-second hardware wrap period. +#if defined(NRF52_PLATFORM) +static uint32_t profile_clock_raw = 0; +static uint32_t profile_clock_ticks = 0; +#endif + +static void syncProfileClock(bool dual_profile) { +#if defined(NRF52_PLATFORM) + static bool active = false; + if (active == dual_profile) return; + if (dual_profile) { + NRF_RTC2->TASKS_STOP = 1; + NRF_RTC2->PRESCALER = 0; + // Reset the software origin against the retained counter. A delayed + // TASKS_CLEAR must not be mistaken for a hardware counter wrap. + profile_clock_raw = NRF_RTC2->COUNTER & 0x00ffffffUL; + profile_clock_ticks = 0; + NRF_RTC2->TASKS_START = 1; + } else { + NRF_RTC2->TASKS_STOP = 1; + } + active = dual_profile; +#else + (void) dual_profile; #endif -void setFlag(void) { - // we sent a packet, set the flag - state |= STATE_INT_READY; } +static uint32_t profileTimestamp(bool dual_profile) { + syncProfileClock(dual_profile); +#if defined(NRF52_PLATFORM) + if (dual_profile) { + const uint32_t raw = NRF_RTC2->COUNTER & 0x00ffffffUL; + profile_clock_ticks += (raw - profile_clock_raw) & 0x00ffffffUL; + profile_clock_raw = raw; + return profile_clock_ticks; + } +#endif + return micros(); +} + +static uint32_t profileElapsedUs(uint32_t since, uint32_t now, bool dual_profile) { +#if defined(NRF52_PLATFORM) + if (!dual_profile) return now - since; + // Unsigned subtraction also handles wrap of the extended 32-bit tick clock. + const uint32_t ticks = now - since; + const uint64_t elapsed = (uint64_t(ticks) * 15625 + 511) >> 9; + return elapsed > UINT32_MAX ? UINT32_MAX : static_cast(elapsed); +#else + (void) dual_profile; + return now - since; +#endif +} + + void RadioLibWrapper::begin() { - _radio->setPacketReceivedAction(setFlag); // this is also SentComplete interrupt + radio_interrupt.claim(); + _radio->setPacketReceivedAction(setFlag); _preamble_sf = getSpreadingFactor(); - _radio->setPreambleLength(preambleLengthForSF(_preamble_sf)); // longer preamble for lower SF improves reliability - state = STATE_IDLE; - - if (_board->getStartupReason() == BD_STARTUP_RX_PACKET) { // received a LoRa packet (while in deep sleep) - setFlag(); // LoRa packet is already received - } - - _noise_floor = 0; - _threshold = 0; + _physical_preamble = preambleLengthForSF(_preamble_sf); + _radio->setPreambleLength(_physical_preamble); + _state = STATE_IDLE; + _noise_floor = _threshold = 0; _cad_enabled = false; - - // start average out some samples _num_floor_samples = 0; _floor_sample_sum = 0; + _physical_params.freq = LORA_FREQ; + _physical_params.bw = LORA_BW; + _physical_params.sf = _preamble_sf; +#ifdef LORA_CR + _physical_params.cr = LORA_CR; +#else + _physical_params.cr = 5; +#endif + _profiles.primary = _physical_params; + _params_valid = true; + _profile_generation = _profiles.generation[0]; + _last_rx_generation = _profile_generation; + _profile_visit_stamp = profileTimestamp(false); + if (_board->getStartupReason() == BD_STARTUP_RX_PACKET) { + _state = STATE_RX; + setFlag(); + } } -uint32_t RadioLibWrapper::getRngSeed() { - return _radio->random(0x7FFFFFFF); -} +uint32_t RadioLibWrapper::getRngSeed() { return _radio->random(0x7FFFFFFF); } void RadioLibWrapper::setTxPower(int8_t dbm) { #if defined(USE_LR2021) @@ -56,130 +115,119 @@ void RadioLibWrapper::setTxPower(int8_t dbm) { void RadioLibWrapper::idle() { _radio->standby(); - state = STATE_IDLE; // need another startReceive() + _state = STATE_IDLE; } void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; - if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling + if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { _num_floor_samples = 0; _floor_sample_sum = 0; } } -void RadioLibWrapper::doResetAGC() { - _radio->sleep(); // warm sleep to reset analog frontend -} +void RadioLibWrapper::doResetAGC() { _radio->sleep(); } void RadioLibWrapper::resetAGC() { - // make sure we're not mid-receive of packet! - if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) return; - + if (_state == STATE_TX_WAIT || _state == STATE_TX_DONE || radio_interrupt.pending() + || isChipBusy() || isReceivingPacket()) return; doResetAGC(); - state = STATE_IDLE; // trigger a startReceive() - - // Reset noise floor sampling so it reconverges from scratch. - // Without this, a stuck _noise_floor of -120 makes the sampling threshold - // too low (-106) to accept normal samples (~-105), self-reinforcing the - // stuck value even after the receiver has recovered. + _state = STATE_IDLE; _noise_floor = 0; _num_floor_samples = 0; _floor_sample_sum = 0; } +void RadioLibWrapper::saveNoiseFloor(uint8_t profile) { + _profile_noise_floor[profile] = _noise_floor; + _profile_floor_samples[profile] = _num_floor_samples; + _profile_floor_sum[profile] = _floor_sample_sum; +} +void RadioLibWrapper::loadNoiseFloor(uint8_t profile) { + _noise_floor = _profile_noise_floor[profile]; + _num_floor_samples = _profile_floor_samples[profile]; + _floor_sample_sum = _profile_floor_sum[profile]; +} + void RadioLibWrapper::loop() { - if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + if (_state == STATE_RX && !radio_interrupt.pending() && !isChipBusy() + && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { int rssi = getCurrentRSSI(); - if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD - _num_floor_samples++; + if (rssi < _noise_floor + SAMPLING_THRESHOLD) { + ++_num_floor_samples; _floor_sample_sum += rssi; } } } else if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && _floor_sample_sum != 0) { _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; - if (_noise_floor < -120) { - _noise_floor = -120; // clamp to lower bound of -120dBi - } + if (_noise_floor < -120) _noise_floor = -120; _floor_sample_sum = 0; - - #ifdef MESH_DEBUG_NOISE_FLOOR - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); - #endif } + serviceProfiles(); } -void RadioLibWrapper::startRecv() { - #if defined(USE_LR2021) - _radio->standby(); // without this LR2021 can throw -706 when calling startReceive after hardware CAD when side detectors are enabled - #endif - int err = _radio->startReceive(); - if (err == RADIOLIB_ERR_NONE) { - state = STATE_RX; - } else { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err); - } +bool RadioLibWrapper::startRecv() { +#if defined(USE_LR2021) + _radio->standby(); +#endif + const int16_t rc = _radio->startReceive(); + _state = rc == RADIOLIB_ERR_NONE ? STATE_RX : STATE_IDLE; + if (rc != RADIOLIB_ERR_NONE) MESH_DEBUG_PRINTLN("RadioLibWrapper: startReceive(%d)", rc); + return rc == RADIOLIB_ERR_NONE; } -bool RadioLibWrapper::isInRecvMode() const { - return (state & ~STATE_INT_READY) == STATE_RX; -} +bool RadioLibWrapper::isInRecvMode() const { return _state == STATE_RX; } int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { + if (_state == STATE_TX_WAIT || _state == STATE_TX_DONE || _profile_refresh_required) return 0; int len = 0; - if (state & STATE_INT_READY) { + if (radio_interrupt.claim()) { len = _radio->getPacketLength(); if (len > 0) { - if (len > sz) { len = sz; } - int err = _radio->readData(bytes, len); - if (err != RADIOLIB_ERR_NONE) { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); + if (len > sz) len = sz; + const int16_t rc = _radio->readData(bytes, len); + if (rc != RADIOLIB_ERR_NONE) { + ++n_recv_errors; len = 0; - n_recv_errors++; } else { - // Serial.print(" readData() -> "); Serial.println(len); - n_recv++; + ++n_recv; + _last_rx_profile = _active_profile; + _last_rx_generation = _profile_generation; + _last_rssi = _radio->getRSSI(); + _last_snr = _radio->getSNR(); } } - #if defined(USE_LR2021) - state = STATE_RX; // LR2021 stays in Rx after readData, calling startReceive while still in Rx throws -706 errors - #else - state = STATE_IDLE; // need another startReceive() - #endif - } - - if (state != STATE_RX) { - int err = _radio->startReceive(); - if (err == RADIOLIB_ERR_NONE) { - state = STATE_RX; - } else { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err); - } +#if defined(USE_LR2021) + _state = STATE_RX; +#else + _state = STATE_IDLE; +#endif } + if (_state != STATE_RX && !radio_interrupt.pending()) startRecv(); return len; } -uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { - return _radio->getTimeOnAir(len_bytes) / 1000; -} +uint32_t RadioLibWrapper::getEstAirtimeFor(int len) { return _radio->getTimeOnAir(len) / 1000; } bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { + if (_state == STATE_TX_WAIT || _state == STATE_TX_DONE || radio_interrupt.pending() + || isChipBusy() || isReceivingPacket() || _profile_refresh_required) return false; _board->onBeforeTransmit(); - int err = _radio->startTransmit((uint8_t *) bytes, len); - if (err == RADIOLIB_ERR_NONE) { - state = STATE_TX_WAIT; + const int16_t rc = _radio->startTransmit((uint8_t*)bytes, len); + if (rc == RADIOLIB_ERR_NONE) { + _state = STATE_TX_WAIT; return true; } - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startTransmit(%d)", err); - idle(); // trigger another startRecv() + idle(); _board->onAfterTransmit(); return false; } bool RadioLibWrapper::isSendComplete() { - if (state & STATE_INT_READY) { - state = STATE_IDLE; - n_sent++; + if (_state == STATE_TX_WAIT && radio_interrupt.claim()) { + _state = STATE_TX_DONE; + ++n_sent; return true; } return false; @@ -188,36 +236,215 @@ bool RadioLibWrapper::isSendComplete() { void RadioLibWrapper::onSendFinished() { _radio->finishTransmit(); _board->onAfterTransmit(); - state = STATE_IDLE; + _state = STATE_IDLE; + _profile_visit_stamp = profileTimestamp(_profiles.enabled()); } -int16_t RadioLibWrapper::performChannelScan() { - return _radio->scanChannel(); -} +int16_t RadioLibWrapper::performChannelScan() { return _radio->scanChannel(); } bool RadioLibWrapper::isChannelActive() { - // int.thresh: RSSI-based interference detection (relative to noise floor) + if (radio_interrupt.pending() || isChipBusy() || isReceivingPacket()) return true; if (_threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true; - - // cad: hardware channel activity detection if (_cad_enabled) { - int16_t result = performChannelScan(); - // scanChannel() triggers DIO interrupt (CAD done) which sets STATE_INT_READY - // via setFlag() ISR. Clear it before restarting RX so recvRaw() doesn't - // try to read a non-existent packet and count a spurious recv error. - state = STATE_IDLE; + const int16_t result = performChannelScan(); + radio_interrupt.claim(); // This completion belongs to the synchronous CAD. + _state = STATE_IDLE; startRecv(); - if (result != RADIOLIB_CHANNEL_FREE) return true; + return result != RADIOLIB_CHANNEL_FREE; } - return false; } -float RadioLibWrapper::getLastRSSI() const { - return _radio->getRSSI(); +float RadioLibWrapper::getLastRSSI() const { return _last_rssi; } +float RadioLibWrapper::getLastSNR() const { return _last_snr; } + +// 0/1 = safely stopped (whether to resume RX), 2 = busy, 3 = command failure. +uint8_t RadioLibWrapper::beginReconfigure() { + if (_state == STATE_TX_WAIT || _state == STATE_TX_DONE || isChipBusy() + || (!_profile_refresh_required && (radio_interrupt.pending() || isReceivingPacket()))) return 2; + // After a failed rollback, no FIFO contents have a trustworthy profile. + // Discard those events and rebuild RX before allowing delivery or TX. + if (_profile_refresh_required) radio_interrupt.claim(); + const bool resume = _state == STATE_RX || _profile_refresh_required; + if (resume) { + if (_radio->standby() != RADIOLIB_ERR_NONE) return 3; + if (_profile_refresh_required) radio_interrupt.claim(); + // A completed packet on a known tuple retains its old FIFO and profile. + else if (radio_interrupt.pending()) return 2; + } + _state = STATE_IDLE; + return resume; } -float RadioLibWrapper::getLastSNR() const { - return _radio->getSNR(); + +mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { + using Result = mesh::RadioParamApplyResult; + if (profile > 1 || (profile == 1 && !_profiles.enabled())) return Result::FAILED; + const auto p = _profiles.params(profile); + const uint16_t preamble = profilePreamble(profile); + if (!validateProfile(p) || !mesh::RadioProfiles::safePreamble(p, preamble)) return Result::FAILED; + if (!_profile_refresh_required && _active_profile == profile && _params_valid + && _profile_generation == _profiles.generation[profile] + && _physical_params == p && _physical_preamble == preamble + && _physical_dual == _profiles.enabled()) return Result::APPLIED; + const bool dual = _profiles.enabled(); + const uint32_t started = profileTimestamp(dual); + beginProfileRetune(_state == STATE_RX); + const uint8_t resume = beginReconfigure(); + if (resume > 1) { + endProfileRetune(resume == 2); + if (resume == 3) { _state = STATE_IDLE; _profile_refresh_required = true; } + return resume == 2 ? Result::BUSY : Result::FAILED; + } + const auto previous = _physical_params; + const uint16_t previous_preamble = _physical_preamble; + const uint8_t previous_profile = _active_profile; + const uint32_t previous_generation = _profile_generation; + const bool had_previous = _params_valid; + _physical_preamble = preamble; + bool applied = applyParams(p.freq, p.bw, p.sf, p.cr); + if (applied && resume) applied = startRecv(); + endProfileRetune(applied); + if (applied) { + saveNoiseFloor(previous_profile); + _physical_params = p; + _physical_dual = dual; + _params_valid = true; + _active_profile = profile; + _profile_generation = _profiles.generation[profile]; + _profile_refresh_required = false; + if (profile != previous_profile) loadNoiseFloor(profile); + ++_profiles.switches; + } else { + ++_profiles.switch_failures; + _physical_preamble = previous_preamble; + // Full setup after revoking fast ownership repairs partial command writes. + bool restored = had_previous && _radio->standby() == RADIOLIB_ERR_NONE + && applyParams(previous.freq, previous.bw, previous.sf, previous.cr); + if (restored && resume) restored = startRecv(); + _physical_params = previous; + _active_profile = previous_profile; + _profile_generation = previous_generation; + _params_valid = had_previous; + _profile_refresh_required = !restored; + } + _profile_visit_stamp = profileTimestamp(dual); + const uint32_t elapsed = profileElapsedUs(started, _profile_visit_stamp, dual); + if (elapsed > _profiles.longest_switch_us) _profiles.longest_switch_us = elapsed; + if (applied && resume && previous_profile != profile) + _profiles.sampleSwitch(previous_profile, profile, elapsed); + return applied ? Result::APPLIED : Result::FAILED; +} + +void RadioLibWrapper::serviceProfiles() { + const bool dual = _profiles.enabled(); + // Poll even during RX/TX deferrals so long packet activity cannot hide an + // RTC hardware wrap from the software extension. + const uint32_t now = profileTimestamp(dual); + if (_state == STATE_TX_WAIT || _state == STATE_TX_DONE || isChipBusy() + || (!_profile_refresh_required && (radio_interrupt.pending() || isReceivingPacket()))) return; + const bool changed = _profile_scan_generation[0] != _profiles.generation[0] + || _profile_scan_generation[1] != _profiles.generation[1]; + setProfileStandbyWarm(dual); + uint8_t target = _active_profile; + if (!dual) target = 0; + else if (changed) target = _profiles.slowerProfile(); + else if (profileElapsedUs(_profile_visit_stamp, now, dual) + >= _profiles.listenUs(_active_profile, profilePreamble(_profiles.slowerProfile()))) + target ^= 1; + const auto result = tuneProfile(target); + if (changed && result == mesh::RadioParamApplyResult::APPLIED) { + _profile_scan_generation[0] = _profiles.generation[0]; + _profile_scan_generation[1] = _profiles.generation[1]; + // New profile settings must collect their own interference baseline. + _profile_noise_floor[0] = _profile_noise_floor[1] = 0; + _profile_floor_samples[0] = _profile_floor_samples[1] = 0; + _profile_floor_sum[0] = _profile_floor_sum[1] = 0; + _noise_floor = 0; + _num_floor_samples = 0; + _floor_sample_sum = 0; + } +} + +mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile) { + if (!_profiles.canTransmit(profile)) return mesh::RadioParamApplyResult::FAILED; + return tuneProfile(profile); +} + +uint32_t RadioLibWrapper::getProfileAirtime(uint8_t profile, int len, uint8_t cr) { + if (profile > 1 || len < 0 || len > MAX_TRANS_UNIT) return 0; + const auto& p = _profiles.params(profile); + if (!validateProfile(p)) return 0; + const double symbol = mesh::RadioProfiles::symbolUs(p); + if (cr < 5 || cr > 8) cr = p.cr; + const int de = symbol >= 16000 +#if MESH_RADIO_PROFILE_CHIP == 1121 + // Match pinned RadioLib's high-band SX128x compatibility rule. + || (p.freq > 1000 && p.sf > 10) +#endif + ? 1 : 0; + const double coded = ceil((8.0 * len - 4 * p.sf + (p.sf <= 6 ? 20 : 28) + 16) + / (4 * (p.sf - 2 * de))); + const double payload = 8 + (coded > 0 ? coded * cr : 0); + const double airtime = ceil((profilePreamble(profile) + (p.sf <= 6 ? 6.25 : 4.25) + + payload) * symbol / 1000.0); + const uint32_t rounded = airtime > UINT32_MAX ? UINT32_MAX : uint32_t(airtime); + return rounded < minimumAirtimeMillis() ? minimumAirtimeMillis() : rounded; +} + +mesh::RadioParamApplyResult RadioLibWrapper::trySetPrimaryParams(const mesh::RadioProfileParams& p, + bool temporary, const uint32_t* timings) { + (void)timings; // Upstream uses continuous RX; no RX duty-cycle setting exists. + auto preview = _profiles; + preview.setPrimary(p, temporary); + if (!validateProfile(p) || !preview.automaticPreambleFits()) + return mesh::RadioParamApplyResult::FAILED; + const auto previous = _profiles; + _profiles = preview; + const auto result = tuneProfile(0); + if (result != mesh::RadioParamApplyResult::APPLIED) _profiles = previous; + return result; +} + +mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, uint8_t sf, + uint8_t cr, const uint32_t* timings) { + auto p = _profiles.primary; + p.freq = freq; p.bw = bw; p.sf = sf; p.cr = cr; + return trySetPrimaryParams(p, _profiles.primary_temporary, timings); +} + +bool RadioLibWrapper::validateProfile(const mesh::RadioProfileParams& p) const { + if (!mesh::RadioProfiles::valid(p)) return false; +#if MESH_RADIO_PROFILE_CHIP != 1110 && MESH_RADIO_PROFILE_CHIP != 1121 && MESH_RADIO_PROFILE_CHIP != 2021 && MESH_RADIO_PROFILE_CHIP != 68 && MESH_RADIO_PROFILE_CHIP != 1272 + static constexpr float bandwidths[] = {7.8f,10.4f,15.6f,20.8f,31.25f,41.7f,62.5f,125,250,500}; + bool supported = false; + for (float bw : bandwidths) if (fabsf(p.bw - bw) < 0.01f) supported = true; + if (!supported) return false; +#endif +#if MESH_RADIO_PROFILE_CHIP == 1276 + return p.freq >= 137 && p.freq <= 1020 && p.sf >= 7 && p.bw <= 500; +#elif MESH_RADIO_PROFILE_CHIP == 1272 + return p.freq >= 860 && p.freq <= 1020 && p.sf >= 7 + && (p.bw == 125 || p.bw == 250 || p.bw == 500); +#elif MESH_RADIO_PROFILE_CHIP == 68 + return p.freq >= 150 && p.freq <= 960 + && ((p.bw == 125 && p.sf <= 9) || (p.bw == 250 && p.sf <= 10) + || (p.bw == 500 && p.sf <= 11)); +#elif MESH_RADIO_PROFILE_CHIP == 2021 + return ((p.freq >= 150 && p.freq <= 1090) || (p.freq >= 1900 && p.freq <= 2200) + || (p.freq >= 2400 && p.freq <= 2500)) && p.bw >= 31.25 && p.bw <= 1000; +#elif MESH_RADIO_PROFILE_CHIP == 1110 + return p.freq >= 150 && p.freq <= 960 + && (p.bw == 62.5 || p.bw == 125 || p.bw == 250 || p.bw == 500); +#elif MESH_RADIO_PROFILE_CHIP == 1121 + return ((p.freq >= 150 && p.freq <= 960) || (p.freq >= 1900 && p.freq <= 2200) + || (p.freq >= 2400 && p.freq <= 2500)) + && (p.bw == 62.5 || p.bw == 125 || p.bw == 250 || p.bw == 500 + || (p.freq >= 1900 && (p.bw == 203.125f || p.bw == 406.25f || p.bw == 812.5f))); +#elif MESH_RADIO_PROFILE_CHIP == 1268 + return p.freq >= 410 && p.freq <= 810 && p.bw <= 500; +#else + return p.freq >= 150 && p.freq <= 960 && p.bw <= 500; +#endif } // Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) @@ -231,7 +458,7 @@ static float snr_threshold[] = { }; float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { - if (sf < 7) return 0.0f; + if (sf < 7 || sf > 12) return 0.0f; if (snr < snr_threshold[sf - 7]) return 0.0f; // Below threshold, no chance of success @@ -241,7 +468,7 @@ float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) { return max(0.0, min(1.0, success_rate_based_on_snr * collision_penalty)); } -PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols) { +PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint16_t preambleSymbols) { // based on RadioLib's calculateTimeOnAir() uint32_t tsym_us = ((uint32_t)10000 << sf) / (bw * 10); uint32_t sfCoeff1_x4 = (sf == 5 || sf == 6) ? 25 : 17; // 6.25 : 4.25, semtech magic numbers to account for sync word + sfd @@ -252,9 +479,9 @@ PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t // airtime for max packet at current radio settings uint32_t total_us = _radio->getTimeOnAir(MAX_TRANS_UNIT); // airtime for payload only (no preamble, header or SOF) - uint32_t payload_us = total_us > preamble_us ? total_us - preamble_us : 4000 - preamble_us; // fallback to 4 secs at worst case + uint32_t payload_us = total_us > preamble_us ? total_us - preamble_us : (preamble_us < 4000000UL ? 4000000UL - preamble_us : 0); // fallback to 4 secs at worst case // rescale payload_us for max possible CR - if (cr >= 5 && cr < 8) { payload_us = (payload_us * 8) / cr; } + if (cr >= 5 && cr < 8) { payload_us = (uint64_t(payload_us) * 8) / cr; } return PacketMillis {(preamble_us + 999) / 1000, (payload_us + 999) / 1000}; } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116..554c4d4a7 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,6 +2,7 @@ #include #include +#include #ifdef USE_CC310_HW_CRYPTO #include @@ -23,13 +24,38 @@ protected: uint8_t _preamble_sf; void idle(); - void startRecv(); + bool startRecv(); + virtual bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) = 0; + virtual bool isChipBusy() { return false; } + virtual uint32_t minimumAirtimeMillis() const { return 0; } + virtual void beginProfileRetune(bool) {} + virtual void endProfileRetune(bool) {} + virtual void setProfileStandbyWarm(bool) {} + uint8_t beginReconfigure(); + mesh::RadioParamApplyResult tuneProfile(uint8_t profile); + void serviceProfiles(); + void saveNoiseFloor(uint8_t profile); + void loadNoiseFloor(uint8_t profile); + + mesh::RadioProfiles _profiles; + mesh::RadioProfileParams _physical_params; + uint8_t _state = 0, _active_profile = 0, _last_rx_profile = 0; + uint32_t _profile_generation = 1, _last_rx_generation = 1; + uint16_t _physical_preamble = 0; + bool _params_valid = false, _profile_refresh_required = false, _physical_dual = false; + uint32_t _profile_visit_stamp = 0; + uint32_t _profile_scan_generation[2] = {}; + float _last_rssi = 0, _last_snr = 0; + int16_t _profile_noise_floor[2] = {}; + uint16_t _profile_floor_samples[2] = {}; + int32_t _profile_floor_sum[2] = {}; + float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); public: - RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; } + RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = n_recv_errors = 0; } void begin() override; virtual void powerOff() { _radio->sleep(); } @@ -47,15 +73,35 @@ public: return isChannelActive(); } - virtual void setParams(float freq, float bw, uint8_t sf, uint8_t cr) = 0; + mesh::RadioProfiles* profiles() override { return &_profiles; } + const mesh::RadioProfiles* profiles() const override { return &_profiles; } + bool validateProfile(const mesh::RadioProfileParams& params) const override; + mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams& params, + bool temporary = false, const uint32_t* timings = nullptr) override; + mesh::RadioParamApplyResult trySetParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* timings = nullptr); + bool setParams(float freq, float bw, uint8_t sf, uint8_t cr) { + return trySetParams(freq, bw, sf, cr) == mesh::RadioParamApplyResult::APPLIED; + } + mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override; + uint32_t getProfileAirtime(uint8_t profile, int len_bytes, uint8_t cr = 0) override; + uint8_t receiveProfile() const override { return _last_rx_profile; } + uint32_t receiveProfileGeneration() const override { return _last_rx_generation; } + uint16_t profilePreamble(uint8_t profile) const override { + return _profiles.preamble(profile, preambleLengthForSF(_profiles.params(profile).sf)); + } uint32_t getRngSeed(); void setTxPower(int8_t dbm); virtual float getCurrentRSSI() =0; virtual uint8_t getSpreadingFactor() const { return LORA_SF; } static uint16_t preambleLengthForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; } - void updatePreamble(uint8_t sf) { _preamble_sf = sf; _radio->setPreambleLength(preambleLengthForSF(sf)); } - PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols); + bool updatePreamble(uint8_t sf) { + _preamble_sf = sf; + return _radio->setPreambleLength(_physical_preamble ? _physical_preamble : preambleLengthForSF(sf)) == RADIOLIB_ERR_NONE; + } + uint16_t currentPreambleLength() const { return _physical_preamble; } + PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint16_t preambleSymbols); virtual int16_t performChannelScan(); int getNoiseFloor() const override { return _noise_floor; } diff --git a/src/helpers/radiolib/RadioProfileChip.h b/src/helpers/radiolib/RadioProfileChip.h new file mode 100644 index 000000000..b570acd76 --- /dev/null +++ b/src/helpers/radiolib/RadioProfileChip.h @@ -0,0 +1,35 @@ +#pragma once + +// Variant headers can advertise more than one optional chip (Generic E22 +// defines both USE_SX1262 and USE_SX1268). Validate the concrete transceiver +// selected by the build, not whichever optional USE_* branch appears first. +#define MESH_PROFILE_CHIP_CustomSX1262 1262 +#define MESH_PROFILE_CHIP_CustomSTM32WLx 1262 +#define MESH_PROFILE_CHIP_CustomSX1268 1268 +#define MESH_PROFILE_CHIP_CustomSX1276 1276 +#define MESH_PROFILE_CHIP_CustomLLCC68 68 +#define MESH_PROFILE_CHIP_CustomLR1110 1110 +#define MESH_PROFILE_CHIP_CustomLR1121 1121 +#define MESH_PROFILE_CHIP_CustomLR2021 2021 +#define MESH_PROFILE_CHIP_(name) MESH_PROFILE_CHIP_##name +#define MESH_PROFILE_CHIP(name) MESH_PROFILE_CHIP_(name) + +#if defined(RADIO_CLASS) + #define MESH_RADIO_PROFILE_CHIP MESH_PROFILE_CHIP(RADIO_CLASS) +#elif defined(USE_SX1276) + #define MESH_RADIO_PROFILE_CHIP 1276 +#elif defined(USE_SX1272) + #define MESH_RADIO_PROFILE_CHIP 1272 +#elif defined(USE_LLCC68) + #define MESH_RADIO_PROFILE_CHIP 68 +#elif defined(USE_LR2021) + #define MESH_RADIO_PROFILE_CHIP 2021 +#elif defined(USE_LR1110) + #define MESH_RADIO_PROFILE_CHIP 1110 +#elif defined(USE_LR1121) + #define MESH_RADIO_PROFILE_CHIP 1121 +#elif defined(USE_SX1268) + #define MESH_RADIO_PROFILE_CHIP 1268 +#else + #define MESH_RADIO_PROFILE_CHIP 1262 +#endif diff --git a/src/helpers/radiolib/SX1262ProfileSwitchState.h b/src/helpers/radiolib/SX1262ProfileSwitchState.h new file mode 100644 index 000000000..5dfed042e --- /dev/null +++ b/src/helpers/radiolib/SX1262ProfileSwitchState.h @@ -0,0 +1,99 @@ +#pragma once +#include +#include + +// A short-lived capability for reusing a fully configured continuous-RX +// context. Standby outside the owned hop, staging, sleep and reset revoke it. +struct SX1262ProfileSwitchState { + bool enabled; + bool rxValid = false; + bool open = false; + bool active = false; + bool standbyReady = false; + int16_t error = 0; + bool modulationValid = false; + uint8_t modulationSf = 0, modulationBw = 0, modulationCr = 0, modulationLdro = 0; + bool packetValid = false; + size_t packetPreamble = 0; + uint8_t packetCrc = 0, packetImplicitLen = 0, packetHeader = 0, packetIq = 0; + + explicit SX1262ProfileSwitchState(bool enable) : enabled(enable) {} + void invalidateModulation() { modulationValid = false; } + void invalidatePacket() { packetValid = false; } + void invalidate() { + rxValid = active = standbyReady = false; + invalidateModulation(); + invalidatePacket(); + } + void begin(bool continuousRx, bool warm) { + open = true; + error = 0; + standbyReady = false; + active = enabled && rxValid && continuousRx && warm; + } + void standbyResult(int16_t result) { + if (result != 0) { + if (open) error = result; + invalidate(); + } else if (active) { + standbyReady = true; + } + } + bool canResumeFast() const { return open && active && rxValid && standbyReady && error == 0; } + // Only trust an acknowledged tuple inside the same owned RX context. The + // normal RadioLib software fields alone do not prove a write succeeded. + bool matchesModulation(uint8_t sf, uint8_t bw, uint8_t cr, uint8_t ldro) const { + return canResumeFast() && modulationValid && modulationSf == sf + && modulationBw == bw && modulationCr == cr && modulationLdro == ldro; + } + void modulationResult(int16_t result, uint8_t sf, uint8_t bw, uint8_t cr, uint8_t ldro) { + if (result != 0) { + if (open) error = result; + invalidate(); + return; + } + modulationSf = sf; + modulationBw = bw; + modulationCr = cr; + modulationLdro = ldro; + modulationValid = true; + } + bool matchesPacket(size_t preamble, uint8_t crc, uint8_t implicitLen, + uint8_t header, uint8_t iq) const { + return canResumeFast() && packetValid && packetPreamble == preamble + && packetCrc == crc && packetImplicitLen == implicitLen + && packetHeader == header && packetIq == iq; + } + bool matchesPacketIq(uint8_t iq) const { + return canResumeFast() && packetValid && packetIq == iq; + } + void packetResult(int16_t result, size_t preamble, uint8_t crc, + uint8_t implicitLen, uint8_t header, uint8_t iq) { + if (result != 0) { + if (open) error = result; + invalidate(); + return; + } + packetPreamble = preamble; + packetCrc = crc; + packetImplicitLen = implicitLen; + packetHeader = header; + packetIq = iq; + packetValid = true; + } + bool failed() const { return open && error != 0; } + void rxResult(int16_t result, bool reusable) { + if (result != 0) { + if (open) error = result; + invalidate(); + } else { + rxValid = enabled && reusable; + standbyReady = false; + } + } + void end(bool success) { + if (!success) invalidate(); + open = active = standbyReady = false; + error = 0; + } +}; diff --git a/test/fixtures/radio_profiles/chirp_math.cpp b/test/fixtures/radio_profiles/chirp_math.cpp new file mode 100644 index 000000000..8a6fdf01d --- /dev/null +++ b/test/fixtures/radio_profiles/chirp_math.cpp @@ -0,0 +1,238 @@ +#include +#include +#include +#include +#include +#include + +using Profiles=mesh::RadioProfiles; +mesh::RadioProfileParams params(unsigned sf,float bw) { + mesh::RadioProfileParams p; p.freq=909.5;p.sf=sf;p.bw=bw;p.cr=5;return p; +} +static bool originalSafePreamble(const mesh::RadioProfileParams& p, uint16_t symbols) { + const double symbol = Profiles::symbolUs(p); + if (symbol <= 0) return false; + const int de = symbol >= 16000 ? 1 : 0; + const double payload = 8 + std::ceil((8.0 * 255 - 4 * p.sf + + (p.sf <= 6 ? 20 : 28) + 16) / (4 * (p.sf - 2 * de))) * 8; + return (symbols + (p.sf <= 6 ? 6.25 : 4.25) + payload) * symbol * 4 <= UINT32_MAX; +} +static void checkSafePreamble(unsigned sf, float bw, uint16_t symbols) { + const auto p = params(sf, bw); + assert(Profiles::safePreamble(p, symbols) == originalSafePreamble(p, symbols)); +} +int main() { + // Sharing the read-only validation table must preserve every supported + // value and the strict float tolerance on both sides of its boundary. + for (float bw : {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, + 62.5f, 125.0f, 250.0f, 500.0f, 1000.0f, 812.5f, 1625.0f}) { + auto p = params(7, bw); + assert(Profiles::valid(p)); + const float lower = bw - 0.01f, upper = bw + 0.01f; + for (float inside : {std::nextafter(lower, bw), std::nextafter(upper, bw)}) { + p.bw = inside; + assert(std::fabs(inside - bw) < 0.01f && Profiles::valid(p)); + } + for (float outside : {std::nextafter(lower, -std::numeric_limits::infinity()), + std::nextafter(upper, std::numeric_limits::infinity())}) { + p.bw = outside; + assert(std::fabs(outside - bw) >= 0.01f && !Profiles::valid(p)); + } + } + for (float unsupported : {0.0f, 1.0f, 8.0f, 12.0f, 100.0f, 1500.0f, 2000.0f}) + assert(!Profiles::valid(params(7, unsupported))); + for (float nonfinite : {std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}) { + assert(!Profiles::valid(params(7, nonfinite))); + auto p = params(7, 125); + p.freq = nonfinite; + assert(!Profiles::valid(p)); + } + // Compare the production integer ceiling with the original double formula, + // including invalid SFs where zero/negative denominators were observable. + for (unsigned sf = 0; sf < 256; ++sf) { + const float transition = float(double(1u << (sf & 31)) * 1000 / 16000); + for (float bw : {0.0f, -0.0f, -1.0f, 7.8f, 62.5f, 125.0f, 500.0f, + 1000.0f, transition, std::nextafter(transition, 0.0f), + std::nextafter(transition, std::numeric_limits::infinity()), + std::numeric_limits::min(), std::numeric_limits::max(), + std::numeric_limits::denorm_min(), + std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}) { + for (uint16_t symbols : {0, 1, 8, 16, 32, 128, 1024, 65528, 65535}) + checkSafePreamble(sf, bw, symbols); + } + } + for (unsigned sf = 0; sf <= 12; ++sf) + for (float bw : {0.001f, 7.8f, 125.0f}) + for (unsigned symbols = 0; symbols <= UINT16_MAX; ++symbols) + checkSafePreamble(sf, bw, uint16_t(symbols)); + uint32_t random = 0x89abcdef; + for (unsigned i = 0; i < 16384; ++i) { + random ^= random << 13; random ^= random >> 17; random ^= random << 5; + float bw; + static_assert(sizeof(bw) == sizeof(random), "Radio bandwidth is a 32-bit float"); + std::memcpy(&bw, &random, sizeof(bw)); + checkSafePreamble((i & 1) ? i % 13 : random & 255, bw, uint16_t(random >> 16)); + } + assert(Profiles::MinListenSymbols==4.6); + assert(Profiles::SlowListenSymbols==4.6); + assert(Profiles::MinFastListenSymbols==4.6); + assert(Profiles::LoopBudgetUs==0); + auto a=params(10,125),b=params(10,125); + auto t=Profiles::calculateChirpTiming(a,b,1,0,1000,4000); + assert(t.valid && t.slow==0 && t.symbol_us[0]==8192); + assert(t.listen_us[0]==37684 && t.listen_us[1]==37684); + assert(t.preamble[0]==32 && t.preamble[1]==32); + // The function accepts board-specific budgets; a V4 measurement must not + // become a universal allowance for the GPIO-expander Indicator. + a=params(6,125);b=a; + auto fast=Profiles::calculateChirpTiming(a,b,0,0,1000,4000); + auto indicator=Profiles::calculateChirpTiming(a,b,0,0,9000,4000); + assert(indicator.preamble[0]>fast.preamble[0]); + assert(indicator.preamble[1]>fast.preamble[1]); + assert(fast.listen_us[0]>=4.6*512 && fast.listen_us[1]>=4.6*512); + b.bw=0;assert(!Profiles::calculateChirpTiming(a,b).valid); + b.bw=NAN;assert(!Profiles::calculateChirpTiming(a,b).valid); + b=params(13,125);assert(!Profiles::calculateChirpTiming(a,b).valid); + b=params(10,1e-30f);assert(Profiles::minimumListenUs(b)==0); + a=params(7,62.5);b=params(8,500); + t=Profiles::calculateChirpTiming(a,b); + assert(t.preamble[1]==40); // Derived from dwell and acquisition, not a pair-specific floor. + auto swapped=Profiles::calculateChirpTiming(b,a); + assert(swapped.slow==1 && swapped.preamble[0]==40); + assert(swapped.preamble[1]==t.preamble[0]); + for (float bw0: {7.8f,62.5f,125.0f,250.0f,500.0f}) + for (float bw1: {7.8f,62.5f,125.0f,250.0f,500.0f}) + for (unsigned sf0=5;sf0<=12;++sf0) for(unsigned sf1=5;sf1<=12;++sf1) { + Profiles p;p.primary=params(sf0,bw0);p.secondary.params=params(sf1,bw1); + p.secondary.mode=mesh::RadioProfileMode::Rx; + for (unsigned n=0;n=32 && std::fmod(minimum.preamble[i],8)==0); + assert(p.automaticPreamble(i)==minimum.preamble[i]); + } + if (!p.automaticPreambleFits()) continue; + if (p.automaticPreamble(0)>Profiles::MaxAutomaticPreamble + || p.automaticPreamble(1)>Profiles::MaxAutomaticPreamble) { + assert(p.preamble(0,32)<=512 && p.preamble(1,32)<=512); + continue; + } + auto actual=p.chirpTiming(); + assert(actual.valid); + assert(p.listenUs(actual.slow)==Profiles::minimumListenUs(p.params(actual.slow),true)); + assert(p.listenUs(actual.slow^1)>=Profiles::minimumListenUs(p.params(actual.slow^1))); + const double available=std::floor(p.preamble(actual.slow,32)*actual.symbol_us[actual.slow]/2 + -p.listenUs(actual.slow)-2*p.switchBudgetUs()-Profiles::LoopBudgetUs); + const uint32_t fast_floor=Profiles::minimumListenUs(p.params(actual.slow^1)); + assert(p.listenUs(actual.slow^1)==(available>fast_floor ? (uint32_t)available : fast_floor)); + assert(2*actual.cycle_us<=p.preamble(actual.slow,32)*actual.symbol_us[actual.slow]+1e-6); + assert(actual.preamble[0]<=p.preamble(0,32)); + assert(actual.preamble[1]<=p.preamble(1,32)); + } + Profiles p;p.primary=params(7,62.5);p.secondary.params=params(7,500); + p.secondary.mode=mesh::RadioProfileMode::Rx; + assert(Profiles::MaxAutomaticPreamble==512); + assert(p.preamble(0,32)==512 && p.preamble(1,32)==512 && !p.switchTestReady()); + for (unsigned n=0;nbefore.preamble[1]); + assert(p.preamble(0,32)>=wire0 && p.preamble(1,32)>=wire1); + p.resetSwitchTest(); + for (unsigned n=0;n21847); + p.secondary.params.preamble=32; + assert(p.preamble(1,32)==32 && p.chirpTiming().preamble[1]>32); + p.secondary.mode=mesh::RadioProfileMode::Off; + assert(!p.chirpTiming().valid); + assert(p.listenUs(0)==9421); // Same minimum helper; single-profile RX does not scan. + // Exact tested SF8/500 pair, explicit 32 preserved; order is not hard-coded. + p.primary=params(7,62.5);p.primary.preamble=32; + p.secondary.params=params(8,500);p.secondary.params.preamble=32; + p.secondary.mode=mesh::RadioProfileMode::Rx; + assert(p.automaticPreambleFits() && p.listenUs(0)==9421 && p.listenUs(1)==22147); + assert(p.preamble(0,32)==32 && p.preamble(1,32)==32); + assert(p.chirpTiming().preamble[1]==40); + std::swap(p.primary,p.secondary.params); + assert(p.slowerProfile()==1 && p.listenUs(1)==9421 && p.listenUs(0)==22147); + p.secondary.params.preamble=8; + assert(!p.automaticPreambleFits()); + // With no fixed loop reserve, 27 symbols cannot cover both 4.6-symbol visits. + p.primary=params(7,500);p.secondary.params=params(7,500); + p.primary.preamble=27; + assert(p.listenUs(0)==1178 && p.listenUs(1)>=1178); + assert(!p.automaticPreambleFits()); + p.primary.preamble=32; + assert(p.automaticPreambleFits() && p.listenUs(1)==1718); + // Long requested visits remain visible, not replaced by policy minima. + t=Profiles::calculateChirpTiming(params(7,62.5),params(8,500),20000,30000); + assert(t.listen_us[0]==20000 && t.listen_us[1]==30000 && t.cycle_us==50000); + Profiles measured; + measured.primary=params(7,500); + measured.secondary.params=params(7,500); + measured.secondary.mode=mesh::RadioProfileMode::Rx; + assert(!measured.switchTestReady() && measured.switchBudgetUs()==0); + assert(measured.preamble(0,32)==512 && measured.preamble(1,32)==512); + measured.sampleSwitch(0,0,10000); measured.sampleSwitch(0,1,0); + assert(measured.switch_test_samples[0]==0); + for (unsigned n=0;n=32 && measured.preamble(0,32)%8==0); + assert(measured.preamble(1,32)>=32 && measured.preamble(1,32)%8==0); + measured.sampleSwitch(0,1,8428); + assert(measured.switchBudgetUs()==9271); + assert(measured.automaticPreamble(0)>128); + assert(measured.preamble(0,32)==168 && measured.chirpTiming().preamble[0]==168); + // Ratios requiring 129..512 symbols must no longer be clipped at 128. + Profiles usa_fast; + usa_fast.primary=params(7,62.5);usa_fast.secondary.params=params(5,500); + usa_fast.secondary.mode=mesh::RadioProfileMode::RxTx; + for (unsigned n=0;n128 && usa_fast.automaticPreamble(1)<=512); + assert(usa_fast.preamble(1,32)==usa_fast.automaticPreamble(1)); + assert(usa_fast.preamble(1,32)%8==0); + // Keep the new cap and uncapped diagnostics independently tested. + measured.sampleSwitch(0,1,40000); + assert(measured.automaticPreamble(0)>512 && measured.preamble(0,32)==512); + assert(measured.chirpTiming().preamble[0]>512); + measured.secondary.params.preamble=88; // explicit overrides remain unchanged + assert(measured.preamble(1,32)==88); + auto replacement=measured.secondary;replacement.params.freq=910.5; + measured.setSecondary(replacement,true); + assert(!measured.switchTestReady() && measured.switchBudgetUs()==0); + assert(measured.preamble(0,32)==512 && measured.preamble(1,32)==88); + + // A T1000-E-sized 977 us hop calibrates to 1075 us, yet SF8/500 no longer + // inherits the historical 88-symbol floor or a fixed 300 us loop pad. + Profiles t1000; + t1000.primary=params(7,62.5); + t1000.secondary.params=params(8,500); + t1000.secondary.mode=mesh::RadioProfileMode::RxTx; + for (unsigned n=0;n +#include +#include +#include +#include +#include +uint32_t test_millis = 0; +bool CommonRadioPrefs::setByKey(const char*, const char*) { return false; } +bool CommonRadioPrefs::getByKey(const char*, char*, size_t) { return false; } +class Prefs : public CommonRadioPrefs { +protected: + void structure() override { CommonRadioPrefs::structure(); def("freq",freq); def("bw",bw); def("sf",sf); def("cr",cr); } +public: + float freq = 869.525f, bw = 125; + uint8_t sf = 8, cr = 5; + float getFreq()const override{return freq;} void setFreq(float f)override{freq=f;markDirty();} + float getBandwidth()const override{return bw;} void setBandwidth(float v)override{bw=v;markDirty();} + uint8_t getSpreadFactor()const override{return sf;} void setSpreadFactor(uint8_t v)override{sf=v;markDirty();} + uint8_t getCodingRate()const override{return cr;} void setCodingRate(uint8_t v)override{cr=v;markDirty();} + float getAirtimeFactor()const override{return 0;} void setAirtimeFactor(float)override{} + bool isCadEnabled()const override{return false;} void setCadEnabled(bool)override{} + uint8_t getIntThresh()const override{return 0;} void setIntThresh(uint8_t)override{} + uint8_t getRxGain()const override{return 0;} void setRxGain(uint8_t)override{} + uint8_t getTxPower()const override{return 0;} void setTxPower(uint8_t)override{} + float getRxDelay()const override{return 0;} void setRxDelay(float)override{} + uint16_t getAgcResetInt()const override{return 0;} void setAgcResetInt(uint16_t)override{} + uint8_t getHashMode()const override{return 0;} void setHashMode(uint8_t)override{} + uint8_t getMultiAcks()const override{return 0;} void setMultiAcks(uint8_t)override{} + float getFloodTxDelay()const override{return 0;} void setFloodTxDelay(float)override{} + float getDirectTxDelay()const override{return 0;} void setDirectTxDelay(float)override{} + uint8_t getFEMRxGain()const override{return 0;} void setFEMRxGain(uint8_t)override{} + uint8_t getFEMTxGain()const override{return 0;} void setFEMTxGain(uint8_t)override{} +}; +struct Rig { + Prefs prefs; mesh::Radio radio; mesh::RTCClock rtc; mesh::Dispatcher dispatcher; + mesh::RadioProfileCLI& cli; + Rig():cli(prefs.profileCLI()) { cli.begin(prefs,radio,rtc,&dispatcher); cli.loop(); prefs.clearDirty(); } + std::string cmd(const char* s, bool remote=false) { char reply[160]={}; assert(cli.handle(s,reply,sizeof(reply),remote)); return reply; } + void ok(const char* s,bool remote=false) { auto r=cmd(s,remote); if(r.find("OK")!=0) {fprintf(stderr,"%s => %s\n",s,r.c_str());abort();} } + void error(const char* s) {auto r=cmd(s);assert(r.find("Error") == 0);} + void tick(uint32_t elapsed, bool idle=true) {test_millis+=elapsed;cli.loop(idle);} +}; +class MemoryStream : public Stream { +public: + std::string text; size_t pos=0; + MemoryStream(const std::string& s=""):text(s){} + int available()override{return int(text.size()-pos);} int read()override{return pos off"); + r.ok("set tempradioat 920,125,8,5,+1,+5");r.ok("set radioat 910,125,8,5,+2"); + r.tick(180000,false);assert(r.radio.state.primary.freq!=920);r.tick(0);assert(r.radio.state.primary.freq==920); + r.tick(0);assert(r.radio.state.primary.freq==910);assert(!r.radio.state.primary_temporary);r.tick(200000);assert(r.radio.state.primary.freq==910); + r.ok("set radio2at 915,125,8,5,rx,+1");r.ok("set tempradio2at 916,125,8,5,rxtx,+1,+2"); + r.tick(60000);assert(r.radio.state.secondary.params.freq==915);r.tick(0);assert(r.radio.state.secondary.params.freq==916); + r.ok("del tempradioat2 all");r.tick(2000);assert(r.radio.state.secondary.params.freq==915);assert(r.cmd("get tempradio2at")=="> off"); + r.ok("set radio2at 930,125,8,5,rx,+1");r.tick(60000,false);r.ok("set radio2 940,125,8,5,rx"); + r.tick(2000);r.tick(0);assert(r.radio.state.secondary.params.freq==940);assert(r.cmd("get radio2at")=="> off"); + r.ok("set radio2at 950,125,8,5,rx,+1");r.ok("set radio2 945,125,8,5,rx");r.tick(2000);assert(r.radio.state.secondary.params.freq==945); + r.tick(58000);assert(r.radio.state.secondary.params.freq==950); + assert(!r.cli.needsService() || r.prefs.isDirty()); + r.prefs.clearDirty();assert(!r.cli.needsService());r.ok("set radio2at 951,125,8,5,rx,+1");assert(r.cli.needsService()); + r.ok("del radio2at");assert(!r.cli.needsService()); + } else if(name=="failure") { + r.ok("set radioat 915,125,8,5,+1");r.radio.result=mesh::RadioParamApplyResult::BUSY;r.tick(60000);assert(r.prefs.freq!=915);assert(!r.prefs.isDirty()); + r.radio.result=mesh::RadioParamApplyResult::FAILED;r.tick(1000);assert(r.prefs.freq!=915);assert(!r.prefs.isDirty()); + r.radio.result=mesh::RadioParamApplyResult::APPLIED;r.tick(999);assert(r.prefs.freq!=915);r.tick(1);assert(r.prefs.freq==915);assert(r.prefs.isDirty()); + } else if(name=="remote") { + r.ok("set radio2 915,125,8,5,rxtx",true);assert(!r.prefs.isDirty());mesh::Packet response;r.cli.attachReply(&response);assert(response.transmit_token); + r.cli.onTransmitResult(response.transmit_token+1,true);r.tick(10000,false);assert(!r.radio.state.enabled()); + r.cli.onTransmitResult(response.transmit_token,false);r.tick(0,true);assert(!r.radio.state.enabled());assert(!r.prefs.isDirty());assert(!r.cli.awaitingReply()); + r.ok("set tempradio 920,125,8,5,1",true);r.cli.attachReply(&response);r.tick(5000,false);assert(r.radio.state.primary.freq!=920); + r.cli.onTransmitResult(response.transmit_token,false);r.cli.onTransmitResult(response.transmit_token,true);r.tick(0,false);assert(r.radio.state.primary.freq!=920); + r.tick(0,true);assert(r.radio.state.primary.freq==920);r.tick(57000);assert(r.radio.state.primary.freq!=920); + r.ok("set radio2 915,125,8,5,rx",true);r.cli.attachReply(nullptr);r.tick(2000);assert(!r.radio.state.enabled());assert(!r.prefs.isDirty()); + } else if(name=="remote_clock") { + r.ok("set tempradio2 915,125,8,5,rx,1",true);mesh::Packet response;r.cli.attachReply(&response); + r.rtc.epoch+=100000;r.tick(62000,false);r.cli.onTransmitResult(response.transmit_token,true);r.tick(0);assert(!r.radio.state.enabled()); + r.rtc.epoch=1700000000;r.ok("set radio2at 915,125,8,5,rx,+1",true);r.cli.attachReply(&response);r.tick(45000,false); + r.rtc.epoch-=1000;r.cli.onTransmitResult(response.transmit_token,true);r.tick(0);assert(!r.radio.state.enabled());r.tick(15000);assert(r.radio.state.enabled()); + r.ok("set tempradioat 920,125,8,5,+1,+2",true);r.cli.attachReply(&response);r.tick(130000,false);r.cli.onTransmitResult(response.transmit_token,true);r.tick(0);assert(!r.radio.state.primary_temporary); + } else if(name=="serialize") { + r.ok("set radio 915,250,7,6,48");r.ok("set radio2 920,125,8,5,rxtx,40");r.ok("set radio2.cross off");r.tick(2000); + r.ok("set tempradio 925,125,8,5,1");r.ok("set radio2at 926,125,8,5,rx,+1");MemoryStream out;assert(r.prefs.saveSerial(out)); + Prefs loaded;MemoryStream in(out.text);assert(loaded.loadSerial(in));mesh::Radio radio;mesh::RTCClock rtc;loaded.profileCLI().begin(loaded,radio,rtc);loaded.profileCLI().loop(); + assert(loaded.freq==915&&loaded.sf==7&&loaded.cr==6&&loaded.bw==250);assert(radio.state.primary.preamble==48); + assert(radio.state.secondary.params.freq==920&&radio.state.secondary.params.bw==125&&radio.state.secondary.params.sf==8&&radio.state.secondary.params.cr==5); + assert(radio.state.secondary.params.preamble==40&&radio.state.secondary.mode==mesh::RadioProfileMode::RxTx&&radio.state.cross==mesh::RadioCrossMode::Off); + char reply[160];loaded.profileCLI().handle("get radio2at",reply);assert(!strcmp(reply,"> off"));loaded.profileCLI().handle("get tempradio",reply);assert(!strcmp(reply,"> off")); + Prefs legacy;MemoryStream old("{freq:868.5,bw:125,sf:9,cr:5}");assert(legacy.loadSerial(old));mesh::Radio oldradio;legacy.profileCLI().begin(legacy,oldradio,rtc);legacy.profileCLI().loop(); + assert(!oldradio.state.enabled()&&oldradio.state.primary.preamble==0&&oldradio.state.cross==mesh::RadioCrossMode::Auto); + test_millis=UINT32_MAX-500;r.ok("set radio2.cross on");assert(r.cli.saveDue());assert(!r.cli.saveDue()); + test_millis+=1000;assert(r.cli.saveDue());r.prefs.clearDirty();assert(!r.cli.saveDue()); + } else if(name=="legacy") { + r.radio.supported=false;char reply[160];assert(!r.cli.handle("get radio",reply));assert(!r.cli.handle("set radio 915,125,8,5",reply));assert(!r.cli.handle("tempradio 915,125,8,5,1",reply)); + assert(r.cli.handle("get radio2",reply));assert(!strncmp(reply,"Error",5)); + } else assert(false); +} diff --git a/test/fixtures/radio_profiles/cli_backend.h b/test/fixtures/radio_profiles/cli_backend.h new file mode 100644 index 000000000..bdad6312c --- /dev/null +++ b/test/fixtures/radio_profiles/cli_backend.h @@ -0,0 +1,27 @@ +#pragma once +#include +namespace mesh { +class Packet { public: uint32_t transmit_token = 0; }; +class RTCClock { public: uint32_t epoch = 1700000000; uint32_t getCurrentTime() { return epoch; } }; +class Radio { +public: + RadioProfiles state; + RadioParamApplyResult result = RadioParamApplyResult::APPLIED; + bool supported = true; + unsigned applies = 0; + RadioProfiles* profiles() { return supported ? &state : nullptr; } + bool validateProfile(const RadioProfileParams& p) const { return RadioProfiles::valid(p); } + RadioParamApplyResult trySetPrimaryParams(const RadioProfileParams& p, bool temp = false) { + ++applies; + if (result == RadioParamApplyResult::APPLIED) state.setPrimary(p, temp); + return result; + } + uint16_t profilePreamble(uint8_t p) const { return state.params(p).preamble; } +}; +class Dispatcher { + uint32_t next = 0; +public: + uint32_t reserveTransmitToken() { return ++next; } + void setTransmitToken(Packet* packet, uint32_t token) { packet->transmit_token = token; } +}; +} diff --git a/test/fixtures/radio_profiles/dispatcher.cpp b/test/fixtures/radio_profiles/dispatcher.cpp new file mode 100644 index 000000000..c86839555 --- /dev/null +++ b/test/fixtures/radio_profiles/dispatcher.cpp @@ -0,0 +1,330 @@ +#include +#include +#include +#include +#include +#include +#include + +struct Clock : mesh::MillisecondClock { + uint32_t now = 100; + unsigned long getMillis() override { return now; } +}; + +struct Radio : mesh::Radio { + mesh::RadioProfiles config; + bool expose_profiles = true, prepare_busy = false, complete = true, start_success = true; + uint8_t selected = 0, busy_mask = 0; + int failed_profile = -1; + unsigned loop_count = 0; + uint32_t airtime = 10; + std::vector prepared, sent, incoming; + std::vector> raw_sent; + Radio() { + config.primary.freq = 909.5f; config.primary.bw = 62.5f; + config.primary.sf = 7; config.primary.cr = 5; + config.secondary.params = config.primary; + config.secondary.params.freq = 910.5f; + config.secondary.params.bw = 500; + config.secondary.mode = mesh::RadioProfileMode::RxTx; + config.cross = mesh::RadioCrossMode::Off; + } + mesh::RadioProfiles* profiles() override { return expose_profiles ? &config : nullptr; } + const mesh::RadioProfiles* profiles() const override { return expose_profiles ? &config : nullptr; } + uint8_t receiveProfile() const override { return selected; } + void loop() override { ++loop_count; } + int recvRaw(uint8_t* bytes, int maximum) override { + assert(incoming.size() <= size_t(maximum)); + const int size = incoming.size(); + if (size) memcpy(bytes, incoming.data(), size); + incoming.clear(); + return size; + } + uint32_t getEstAirtimeFor(int) override { return airtime; } + uint32_t getProfileAirtime(uint8_t, int, uint8_t = 0) override { return airtime; } + float packetScore(float, int) override { return 1; } + mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t target) override { + prepared.push_back(target); + if (prepare_busy) return mesh::RadioParamApplyResult::BUSY; + if (failed_profile == target) return mesh::RadioParamApplyResult::FAILED; + selected = target; + return mesh::RadioParamApplyResult::APPLIED; + } + bool startSendRaw(const uint8_t* bytes, int size) override { + if (!start_success) return false; + sent.push_back(selected); + raw_sent.emplace_back(bytes, bytes + size); + return true; + } + bool isSendComplete() override { return complete; } + void onSendFinished() override { } + bool isInRecvMode() const override { return true; } + bool isReceiving() override { return busy_mask & (1U << selected); } +}; + +struct Node : mesh::Dispatcher { + bool reply = false, forward = false; + int rx_delay = 0; + unsigned received = 0; + uint8_t received_profile = 0xff; + float budget_factor = 0; + uint32_t budget_window = 100000; + std::vector> results; + Node(Radio& radio, Clock& clock, mesh::PacketManager& manager) + : Dispatcher(radio, clock, manager) { } + mesh::DispatcherAction onRecvPacket(mesh::Packet* packet) override { + ++received; + received_profile = packet->radio_profile; + if (reply) { + auto* response = obtainNewPacket(); + assert(response); + fill(response); + sendPacket(response, 0); + } + return forward ? ACTION_RETRANSMIT(0) : ACTION_RELEASE; + } + int calcRxDelay(float, uint32_t) const override { return rx_delay; } + float getAirtimeBudgetFactor() const override { return budget_factor; } + unsigned long getDutyCycleWindowMs() const override { return budget_window; } + void onTransmitResult(uint32_t token, bool success) override { results.emplace_back(token, success); } + static void fill(mesh::Packet* packet) { + packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RESPONSE << PH_TYPE_SHIFT); + packet->path_len = 0; + packet->payload_len = 2; + packet->payload[0] = 0x55; packet->payload[1] = 0x66; + } + mesh::Packet* local(uint32_t delay = 0) { + auto* packet = obtainNewPacket(); + assert(packet); + fill(packet); + sendPacket(packet, 0, delay); + return packet; + } + void replyOn(uint8_t profile, uint32_t generation, uint32_t delay = 0) { + ReceiveProfileScope context(*this, profile, generation); + local(delay); + } +}; + +void tick(Node& node, Clock& clock, unsigned elapsed = 1) { + clock.now += elapsed; + node.loop(); +} + +void receive(Radio& radio, uint8_t profile, bool flood = false) { + radio.selected = profile; + radio.incoming = {uint8_t((flood ? ROUTE_TYPE_FLOOD : ROUTE_TYPE_DIRECT) + | (PAYLOAD_TYPE_REQ << PH_TYPE_SHIFT)), 0, 0x55, 0x66}; +} + +int main(int argc, char** argv) { + assert(argc == 2); + const std::string scenario = argv[1]; + if (scenario == "affinity") { + Clock clock; Radio radio; StaticPoolPacketManager manager(12); Node node(radio, clock, manager); + node.begin(); node.reply = true; + receive(radio, 1); tick(node, clock); + assert(node.received == 1 && node.received_profile == 1); + assert(radio.sent == std::vector({1})); + assert(radio.config.rx_packets[1] == 1); + assert(node.hasPendingTransmissions()); + tick(node, clock); + assert(!node.hasPendingTransmissions() && radio.config.tx_packets[1] == 1); + assert(manager.getFreeCount() == 12); + node.local(); tick(node, clock); + assert(radio.sent.back() == 0); // RX affinity did not leak to local work. + tick(node, clock); + node.rx_delay = 100; + receive(radio, 1, true); tick(node, clock); + radio.selected = 0; // Scanner moved before the delayed request was handled. + tick(node, clock, 101); tick(node, clock); + assert(node.received == 2 && radio.sent.back() == 1); + assert(manager.getFreeCount() == 12); + } else if (scenario == "matrix") { + for (unsigned temporary = 0; temporary < 4; ++temporary) + for (unsigned cross = 0; cross < 3; ++cross) + for (unsigned mode = 1; mode <= 2; ++mode) + for (unsigned origin = 0; origin < 2; ++origin) { + Clock clock; Radio radio; StaticPoolPacketManager manager(6); Node node(radio, clock, manager); + radio.config.primary_temporary = temporary & 1; + radio.config.secondary_temporary = temporary & 2; + radio.config.cross = mesh::RadioCrossMode(cross); + radio.config.secondary.mode = mesh::RadioProfileMode(mode); + node.begin(); + node.replyOn(origin, radio.config.generation[origin]); + const bool crossing = cross == 1 || (cross == 0 && bool(temporary & 1) == bool(temporary & 2)); + unsigned expected = origin == 0 || mode == 2 ? 1U << origin : 0; + if (crossing && ((origin ^ 1) == 0 || mode == 2)) expected |= 1U << (origin ^ 1); + for (unsigned i = 0; i < 5; ++i) tick(node, clock); + unsigned actual = 0; + for (auto profile : radio.sent) { assert(!(actual & (1U << profile))); actual |= 1U << profile; } + assert(actual == expected); + assert(manager.getFreeCount() == 6); + } + } else if (scenario == "stale") { + Clock clock; Radio radio; StaticPoolPacketManager manager(8); Node node(radio, clock, manager); + node.begin(); radio.config.cross = mesh::RadioCrossMode::On; + node.local(100); + ++radio.config.generation[0]; // Invalidates origin and both queued copies. + tick(node, clock, 101); tick(node, clock); + assert(radio.sent.empty() && manager.getFreeCount() == 8); + node.local(); + radio.config.cross = mesh::RadioCrossMode::Off; + tick(node, clock); tick(node, clock); tick(node, clock); + assert(radio.sent == std::vector({0}) && manager.getFreeCount() == 8); + node.replyOn(1, radio.config.generation[1], 100); + ++radio.config.generation[1]; + tick(node, clock, 101); + assert(radio.sent.size() == 1 && manager.getFreeCount() == 8); + node.rx_delay = 100; + receive(radio, 1, true); tick(node, clock); + ++radio.config.generation[1]; + tick(node, clock, 101); + assert(node.received == 0 && manager.getFreeCount() == 8); + } else if (scenario == "cad") { + Clock clock; Radio radio; StaticPoolPacketManager manager(8); Node node(radio, clock, manager); + radio.config.cross = mesh::RadioCrossMode::On; + node.begin(); radio.busy_mask = 1; node.local(); + tick(node, clock); + assert(radio.sent.empty() && manager.getOutboundTotal() == 2); + tick(node, clock); + assert(radio.sent == std::vector({1})); // Busy primary did not block secondary. + tick(node, clock); + radio.busy_mask = 0; tick(node, clock, 201); tick(node, clock); + assert(radio.sent == std::vector({1, 0}) && manager.getFreeCount() == 8); + } else if (scenario == "busy") { + Clock clock; Radio radio; StaticPoolPacketManager manager(6); Node node(radio, clock, manager); + node.begin(); node.local(); radio.prepare_busy = true; + tick(node, clock); + assert(radio.sent.empty() && manager.getOutboundTotal() == 1 && !node.hasOutbound()); + radio.prepare_busy = false; tick(node, clock); tick(node, clock); + assert(radio.sent == std::vector({0}) && manager.getFreeCount() == 6); + radio.failed_profile = 0; node.local(); tick(node, clock); + assert(radio.sent.size() == 1 && manager.getFreeCount() == 6); + } else if (scenario == "cad_generation") { + Clock clock; Radio radio; StaticPoolPacketManager manager(6); Node node(radio, clock, manager); + node.begin(); radio.busy_mask = 1; node.local(); tick(node, clock); + ++radio.config.generation[0]; node.local(); + tick(node, clock, 5000); // Discard the old session's deferred queue item. + tick(node, clock); // The new session starts a fresh CAD interval. + assert(radio.sent.empty() && manager.getOutboundTotal() == 1); + } else if (scenario == "budget") { + Clock clock; Radio radio; StaticPoolPacketManager manager(6); Node node(radio, clock, manager); + node.budget_factor = 99; node.budget_window = 1000; radio.airtime = 100; + radio.config.cross = mesh::RadioCrossMode::On; + node.begin(); node.local(); tick(node, clock); tick(node, clock, 10); + assert(radio.prepared.empty() && radio.sent.empty() && radio.loop_count == 2); + assert(manager.getOutboundTotal() == 2); // No tune before airtime credit. + } else if (scenario == "shared_budget") { + Clock clock; Radio radio; StaticPoolPacketManager manager(6); Node node(radio, clock, manager); + node.budget_factor = 1; node.budget_window = 400; radio.airtime = 40; + radio.config.cross = mesh::RadioCrossMode::On; + node.begin(); node.local(); tick(node, clock); + tick(node, clock, 40); // First completion; the shared TX timestamp is now due. + tick(node, clock); // Next-profile start on the following dispatch pass. + tick(node, clock, 40); + assert(radio.sent == std::vector({0, 1})); + assert(node.getTotalAirTime() == 80); + assert(node.getRemainingTxBudget() == 140); // One common credit balance. + assert(manager.getFreeCount() == 6); + } else if (scenario == "queue") { + mesh::Packet a, b; + PacketQueue queue(1); + assert(queue.add(&a, 0, 0xfffffff0)); + assert(!queue.add(&b, 0, 0)); + assert(queue.peek(0xfffffff1) == &a); + assert(queue.reschedule(&a, 10)); + assert(queue.peek(0xfffffff1) == nullptr); + assert(queue.get(11) == &a && queue.count() == 0); + Clock clock; Radio radio; StaticPoolPacketManager manager(1); Node node(radio, clock, manager); + radio.config.cross = mesh::RadioCrossMode::On; + node.begin(); node.local(); + assert(manager.getOutboundTotal() == 1 && manager.getFreeCount() == 0); + tick(node, clock); tick(node, clock); + assert(radio.sent == std::vector({0}) && manager.getFreeCount() == 1); + } else if (scenario == "completion") { + { + struct RejectManager : StaticPoolPacketManager { + RejectManager() : StaticPoolPacketManager(1) { } + bool queueOutbound(mesh::Packet*, uint8_t, uint32_t) override { return false; } + } rejected; + Clock clock; Radio radio; Node node(radio, clock, rejected); node.begin(); + auto* packet = node.obtainNewPacket(); assert(packet); Node::fill(packet); + const uint32_t token = node.reserveTransmitToken(); node.setTransmitToken(packet, token); + node.sendPacket(packet, 0); + assert(node.results == (std::vector>{{token, false}})); + assert(rejected.getFreeCount() == 1); + } + Clock clock; Radio radio; StaticPoolPacketManager manager(6); Node node(radio, clock, manager); + node.begin(); + auto tagged = [&]() { + auto* packet = node.obtainNewPacket(); assert(packet); Node::fill(packet); + const uint32_t token = node.reserveTransmitToken(); assert(token); + node.setTransmitToken(packet, token); node.sendPacket(packet, 0); + return token; + }; + uint32_t token = tagged(); tick(node, clock); + assert(node.results.empty()); // Starting a TX is not a successful response. + tick(node, clock); + assert(node.results == (std::vector>{{token, true}})); + node.local(); tick(node, clock); tick(node, clock); + assert(node.results.size() == 1); // An unrelated packet cannot satisfy a barrier. + radio.start_success = false; token = tagged(); tick(node, clock); + assert(node.results.back() == std::make_pair(token, false)); + radio.start_success = true; radio.complete = false; token = tagged(); tick(node, clock); + tick(node, clock, 100); + assert(node.results.back() == std::make_pair(token, false)); // TX timeout. + radio.complete = true; radio.failed_profile = 0; token = tagged(); tick(node, clock); + assert(node.results.back() == std::make_pair(token, false)); // Failed tuple preparation. + radio.failed_profile = -1; token = tagged(); ++radio.config.generation[0]; tick(node, clock); + assert(node.results.back() == std::make_pair(token, false)); // Stale session rejection. + radio.config.cross = mesh::RadioCrossMode::On; radio.failed_profile = 0; + token = tagged(); tick(node, clock); tick(node, clock); tick(node, clock); + assert(node.results[node.results.size() - 2] == std::make_pair(token, false)); + assert(node.results.back() == std::make_pair(token, true)); // Other crossed copy succeeds. + assert(manager.getFreeCount() == 6 && !node.hasPendingTransmissions()); + } else if (scenario == "dedup") { + SimpleMeshTables table; + mesh::Packet primary; Node::fill(&primary); + primary.radio_profile = 0; primary.radio_generation = 1; + mesh::Packet secondary = primary; + secondary.radio_profile = 1; secondary.radio_generation = 99; + assert(!table.wasSeen(&primary)); table.markSeen(&primary); + // Profiles share one Mesh node and one history, including when crossover + // is disabled. Changing route/session metadata does not create a new hash. + assert(table.wasSeen(&secondary)); + table.clear(&secondary); + assert(!table.wasSeen(&primary)); + secondary.header = (secondary.header & ~PH_ROUTE_MASK) | ROUTE_TYPE_FLOOD; + table.markSeen(&secondary); + assert(table.wasSeen(&primary)); // Route changes cannot create bridge loops. + } else if (scenario == "wire") { + mesh::Packet packet; Node::fill(&packet); + uint8_t raw[255], changed[255]; + uint8_t hash_before[MAX_HASH_SIZE], hash_after[MAX_HASH_SIZE]; + packet.calculatePacketHash(hash_before); + const unsigned length = packet.writeTo(raw); + packet.radio_profile = 1; packet.radio_origin = 1; + packet.radio_generation = 99; packet.radio_origin_generation = 77; packet.radio_bound = true; + packet.transmit_token = 123; + assert(packet.writeTo(changed) == length && memcmp(raw, changed, length) == 0); + packet.calculatePacketHash(hash_after); + assert(memcmp(hash_before, hash_after, sizeof(hash_before)) == 0); + assert(packet.readFrom(raw, length)); + assert(packet.radio_profile == 0 && packet.radio_origin == 0 && !packet.radio_bound); + assert(packet.radio_generation == 0 && packet.radio_origin_generation == 0); + assert(packet.transmit_token == 0); + Clock clock; Radio radio; StaticPoolPacketManager manager(3); Node node(radio, clock, manager); + radio.expose_profiles = false; node.begin(); node.local(); tick(node, clock); tick(node, clock); + assert(radio.sent == std::vector({0}) && manager.getFreeCount() == 3); + } else if (scenario == "wrap") { + Clock clock; clock.now = 0xfffffff0; + Radio radio; StaticPoolPacketManager manager(3); Node node(radio, clock, manager); + node.begin(); node.local(32); + tick(node, clock, 31); assert(radio.sent.empty()); + tick(node, clock, 2); assert(radio.sent == std::vector({0})); + tick(node, clock); assert(manager.getFreeCount() == 3); + } else { + assert(!"Unknown scenario"); + } +} diff --git a/test/radio_profile_test_support.py b/test/radio_profile_test_support.py new file mode 100644 index 000000000..76ea0ee8e --- /dev/null +++ b/test/radio_profile_test_support.py @@ -0,0 +1,29 @@ +"""Native production-method harnesses for radio profile tests.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +def method(text, signature): + """Extract a complete C++ method for execution in a small native fixture.""" + start = text.index(signature) + opening = text.index("{", start) + depth, end = 1, opening + 1 + while depth: + depth += (text[end] == "{") - (text[end] == "}") + end += 1 + return text[start:end] + +class NativeTest(unittest.TestCase): + def compile_run(self, source): + with tempfile.TemporaryDirectory() as folder: + cpp, exe = Path(folder) / "test.cpp", Path(folder) / "test.exe" + cpp.write_text(source) + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++11", + "-Wall", "-Wextra", *flags, str(cpp), "-o", str(exe)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stderr) diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index 433c97106..9ce533711 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -6,6 +6,10 @@ #include "../../examples/companion_radio/NodePrefs.h" +// The native target tests serialization without the board-dependent CLI backend. +bool CommonRadioPrefs::setByKey(const char*, const char*) { return false; } +bool CommonRadioPrefs::getByKey(const char*, char*, size_t) { return false; } + class ReplayStream : public Stream { const char* _text; int _pos = 0; @@ -47,6 +51,12 @@ public: size_t print(long long value, int = DEC) override { return emit(value); } size_t print(unsigned long long value, int = DEC) override { return emit(value); } + size_t print(double value, int precision = 2) override { + char text[64]; + int length = snprintf(text, sizeof(text), "%.*f", precision, value); + return write(reinterpret_cast(text), length); + } + const std::string& text() const { return _text; } }; @@ -77,6 +87,43 @@ TEST(CompanionNodePrefs, RxGainSettingsRoundTripIndependently) { } #endif +TEST(CompanionNodePrefs, RadioProfilesRoundTripInExistingPrefs) { + NodePrefs saved; + ReplayStream input("{radio:{freq:915,bw:250,sf:7,cr:6,profiles:{freq:920,bw:125,sf:8,cr:5,preamble:40,mode:2,primary_pre:48,cross:2}}}"); + ASSERT_TRUE(saved.loadSerial(input)); + CaptureStream output; + ASSERT_TRUE(saved.saveSerial(output)); + NodePrefs loaded; + ReplayStream replay(output.text().c_str()); + ASSERT_TRUE(loaded.loadSerial(replay)); + const auto& secondary = loaded.getRadioPrefs()->profileCLI().savedSecondary(); + EXPECT_FLOAT_EQ(915, loaded.freq); + EXPECT_FLOAT_EQ(250, loaded.bw); + EXPECT_EQ(7, loaded.sf); + EXPECT_EQ(6, loaded.cr); + EXPECT_FLOAT_EQ(920, secondary.params.freq); + EXPECT_FLOAT_EQ(125, secondary.params.bw); + EXPECT_EQ(8, secondary.params.sf); + EXPECT_EQ(5, secondary.params.cr); + EXPECT_EQ(40, secondary.params.preamble); + EXPECT_EQ(mesh::RadioProfileMode::RxTx, secondary.mode); + EXPECT_EQ(48, loaded.getRadioPrefs()->profileCLI().savedPrimary().preamble); + EXPECT_NE(std::string::npos, output.text().find("cross:2")); +} + +TEST(CompanionNodePrefs, LegacyPrefsDefaultToOneProfileAndAutomaticPreamble) { + NodePrefs prefs; + ReplayStream input("{radio:{freq:869.525,bw:125,sf:8,cr:5}}"); + ASSERT_TRUE(prefs.loadSerial(input)); + const auto& profiles = prefs.getRadioPrefs()->profileCLI(); + EXPECT_EQ(mesh::RadioProfileMode::Off, profiles.savedSecondary().mode); + EXPECT_EQ(0, profiles.savedSecondary().params.preamble); + EXPECT_EQ(0, profiles.savedPrimary().preamble); + CaptureStream output; + ASSERT_TRUE(prefs.saveSerial(output)); + EXPECT_NE(std::string::npos, output.text().find("cross:0")); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_radio_chirp_math.py b/test/test_radio_chirp_math.py new file mode 100644 index 000000000..93d28aa44 --- /dev/null +++ b/test/test_radio_chirp_math.py @@ -0,0 +1,28 @@ +"""Compile the portable production chirp math, independent of radio hardware.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT=Path(__file__).resolve().parents[1] + +class RadioChirpMathTest(unittest.TestCase): + def test_floor_budgets_rounding_and_profile_order(self): + compiler=shutil.which('g++') or shutil.which('clang++') + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory() as folder: + for stm in (False, True): + with self.subTest(stm32_defined=stm): + exe=Path(folder)/('chirps-stm.exe' if stm else 'chirps.exe') + checks=[] if os.name=='nt' else ['-fsanitize=address,undefined', + '-fno-sanitize-recover=all','-fno-pie','-no-pie'] + done=subprocess.run([compiler,'-std=c++11','-O1','-g','-Wall','-Wextra', + *checks,*(['-DSTM32_PLATFORM'] if stm else []),'-I',str(ROOT/'src'), + str(ROOT/'test/fixtures/radio_profiles/chirp_math.cpp'),'-o',str(exe)],capture_output=True,text=True) + self.assertEqual(done.returncode,0,done.stderr) + done=subprocess.run([str(exe)],capture_output=True,text=True) + self.assertEqual(done.returncode,0,done.stderr) + +if __name__=='__main__':unittest.main() diff --git a/test/test_radio_lr1110_profile_switch.py b/test/test_radio_lr1110_profile_switch.py new file mode 100644 index 000000000..519ece13b --- /dev/null +++ b/test/test_radio_lr1110_profile_switch.py @@ -0,0 +1,345 @@ +"""Exercise production profile-switch ownership, caching and failure paths.""" +from pathlib import Path +from radio_profile_test_support import NativeTest, method +ROOT = Path(__file__).resolve().parents[1] + +class LR1110ProfileSwitchTests(NativeTest): + def test_checked_irq_snapshot_restores_spi_width_and_propagates_error(self): + source = (ROOT / "src/helpers/radiolib/CustomLR1110.h").read_text() + implementation = method(source, "int16_t snapshotIrqAfterRxExit(") + harness = r''' +#include +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_MODULE_SPI_WIDTH_STATUS 2 +struct Module { + enum { BITS_0=0 }; + struct { uint8_t widths[3]={0,0,8}; } spiConfig; + uint8_t response[6]={0,0,0x12,0x34,0x56,0x78}; + int16_t result=0; + int16_t SPItransferStream(const uint8_t*,uint8_t,bool,const uint8_t*, + uint8_t* out,size_t length,bool wait) { + assert(spiConfig.widths[2]==0 && length==6 && wait); + std::memcpy(out,response,length);return result; + } +}; +struct Radio { + Module* mod; + @METHOD@ +}; +int main() { + Module m; Radio r; r.mod=&m; uint32_t irq=0; + assert(r.snapshotIrqAfterRxExit(&irq)==0 && irq==0x12345678); + assert(m.spiConfig.widths[2]==8); + m.result=-7;irq=0xAABBCCDD; + assert(r.snapshotIrqAfterRxExit(&irq)==-7 && irq==0xAABBCCDD); + assert(m.spiConfig.widths[2]==8); +} +''' + self.compile_run(harness.replace("@METHOD@", implementation)) + + def test_fast_rx_skips_only_unchanged_packet_tuple_and_fails_closed(self): + source = (ROOT / "src/helpers/radiolib/CustomLR1110.h").read_text() + signatures = ( + "void setProfileSwitchOptimization(", "void setProfileStandbyWarm(", + "void beginProfileSwitch(", "void endProfileSwitch(", + "bool profileSwitchFailed(", "int16_t standby()", "int16_t standby(uint8_t", + "int16_t sleep()", "int16_t reset()", "int16_t stageMode(", + "int16_t setPreambleLength(", "int16_t startReceive()", + ) + methods = "\n".join(method(source, signature).replace(" override", "") + for signature in signatures) + state = (ROOT / "src/helpers/radiolib/LR1110ProfileSwitchState.h").read_text().replace( + "#pragma once", "" + ) + harness = r''' +#include +#include +#include +#include +@STATE@ +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_LR11X0_STANDBY_XOSC 0 +#define RADIOLIB_LR11X0_RX_TIMEOUT_INF 0xffffff +#define RADIOLIB_LR11X0_IRQ_ALL 0xffffffff +#define RADIOLIB_LR11X0_MAX_PACKET_LENGTH 255 +#define RADIOLIB_RADIO_MODE_NONE 0 +#define RADIOLIB_IRQ_RX_DEFAULT_FLAGS 1 +#define RADIOLIB_IRQ_RX_DEFAULT_MASK 2 +#define RADIOLIB_IRQ_PREAMBLE_DETECTED 3 +#define RADIOLIB_IRQ_HEADER_ERR 6 +using RadioModeType_t=int; +struct RadioModeConfig_t {}; +struct Module { + enum { MODE_IDLE=0, MODE_RX=1 }; + std::string* trace; + void setRfSwitchState(int mode) { + assert(mode==MODE_RX || mode==MODE_IDLE); + *trace+=mode==MODE_RX ? 'W' : 'I'; + } +}; +struct LR1110 { + std::string trace; + char fail=0; + size_t preambleLengthLoRa=32; + uint8_t headerType=0,crcTypeLoRa=1,invertIQEnabled=0; + uint32_t rxTimeout=0; + int stagedMode=7; + Module module{&trace}; + Module* mod=&module; + int16_t call(char c) { trace+=c;return fail==c ? -7:0; } + int16_t standby() { return call('S'); } + int16_t standby(uint8_t) { return call('U'); } + int16_t standby(uint8_t mode, bool wake) { assert(mode==1 && !wake);return call('s'); } + int16_t setFs() { return call('F'); } + int16_t sleep() { return call('L'); } + int16_t reset() { return call('Z'); } + int16_t stageMode(RadioModeType_t,RadioModeConfig_t*) { return call('G'); } + int16_t setPreambleLength(size_t n) { preambleLengthLoRa=n;return call('P'); } + int16_t startReceive(uint32_t,uint32_t,uint32_t,size_t) { return call('N'); } + int16_t clearIrqState(uint32_t flags) { assert(flags==0xffffffff);return call('C'); } + int16_t setPacketParamsLoRa(size_t p,uint8_t,uint8_t len,uint8_t,uint8_t) { + assert(p==preambleLengthLoRa && len==255);return call('P'); + } + int16_t setRx(uint32_t timeout) { assert(timeout==0xffffff);return call('R'); } +}; +struct Radio:LR1110 { + LR1110ProfileSwitchState _profileSwitch{true}; + bool _profileStandbyWarm=false,_profilePacketDirty=false; + uint32_t _profileFastResumes=0; + uint32_t pendingIrq=0; + int16_t snapshotIrqAfterRxExit(uint32_t* irq) { + const int16_t rc=call('Q'); + if (rc==0) *irq=pendingIrq; + return rc; + } + @METHODS@ +}; +void prime(Radio& r) { + r.trace.clear();assert(r.startReceive()==0 && r.trace=="N"); + assert(r._profileSwitch.rxValid);r.trace.clear(); + r.setProfileStandbyWarm(true); +} +void hop(Radio& r,size_t preamble,const std::string& expected) { + r.beginProfileSwitch(true);assert(r.standby()==0 && r.trace=="IF");r.trace.clear(); + assert(r.setPreambleLength(preamble)==0 && r.trace.empty()); + assert(r.startReceive()==0 && r.trace==expected); + assert(r._profileSwitch.rxValid && !r._profilePacketDirty); + r.endProfileSwitch(true);r.trace.clear(); +} +int main() { + Radio r;prime(r);hop(r,32,"QWR");hop(r,48,"QPWR");hop(r,48,"QWR"); + r.pendingIrq=1;hop(r,32,"QCPWR");r.pendingIrq=0; + assert(r._profileFastResumes==4); + r.beginProfileSwitch(true);r.fail='F'; + assert(r.standby()==-7 && r.trace=="IF"); + assert(r.profileSwitchFailed() && !r._profileSwitch.rxValid); + r.endProfileSwitch(false);r.fail=0;prime(r); + r.setProfileStandbyWarm(false); + assert(!r._profileSwitch.rxValid);r.beginProfileSwitch(true); + assert(r.standby()==0 && r.trace=="S");r.endProfileSwitch(true); + prime(r); + r.beginProfileSwitch(true);r.standby();r.trace.clear(); + r.pendingIrq=1;r.fail='C';assert(r.startReceive()==-7 && r.trace=="QC"); + assert(r.profileSwitchFailed() && !r._profileSwitch.rxValid); + r.endProfileSwitch(false);r.fail=0;r.pendingIrq=0;prime(r);hop(r,32,"QWR"); + r.beginProfileSwitch(true);r.standby();r.trace.clear(); + r.fail='Q';assert(r.startReceive()==-7 && r.trace=="Q"); + assert(r.profileSwitchFailed() && !r._profileSwitch.rxValid); + r.endProfileSwitch(false);r.fail=0;prime(r); + r.beginProfileSwitch(true);r.standby();r.trace.clear(); + r.setPreambleLength(64);r.fail='P'; + assert(r.startReceive()==-7 && r.trace=="QP"); + assert(r.profileSwitchFailed() && !r._profileSwitch.rxValid); + r.endProfileSwitch(false);r.fail=0;prime(r);hop(r,32,"QPWR"); + r.beginProfileSwitch(true);r.standby();r.trace.clear(); + r.fail='R';assert(r.startReceive()==-7 && r.trace=="QWR"); + assert(r.profileSwitchFailed() && !r._profileSwitch.rxValid); + r.endProfileSwitch(false);r.fail=0;prime(r); + r.standby(uint8_t(0));assert(!r._profileSwitch.rxValid); + prime(r);r.stageMode(2,nullptr);assert(!r._profileSwitch.rxValid); + prime(r);r.sleep();assert(!r._profileSwitch.rxValid); + prime(r);r.reset();assert(!r._profileSwitch.rxValid); +} +''' + self.compile_run(harness.replace("@STATE@", state).replace("@METHODS@", methods)) + + def test_owned_rx_context_invalidates_on_errors_and_unowned_operations(self): + state = (ROOT / "src/helpers/radiolib/LR1110ProfileSwitchState.h").read_text().replace( + "#pragma once", "" + ) + harness = r''' +#include +#include +@STATE@ +int main() { + LR1110ProfileSwitchState s(true); + s.begin(true, true); + assert(!s.canResumeFast()); + s.end(true); + s.rxResult(0, true); + for (int continuous = 0; continuous < 2; ++continuous) + for (int warm = 0; warm < 2; ++warm) { + s.begin(continuous, warm); + s.standbyResult(0); + assert(s.canResumeFast() == bool(continuous && warm)); + s.end(true); + } + s.begin(true, true); + assert(!s.canResumeFast()); + s.standbyResult(0); + assert(s.canResumeFast()); + s.modulationResult(0, 8, 4, 1, 0); + assert(s.matchesModulation(8, 4, 1, 0)); + assert(!s.matchesModulation(9, 4, 1, 0)); + s.rxResult(0, true); + assert(!s.canResumeFast()); + s.end(true); + s.begin(true, true); + s.standbyResult(-7); + assert(s.failed() && !s.rxValid && !s.modulationValid); + s.end(false); + assert(!s.failed()); + s.rxResult(0, true); + s.begin(true, true); + s.standbyResult(0); + s.modulationResult(-8, 8, 4, 1, 0); + assert(s.failed() && !s.rxValid && !s.modulationValid); + s.end(false); + s.rxResult(0, true); + s.begin(true, true); + s.standbyResult(0); + s.invalidate(); + assert(!s.canResumeFast() && !s.rxValid); +} +''' + self.compile_run(harness.replace("@STATE@", state)) + + def test_batched_modulation_validation_cache_and_failed_write_rollback(self): + source = (ROOT / "src/helpers/radiolib/CustomLR1110.h").read_text() + production = method(source, "int16_t setLoRaModulationParams(") + state = (ROOT / "src/helpers/radiolib/LR1110ProfileSwitchState.h").read_text().replace( + "#pragma once", "" + ) + harness = r''' +#include +#include +#include +#include +@STATE@ +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_INVALID_SPREADING_FACTOR -1 +#define RADIOLIB_ERR_INVALID_CODING_RATE -2 +#define RADIOLIB_ERR_INVALID_BANDWIDTH -3 +#define RADIOLIB_ERR_WRONG_MODEM -4 +#define RADIOLIB_LR11X0_PACKET_TYPE_NONE 0 +#define RADIOLIB_LR11X0_PACKET_TYPE_LORA 1 +#define RADIOLIB_LR11X0_LORA_BW_62_5 3 +#define RADIOLIB_LR11X0_LORA_BW_125_0 4 +#define RADIOLIB_LR11X0_LORA_BW_250_0 5 +#define RADIOLIB_LR11X0_LORA_BW_500_0 6 +#define RADIOLIB_LR11X0_LORA_LDRO_ENABLED 1 +#define RADIOLIB_LR11X0_LORA_LDRO_DISABLED 0 +struct Radio { + LR1110ProfileSwitchState _profileSwitch{true}; + uint8_t spreadingFactor=7, bandwidth=4, codingRate=1, ldrOptimize=0; + float bandwidthKhz=125; + bool ldroAuto=true; + uint8_t packetType=1; + int16_t result=0; + unsigned queries=0, writes=0; + uint8_t payload[4]={}; + int16_t getPacketType(uint8_t* type) { ++queries;*type=packetType;return 0; } + int16_t setModulationParamsLoRa(uint8_t sf,uint8_t bw,uint8_t cr,uint8_t ldro) { + assert(spreadingFactor==sf && bandwidth==bw && codingRate==cr); + ldrOptimize=ldroAuto ? ((float(1U<=16.0f) : ldro; + payload[0]=sf;payload[1]=bw;payload[2]=cr;payload[3]=ldrOptimize; + ++writes;return result; + } + @PRODUCTION@ +}; +int main() { + Radio r; + assert(r.setLoRaModulationParams(125,4,5)==RADIOLIB_ERR_INVALID_SPREADING_FACTOR); + assert(r.setLoRaModulationParams(125,7,9)==RADIOLIB_ERR_INVALID_CODING_RATE); + assert(r.setLoRaModulationParams(63,7,5)==RADIOLIB_ERR_INVALID_BANDWIDTH); + assert(r.writes==0); + r.packetType=0; + assert(r.setLoRaModulationParams(125,7,5)==RADIOLIB_ERR_WRONG_MODEM); + r.packetType=1; + for(float bw: {62.5f,125.0f,250.0f,500.0f}) { + for(uint8_t sf=5;sf<=12;++sf) { + for(uint8_t cr=4;cr<=8;++cr) { + assert(r.setLoRaModulationParams(bw,sf,cr)==0); + assert(r.spreadingFactor==sf && r.bandwidthKhz==bw && r.codingRate==cr-4); + assert(r.payload[0]==sf && r.payload[2]==cr-4); + assert(r.payload[3]==((float(1U<=16.0f)); + r._profileSwitch.rxResult(0,true); + r._profileSwitch.begin(true,true); + r._profileSwitch.standbyResult(0); + const unsigned writes=r.writes; + assert(r.setLoRaModulationParams(bw,sf,cr)==0 && r.writes==writes); + r._profileSwitch.end(true); + } + } + } + r._profileSwitch.rxResult(0,true); + r._profileSwitch.begin(true,true); + r._profileSwitch.standbyResult(0); + const auto oldSf=r.spreadingFactor, oldBw=r.bandwidth, oldCr=r.codingRate; + const auto oldKhz=r.bandwidthKhz; + const auto oldLdro=r.ldrOptimize; + r.result=-99; + assert(r.setLoRaModulationParams(125,8,5)==-99); + assert(r.spreadingFactor==oldSf && r.bandwidth==oldBw && r.codingRate==oldCr); + assert(r.bandwidthKhz==oldKhz && r.ldrOptimize==oldLdro); + assert(r._profileSwitch.failed() && !r._profileSwitch.rxValid); +} +''' + self.compile_run(harness.replace("@STATE@", state).replace("@PRODUCTION@", production)) + + def test_ordinary_packet_mutations_revoke_owned_rx_even_on_failure(self): + source = (ROOT / "src/helpers/radiolib/CustomLR1110.h").read_text() + signatures = ("int16_t setCRC(", "int16_t implicitHeader(", + "int16_t explicitHeader(", "int16_t invertIQ(", "int16_t setModem(") + methods = "\n".join(method(source, s).replace(" override", "") for s in signatures) + state = (ROOT / "src/helpers/radiolib/LR1110ProfileSwitchState.h").read_text().replace("#pragma once", "") + harness = r''' +#include +#include +#include +@STATE@ +using ModemType_t = int; +struct LR1110 { + int16_t result=0; + int16_t setCRC(uint8_t,uint32_t,uint32_t,bool) { return result; } + int16_t implicitHeader(size_t) { return result; } + int16_t explicitHeader() { return result; } + int16_t invertIQ(bool) { return result; } + int16_t setModem(ModemType_t) { return result; } +}; +struct Radio : LR1110 { + LR1110ProfileSwitchState _profileSwitch{true}; + @METHODS@ +}; +int main() { + for(int failure=0;failure<2;++failure) for(int setter=0;setter<5;++setter) { + Radio r;r.result=failure ? -7 : 0; + r._profileSwitch.rxResult(0,true);r._profileSwitch.begin(true,true); + r._profileSwitch.standbyResult(0);r._profileSwitch.modulationResult(0,7,4,1,0); + assert(r._profileSwitch.canResumeFast());int16_t rc=0; + switch(setter) { + case 0:rc=r.setCRC(2);break; + case 1:rc=r.implicitHeader(16);break; + case 2:rc=r.explicitHeader();break; + case 3:rc=r.invertIQ(true);break; + case 4:rc=r.setModem(1);break; + } + assert(rc==r.result && !r._profileSwitch.rxValid && !r._profileSwitch.modulationValid); + assert(!r._profileSwitch.canResumeFast()); + } +} +''' + self.compile_run(harness.replace("@STATE@", state).replace("@METHODS@", methods)) diff --git a/test/test_radio_lr2021_profiles.py b/test/test_radio_lr2021_profiles.py new file mode 100644 index 000000000..751592bfa --- /dev/null +++ b/test/test_radio_lr2021_profiles.py @@ -0,0 +1,133 @@ +"""Exercise concrete-chip profile limits and LR2021 band/detector handling.""" +from pathlib import Path +from radio_profile_test_support import NativeTest, method +ROOT = Path(__file__).resolve().parents[1] + +class ChipProfileTests(NativeTest): + def test_concrete_chip_bandwidth_and_frequency_limits(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + validation = method(source, "bool RadioLibWrapper::validateProfile(").replace("RadioLibWrapper::", "") + cases = { + 1262: 'p.bw=41.7f;assert(r.validateProfile(p));p.bw=41.67f;assert(!r.validateProfile(p));p.bw=83.34f;assert(!r.validateProfile(p));p.bw=125;p.freq=137;assert(!r.validateProfile(p));', + 1268: 'assert(r.validateProfile(p));p.freq=915;assert(!r.validateProfile(p));', + 1276: 'p.freq=137;assert(r.validateProfile(p));p.sf=6;assert(!r.validateProfile(p));', + 1272: 'p.freq=868;assert(r.validateProfile(p));p.freq=433;assert(!r.validateProfile(p));', + 68: 'p.sf=9;assert(r.validateProfile(p));p.sf=10;assert(!r.validateProfile(p));p.bw=250;assert(r.validateProfile(p));', + 1110: 'assert(r.validateProfile(p));p.bw=83.34f;assert(!r.validateProfile(p));p.bw=203.125f;assert(!r.validateProfile(p));', + 1121: 'p.freq=2450;p.bw=203.125f;assert(r.validateProfile(p));p.freq=2300;assert(!r.validateProfile(p));p.freq=433;assert(!r.validateProfile(p));', + 2021: 'for(float bw:{41.67f,41.7f,83.34f,101.56f,203.125f,812.5f,1000.0f}){p.bw=bw;assert(r.validateProfile(p));}p.freq=2300;assert(!r.validateProfile(p));p.freq=2450;assert(r.validateProfile(p));', + } + harness = r''' +#include +#include +#include "@MODEL@" +#define MESH_RADIO_PROFILE_CHIP @CHIP@ +struct RadioLibWrapper { @VALIDATE@ }; +int main() { + RadioLibWrapper r;mesh::RadioProfileParams p; + p.freq=433;p.bw=125;p.sf=7;p.cr=5; + @CHECKS@ +} +''' + for chip, checks in cases.items(): + with self.subTest(chip=chip): + self.compile_run(harness.replace("@MODEL@", str(ROOT / "src/RadioProfiles.h").replace("\\", "/")) + .replace("@CHIP@", str(chip)).replace("@VALIDATE@", validation).replace("@CHECKS@", checks)) + + def test_band_crossing_reselects_pa_with_native_power_limits(self): + source = (ROOT / "src/helpers/radiolib/CustomLR2021.h").read_text() + methods = "\n".join(method(source, s).replace(" override", "") + for s in ("int16_t setOutputPower(int8_t power)", "int16_t setFrequency(float frequency)")) + harness = r''' +#include +#include +#define RADIOLIB_ERR_NONE 0 +struct LR2021 { + float frequency=433;int8_t power=0;unsigned powerWrites=0; + int16_t frequencyResult=0,powerResult=0; + int16_t checkOutputPower(int8_t p,int8_t* clipped) { + const int8_t low=frequency>1500?-19:-9,high=frequency>1500?12:22; + *clipped=phigh?high:p;return 0; + } + int16_t setOutputPower(int8_t p) { ++powerWrites;power=p;return powerResult; } + int16_t setFrequency(float f) { if(!frequencyResult)frequency=f;return frequencyResult; } +}; +struct Radio : LR2021 { + int8_t _txPower=0;bool _txPowerSet=false; + @METHODS@ +}; +int main() { + Radio r;assert(r.setOutputPower(22)==0 && r.power==22); + assert(r.setFrequency(2450)==0 && r.power==12 && r._txPower==22); + assert(r.setFrequency(433)==0 && r.power==22); + const auto writes=r.powerWrites;r.frequencyResult=-7; + assert(r.setFrequency(2450)==-7 && r.powerWrites==writes && r.frequency==433); + r.frequencyResult=0;r.powerResult=-9; + assert(r.setFrequency(2450)==-9 && r._txPower==22); +} +''' + self.compile_run(harness.replace("@METHODS@", methods)) + + def test_dual_profiles_suspend_detectors_and_restore_ldro(self): + source = (ROOT / "src/helpers/radiolib/CustomLR2021Wrapper.h").read_text() + methods = "\n".join(method(source, s) for s in + ("int16_t applySideDetectorConfig(float bw)", "float calcTsym(")) + harness = r''' +#include +#include +#include +#include "@MODEL@" +struct LR2021LoRaSideDetector_t { uint8_t sf=0;bool ldro=false,invertIQ=false;uint8_t syncWord=0; }; +struct CustomLR2021 { + size_t count=0;LR2021LoRaSideDetector_t observed[3];int16_t result=0; + int16_t setSideDetector(const LR2021LoRaSideDetector_t* p,size_t n) { + count=n;for(size_t i=0;i +#include "@MODEL@" +#define MESH_RADIO_PROFILE_CHIP @CHIP@ +#define MAX_TRANS_UNIT 255 +struct Wrapper { + mesh::RadioProfiles _profiles; + bool validateProfile(const mesh::RadioProfileParams& p) const { return mesh::RadioProfiles::valid(p); } + uint16_t profilePreamble(uint8_t) const { return 32; } + uint32_t minimumAirtimeMillis() const { return 200; } + @AIRTIME@ +}; +int main() { + Wrapper r;auto& p=r._profiles.primary;p.freq=433;p.bw=500;p.sf=7;p.cr=5; + assert(r.getProfileAirtime(0,1,0)==200); + p.freq=2450;p.bw=406.25f;p.sf=11;p.cr=8; + assert(r.getProfileAirtime(0,200,0)==@EXPECTED@); +} +''' + for chip, expected in ((1121, 2038), (1110, 1716)): + with self.subTest(chip=chip): + self.compile_run(harness.replace("@MODEL@", str(ROOT / "src/RadioProfiles.h").replace("\\", "/")) + .replace("@AIRTIME@", airtime).replace("@CHIP@", str(chip)).replace("@EXPECTED@", str(expected))) diff --git a/test/test_radio_profile_cli.py b/test/test_radio_profile_cli.py new file mode 100644 index 000000000..e6b56e98b --- /dev/null +++ b/test/test_radio_profile_cli.py @@ -0,0 +1,53 @@ +"""Exercise the production radio CLI, scheduler and existing JSON serializer.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +class RadioProfileCLITests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise RuntimeError("A native C++11 compiler is required") + cls.directory = tempfile.TemporaryDirectory(prefix="radio-profile-cli-") + cls.addClassCleanup(cls.directory.cleanup) + work = Path(cls.directory.name) + (work / "Arduino.h").write_text( + '#pragma once\n#include \n#include \n#include "Stream.h"\n' + 'extern uint32_t test_millis; inline uint32_t millis(){ return test_millis; }\n') + (work / "Dispatcher.h").write_text((ROOT / "test/fixtures/radio_profiles/cli_backend.h").read_text()) + cls.binary = work / "cli.exe" + command = [compiler, "-std=c++11", "-Wall", "-Wextra", "-Wno-unused-parameter", + "-I", str(work), "-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"), + str(ROOT / "test/fixtures/radio_profiles/cli.cpp"), + str(ROOT / "src/helpers/RadioProfileCLI.cpp"), + str(ROOT / "src/helpers/ConfigSerializer.cpp"), str(ROOT / "src/Utils.cpp"), "-o", str(cls.binary)] + if os.name != "nt": + command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + + def run_case(self, scenario): + result = subprocess.run([str(self.binary), scenario], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_strict_tuple_modes_preamble_and_duration(self): self.run_case("parse") + def test_saved_primary_reboot_and_temporary_isolation(self): self.run_case("primary") + def test_monotonic_leases_wrap_and_wall_clock_changes(self): self.run_case("lease") + def test_expiry_retires_old_generation_while_replacement_waits(self): self.run_case("replacement") + def test_one_slot_per_family_replacement_cancel_and_order(self): self.run_case("schedule") + def test_busy_and_failed_primary_apply_preserve_saved_prefs(self): self.run_case("failure") + def test_exact_remote_response_success_failure_long_airtime(self): self.run_case("remote") + def test_remote_acceptance_deadlines_survive_rtc_jumps(self): self.run_case("remote_clock") + def test_existing_json_roundtrip_and_legacy_defaults(self): self.run_case("serialize") + def test_unsupported_backend_preserves_legacy_parser(self): self.run_case("legacy") + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_profile_clock.py b/test/test_radio_profile_clock.py new file mode 100644 index 000000000..34e8dd1ad --- /dev/null +++ b/test/test_radio_profile_clock.py @@ -0,0 +1,75 @@ +"""Execute the production profile clock across RTC and software wraps.""" +from pathlib import Path +from radio_profile_test_support import NativeTest +ROOT = Path(__file__).resolve().parents[1] + +class ProfileClockTests(NativeTest): + def test_extended_rtc_reaches_long_visit_and_wraps_32bit_ticks(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + clock = source[source.index("// Extend the 24-bit RTC counter"): + source.index("void RadioLibWrapper::begin()")] + harness = r''' +#include +#include +#define NRF52_PLATFORM 1 +struct RTC { + uint32_t TASKS_STOP=0,PRESCALER=0,TASKS_START=0,COUNTER=0; +} rtc; +#define NRF_RTC2 (&rtc) +static uint32_t fake_us=123456; +uint32_t micros() { return fake_us; } +@CLOCK@ +void advance(uint32_t ticks) { rtc.COUNTER=(rtc.COUNTER+ticks)&0x00ffffffUL; } +int main() { + assert(profileTimestamp(false)==fake_us); + rtc.COUNTER=0x00fedcba; // Nonzero retained counter near hardware wrap. + const uint32_t visit=profileTimestamp(true); + assert(visit==0 && rtc.TASKS_START==1 && rtc.PRESCALER==0); + for(unsigned second=1;second<=532;++second) { + advance(32768); + const auto stamp=profileTimestamp(true); + assert(stamp==second*32768UL); + assert(profileElapsedUs(visit,stamp,true)==second*1000000UL); + if(second==512)assert(stamp==0x01000000UL); // Not truncated to zero. + if(second==531)assert(profileElapsedUs(visit,stamp,true)<532000000UL); + if(second==532)assert(profileElapsedUs(visit,stamp,true)>=532000000UL); + } + // Disabling returns to the ordinary microsecond clock. A fresh enable + // resets the software origin without clearing the retained hardware count. + fake_us=987654;assert(profileTimestamp(false)==fake_us && rtc.TASKS_STOP==1); + const auto retained=rtc.COUNTER; + assert(profileTimestamp(true)==0 && rtc.COUNTER==retained); + advance(100);assert(profileElapsedUs(0,profileTimestamp(true),true)==3052); + profileTimestamp(false);profileTimestamp(true); + // Sample more often than each 24-bit wrap while advancing to the end of + // the 32-bit extended clock (131072 seconds at32768Hz). + for(unsigned chunk=0;chunk<511;++chunk) { advance(256UL*32768);profileTimestamp(true); } + advance(255UL*32768);const auto before=profileTimestamp(true); + assert(before==0xffff8000UL); + advance(32768);const auto wrapped=profileTimestamp(true);assert(wrapped==0); + assert(profileElapsedUs(before,wrapped,true)==1000000UL); + advance(32768);assert(profileElapsedUs(before,profileTimestamp(true),true)==2000000UL); + assert(profileElapsedUs(0,0xffffffffUL,true)==UINT32_MAX); // Saturate long overdue visits. + // The ordinary microsecond clock retains its existing32-bit wrap behavior. + assert(profileElapsedUs(0xfffffff0UL,0x10UL,false)==32); +} +''' + self.compile_run(harness.replace("@CLOCK@", clock)) + + def test_non_nrf_clock_retains_microsecond_wrap_behavior(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + clock = source[source.index("// Extend the 24-bit RTC counter"): + source.index("void RadioLibWrapper::begin()")] + harness = r''' +#include +#include +static uint32_t fake_us=0; +uint32_t micros() { return fake_us; } +@CLOCK@ +int main() { + fake_us=0xfffffff0UL;const auto before=profileTimestamp(true); + fake_us=0x10;assert(profileElapsedUs(before,profileTimestamp(true),true)==32); + assert(profileTimestamp(false)==fake_us); +} +''' + self.compile_run(harness.replace("@CLOCK@", clock)) diff --git a/test/test_radio_profile_dispatcher.py b/test/test_radio_profile_dispatcher.py new file mode 100644 index 000000000..5cc92439b --- /dev/null +++ b/test/test_radio_profile_dispatcher.py @@ -0,0 +1,67 @@ +"""Execute production Dispatcher/Packet/StaticPool routing without hardware.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class RadioProfileDispatcherTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise RuntimeError("A native C++17 compiler is required") + cls.directory = tempfile.TemporaryDirectory(prefix="radio-profile-dispatcher-") + cls.addClassCleanup(cls.directory.cleanup) + work = Path(cls.directory.name) + (work / "Identity.h").write_text("#pragma once\n", encoding="ascii") + # SimpleMeshTables depends only on this production interface. Keep + # unrelated cryptography/role dependencies out of the native harness. + (work / "Mesh.h").write_text( + "#pragma once\n#include \nnamespace mesh { class MeshTables { public:\n" + "virtual bool wasSeen(const Packet*)=0; virtual void markSeen(const Packet*)=0;\n" + "virtual void clear(const Packet*)=0; }; }\n", encoding="ascii") + cls.binary = work / "dispatcher.exe" + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Wno-unused-parameter", + "-I", str(work), "-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"), + str(ROOT / "test/fixtures/radio_profiles/dispatcher.cpp"), + str(ROOT / "src/Dispatcher.cpp"), str(ROOT / "src/Packet.cpp"), + str(ROOT / "src/helpers/StaticPoolPacketManager.cpp"), "-o", str(cls.binary)] + if os.name != "nt": + command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + if built.returncode: + raise AssertionError(built.stdout + built.stderr) + + def run_case(self, scenario): + environment = os.environ.copy() + # The upstream packet pool intentionally allocates for process lifetime + # and has no destructor. Free-count assertions check packet ownership; + # ASAN still checks invalid accesses, without flagging that base design. + environment["ASAN_OPTIONS"] = environment.get("ASAN_OPTIONS", "") + ":detect_leaks=0" + result = subprocess.run([str(self.binary), scenario], capture_output=True, text=True, + timeout=10, env=environment) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_receive_reply_affinity_and_local_default(self): self.run_case("affinity") + def test_rx_rxtx_cross_and_temporary_isolation_matrix(self): self.run_case("matrix") + def test_stale_inbound_outbound_and_cross_policy(self): self.run_case("stale") + def test_busy_profile_does_not_starve_other_profile(self): self.run_case("cad") + def test_pending_rx_busy_and_failed_retune_ownership(self): self.run_case("busy") + def test_profile_replacement_resets_its_cad_clock(self): self.run_case("cad_generation") + def test_no_retune_before_shared_duty_credit(self): self.run_case("budget") + def test_both_profile_sends_debit_one_budget(self): self.run_case("shared_budget") + def test_queue_reschedule_wrap_and_pool_exhaustion(self): self.run_case("queue") + def test_exact_packet_completion_success_failure_and_crossed_copies(self): self.run_case("completion") + def test_profiles_share_node_duplicate_suppression(self): self.run_case("dedup") + def test_wire_bytes_and_legacy_single_profile_unchanged(self): self.run_case("wire") + def test_dispatcher_clock_wrap(self): self.run_case("wrap") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_profile_driver.py b/test/test_radio_profile_driver.py new file mode 100644 index 000000000..a3622d7e9 --- /dev/null +++ b/test/test_radio_profile_driver.py @@ -0,0 +1,181 @@ +"""Execute production scanner transactions against a controllable transceiver.""" +from pathlib import Path +from radio_profile_test_support import NativeTest, method +ROOT = Path(__file__).resolve().parents[1] + +class ProfileDriverTests(NativeTest): + def test_busy_fifo_rollback_scanner_and_primary_transactions(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + signatures = ("uint8_t RadioLibWrapper::beginReconfigure(", + "int RadioLibWrapper::recvRaw(", + "mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(", + "void RadioLibWrapper::serviceProfiles(", + "mesh::RadioParamApplyResult RadioLibWrapper::trySetPrimaryParams(", + "mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(") + production = "\n".join(method(source, s).replace("RadioLibWrapper::", "") for s in signatures) + harness = r''' +#include +#include +#include +#define STATE_IDLE 0 +#define STATE_RX 1 +#define STATE_TX_WAIT 3 +#define STATE_TX_DONE 4 +#define RADIOLIB_ERR_NONE 0 +static uint32_t elapsed_us = 100000; +uint32_t profileTimestamp(bool) { return elapsed_us; } +uint32_t profileElapsedUs(uint32_t since,uint32_t now,bool) { return now-since; } +uint32_t millis() { return elapsed_us/1000; } +static RadioInterruptEvents radio_interrupt; +struct Chip { + int standbyResult=0; + bool injectInterrupt=false; + mesh::RadioProfileParams physical; + unsigned reads=0,lengthQueries=0; + int getPacketLength() { ++lengthQueries;return 4; } + int16_t readData(uint8_t*,int) { ++reads;return 0; } + float getRSSI() { return -102; } + float getSNR() { return 4.5; } + int16_t standby() { + if (injectInterrupt) { injectInterrupt=false;radio_interrupt.record(); } + return standbyResult; + } +}; +struct RadioLibWrapper { + mesh::RadioProfiles _profiles; + mesh::RadioProfileParams _physical_params; + Chip chip; Chip* _radio=&chip; + uint8_t _state=STATE_RX,_active_profile=0,_last_rx_profile=0; + uint32_t n_recv=0,n_recv_errors=0,_last_rx_generation=0; + float _last_rssi=0,_last_snr=0; + uint32_t _profile_generation=1,_profile_visit_stamp=0; + uint32_t _profile_scan_generation[2]={}; + uint16_t _physical_preamble=32; + bool _params_valid=true,_profile_refresh_required=false,_physical_dual=false; + int16_t _profile_noise_floor[2]={},_noise_floor=0; + uint16_t _profile_floor_samples[2]={},_num_floor_samples=0; + int32_t _profile_floor_sum[2]={},_floor_sample_sum=0; + bool packet=false,busy=false,warm=false; + unsigned applies=0,failApplies=0,failRx=0,ends=0; + bool lastEnd=false; + RadioLibWrapper() { + radio_interrupt.claim(); + _physical_params.freq=909.5f;_physical_params.bw=62.5f; + _physical_params.sf=7;_physical_params.cr=5; + _profiles.primary=_physical_params;chip.physical=_physical_params; + } + uint16_t profilePreamble(uint8_t p) const { return _profiles.preamble(p,32); } + bool validateProfile(const mesh::RadioProfileParams& p) const { return mesh::RadioProfiles::valid(p); } + bool isChipBusy() { return busy; } + bool isReceivingPacket() { return packet; } + void beginProfileRetune(bool) {} + void endProfileRetune(bool success) { ++ends;lastEnd=success; } + void setProfileStandbyWarm(bool enabled) { warm=enabled; } + void saveNoiseFloor(uint8_t) {} + void loadNoiseFloor(uint8_t) {} + bool startRecv() { + elapsed_us+=50; + if(failRx) { --failRx;_state=STATE_IDLE;return false; } + _state=STATE_RX;return true; + } + bool applyParams(float f,float bw,uint8_t sf,uint8_t cr) { + ++applies;elapsed_us+=100; + chip.physical.freq=f;chip.physical.bw=bw; + chip.physical.sf=sf;chip.physical.cr=cr; + if(failApplies) { --failApplies;return false; } + return true; + } + @PRODUCTION@ +}; +void second(RadioLibWrapper& r) { + mesh::RadioProfileConfig p; + p.mode=mesh::RadioProfileMode::RxTx;p.params=r._profiles.primary; + p.params.freq=910.5f;r._profiles.setSecondary(p,false); +} +using Result=mesh::RadioParamApplyResult; +int main() { + for(int reason=0;reason<5;++reason) { + RadioLibWrapper r;second(r); + if(reason==0)radio_interrupt.record(); + if(reason==1)r.packet=true; + if(reason==2)r.busy=true; + if(reason==3)r._state=STATE_TX_WAIT; + if(reason==4)r._state=STATE_TX_DONE; + assert(r.tuneProfile(1)==Result::BUSY); + assert(r.applies==0 && r._active_profile==0 && r._profile_generation==1); + radio_interrupt.claim(); + } + { + RadioLibWrapper r;second(r);r.chip.injectInterrupt=true; + assert(r.tuneProfile(1)==Result::BUSY); + assert(radio_interrupt.pending() && r.applies==0 && r._active_profile==0); + // Old FIFO ownership survives a completion delivered by standby. + assert(r._state==STATE_RX);radio_interrupt.claim(); + } + for(int failure=0;failure<3;++failure) { + RadioLibWrapper r;second(r);const auto previous=r._physical_params; + if(failure==0)r.failApplies=1; + if(failure==1)r.failRx=1; + if(failure==2)r.failApplies=2; + assert(r.tuneProfile(1)==Result::FAILED); + assert(r._active_profile==0 && r._profile_generation==1); + assert(r._physical_params==previous && r.chip.physical==previous); + assert(r._physical_preamble==32 && r.applies==2 && !r.lastEnd); + assert(r._profile_refresh_required==(failure==2)); + if(failure!=2)assert(r._state==STATE_RX); + else { + r.packet=true;radio_interrupt.record();uint8_t packet[8]={}; + const auto before=r.applies; + assert(r.recvRaw(packet,sizeof(packet))==0); + assert(r.chip.reads==0 && r.chip.lengthQueries==0 && r.applies==before); + r.serviceProfiles(); // Repair ignores stale flags from the unknown tuple. + assert(!r._profile_refresh_required && !radio_interrupt.pending() && r._state==STATE_RX); + r.packet=false;radio_interrupt.record(); + assert(r.recvRaw(packet,sizeof(packet))==4 && r.chip.reads==1); + assert(r._last_rx_profile==r._active_profile && r._last_rx_generation==r._profile_generation); + assert(r._last_rssi==-102 && r._last_snr==4.5); + } + } + { + RadioLibWrapper r;second(r);r.chip.standbyResult=-7; + assert(r.tuneProfile(1)==Result::FAILED); + assert(r.applies==0 && r._profile_refresh_required && r._state==STATE_IDLE); + r.chip.standbyResult=0; + assert(r.tuneProfile(0)==Result::APPLIED && !r._profile_refresh_required); + } + { + RadioLibWrapper r;second(r);assert(r.applies==0); // config does not retune + assert(r.tuneProfile(1)==Result::APPLIED && r._active_profile==1); + const auto secondary=r._physical_params; + auto requested=r._profiles.primary;requested.freq=915.5f;requested.preamble=64; + const auto original=r._profiles.primary;const auto generation=r._profiles.generation[0]; + r.failRx=1; + assert(r.trySetPrimaryParams(requested,true,nullptr)==Result::FAILED); + assert(r._profiles.primary==original && !r._profiles.primary_temporary); + assert(r._profiles.generation[0]==generation && r._active_profile==1); + assert(r._physical_params==secondary && r._state==STATE_RX); + assert(r.trySetPrimaryParams(requested,true,nullptr)==Result::APPLIED); + assert(r._profiles.primary==requested && r._profiles.primary_temporary); + assert(r._profiles.generation[0]==generation+1 && r._active_profile==0); + assert(r._profile_generation==r._profiles.generation[0]); + requested.sf=2;const auto applies=r.applies; + assert(r.trySetPrimaryParams(requested,true,nullptr)==Result::FAILED && r.applies==applies); + } + { + RadioLibWrapper r;second(r);r.serviceProfiles();assert(r.warm); + for(unsigned i=0;i<12;++i) { + const auto before=r._active_profile; + elapsed_us+=r._profiles.listenUs(before,r.profilePreamble(r._profiles.slowerProfile()))+1; + r.serviceProfiles();assert(r._active_profile==(before^1)); + } + assert(r._profiles.switchTestReady() && r._profiles.switchBudgetUs()>=150); + auto off=r._profiles.secondary;off.mode=mesh::RadioProfileMode::Off; + r._profiles.setSecondary(off,false);radio_interrupt.record(); + const auto before=r._active_profile; r.serviceProfiles();assert(r._active_profile==before); + radio_interrupt.claim();r.serviceProfiles();assert(r._active_profile==0 && !r.warm); + } +} +''' + self.compile_run(harness.replace("@PRODUCTION@", production).replace( + "#include ", '#include "'+str(ROOT / "src/RadioProfiles.h").replace("\\", "/")+'"').replace( + "#include ", '#include "'+str(ROOT / "src/helpers/radiolib/RadioInterruptEvents.h").replace("\\", "/")+'"')) diff --git a/test/test_radio_sx1262_batched_modulation.py b/test/test_radio_sx1262_batched_modulation.py new file mode 100644 index 000000000..87d9f1769 --- /dev/null +++ b/test/test_radio_sx1262_batched_modulation.py @@ -0,0 +1,199 @@ +"""Exercise production profile-switch ownership, caching and failure paths.""" +from pathlib import Path +from radio_profile_test_support import NativeTest, method +ROOT = Path(__file__).resolve().parents[1] + +class BatchedModulationTests(NativeTest): + def test_tuple_validation_ldro_and_failure_cache_rollback(self): + production = method((ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text(), + "int16_t setLoRaModulationParams(") + harness = r''' +#include +#include +#include +#include +#include +@STATE@ +using std::isfinite; +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_INVALID_SPREADING_FACTOR -1 +#define RADIOLIB_ERR_INVALID_CODING_RATE -2 +#define RADIOLIB_ERR_INVALID_BANDWIDTH -3 +#define RADIOLIB_ERR_WRONG_MODEM -4 +#define RADIOLIB_SX126X_PACKET_TYPE_LORA 1 +#define RADIOLIB_SX126X_LORA_BW_7_8 0x00 +#define RADIOLIB_SX126X_LORA_BW_10_4 0x08 +#define RADIOLIB_SX126X_LORA_BW_15_6 0x01 +#define RADIOLIB_SX126X_LORA_BW_20_8 0x09 +#define RADIOLIB_SX126X_LORA_BW_31_25 0x02 +#define RADIOLIB_SX126X_LORA_BW_41_7 0x0a +#define RADIOLIB_SX126X_LORA_BW_62_5 0x03 +#define RADIOLIB_SX126X_LORA_BW_125_0 0x04 +#define RADIOLIB_SX126X_LORA_BW_250_0 0x05 +#define RADIOLIB_SX126X_LORA_BW_500_0 0x06 +struct Radio { + SX1262ProfileSwitchState _profileSwitch{true}; + uint8_t spreadingFactor=9, bandwidth=4, codingRate=3, ldrOptimize=0; + float bandwidthKhz=125; + bool ldroAuto=true; + unsigned queries=0, writes=0; + uint8_t packetType=1, payload[4]={}; + int16_t result=0; + uint8_t getPacketType() { ++queries; return packetType; } + int16_t setModulationParams(uint8_t sf,uint8_t bw,uint8_t cr,uint8_t ldro) { + // The pinned RadioLib reads these caches, not just the command arguments, + // when deciding LDRO. Assert they are synchronized before that call. + assert(spreadingFactor==sf && bandwidth==bw && codingRate==cr); + if (ldroAuto) ldrOptimize=((1U<=16.0f; + else ldrOptimize=ldro; + payload[0]=sf;payload[1]=bw;payload[2]=cr;payload[3]=ldrOptimize; + ++writes; return result; + } + @PRODUCTION@ +}; +bool sameCache(const Radio& a,const Radio& b) { + return a.spreadingFactor==b.spreadingFactor && a.bandwidth==b.bandwidth + && a.bandwidthKhz==b.bandwidthKhz && a.codingRate==b.codingRate + && a.ldrOptimize==b.ldrOptimize && a.ldroAuto==b.ldroAuto; +} +void beginHop(Radio& r) { + r._profileSwitch.end(true); + r._profileSwitch.rxResult(0,true); + r._profileSwitch.begin(true,true); + r._profileSwitch.standbyResult(0); +} +int main() { + const float bandwidths[]={7.8f,10.4f,15.6f,20.8f,31.25f,41.7f,62.5f,125,250,500}; + const uint8_t codes[]={0,8,1,9,2,10,3,4,5,6}; + for (unsigned i=0;i<10;++i) for(uint8_t sf=5;sf<=12;++sf) + for(uint8_t cr=4;cr<=8;++cr) for(int mode=0;mode<3;++mode) { + Radio r;r.ldroAuto=mode==0;r.ldrOptimize=mode==2; + assert(r.setLoRaModulationParams(bandwidths[i],sf,cr)==0); + assert(r.queries==1 && r.writes==1); + assert(r.spreadingFactor==sf && r.bandwidth==codes[i]); + assert(r.bandwidthKhz==bandwidths[i] && r.codingRate==cr-4); + const uint8_t ldro=mode==0 ? ((1U<=16.0f) : mode==2; + assert(r.ldrOptimize==ldro && r.payload[0]==sf && r.payload[1]==codes[i] + && r.payload[2]==cr-4 && r.payload[3]==ldro); + // Only a subsequent owned RX hop may reuse that acknowledged tuple. + beginHop(r); + assert(r.setLoRaModulationParams(bandwidths[i],sf,cr)==0); + assert(r.queries==2 && r.writes==1); + assert(r._profileSwitch.matchesModulation(sf,codes[i],cr-4,ldro)); + } + { + Radio r;beginHop(r); + // Initially matching software fields are not proof of programmed hardware. + assert(r.setLoRaModulationParams(125,9,7)==0 && r.writes==1); + beginHop(r);assert(r.setLoRaModulationParams(125,9,7)==0 && r.writes==1); + // Change each effective field independently, then verify repeat is skipped. + assert(r.setLoRaModulationParams(250,9,7)==0 && r.writes==2); + assert(r.setLoRaModulationParams(250,10,7)==0 && r.writes==3); + assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==4); + assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==4); + r.ldroAuto=false;r.ldrOptimize=1; + assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==5); + assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==5); + // autoLDRO() itself does not write: the next hop must apply its new value. + r.ldroAuto=true; + assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==6); + r.ldroAuto=false; + assert(r.setLoRaModulationParams(250,10,5)==0 && r.writes==6); + r.ldroAuto=true; + assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==7 && r.ldrOptimize==1); + assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==7); + // A failed changed tuple revokes reuse before physical rollback. + r.result=-707;assert(r.setLoRaModulationParams(500,8,5)==-707); + assert(!r._profileSwitch.modulationValid && r._profileSwitch.failed()); + r.result=0;beginHop(r); + assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==9); + assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==9); + r.packetType=255;assert(r.setLoRaModulationParams(125,11,5)==-4); + assert(!r._profileSwitch.modulationValid); + r.packetType=1;assert(r.setLoRaModulationParams(125,11,5)==0 && r.writes==10); + } + for(int gate=0;gate<6;++gate) { + Radio r;beginHop(r);assert(r.setLoRaModulationParams(125,9,7)==0); + switch(gate) { + case 0:r._profileSwitch.end(true);break; // no owned window + case 1:r._profileSwitch.begin(true,true);break; // no standby yet + case 2:r._profileSwitch.begin(false,true);break; // not continuous RX + case 3:r._profileSwitch.begin(true,false);break; // cold oscillator + case 4:r._profileSwitch.enabled=false;r._profileSwitch.begin(true,true);break; + case 5:r._profileSwitch.invalidate();beginHop(r);break; // state lost + } + assert(r.setLoRaModulationParams(125,9,7)==0 && r.writes==2); + } + // Invalid values cannot issue SPI, mutate caches, or enter float->int UB. + for(float bw: {NAN,INFINITY,-INFINITY,-1.0f,0.0f,50.0f,62.6f,1000.0f}) { + Radio r,old=r;assert(r.setLoRaModulationParams(bw,7,5)==-3); + assert(!r.queries && !r.writes && sameCache(r,old)); + } + for(uint8_t sf: {0,4,13,255}) { + Radio r,old=r;assert(r.setLoRaModulationParams(500,sf,5)==-1); + assert(!r.queries && !r.writes && sameCache(r,old)); + } + for(uint8_t cr: {0,3,9,255}) { + Radio r,old=r;assert(r.setLoRaModulationParams(500,7,cr)==-2); + assert(!r.queries && !r.writes && sameCache(r,old)); + } + for(uint8_t packet: {0,2,255}) { + Radio r;r.packetType=packet;Radio old=r; + assert(r.setLoRaModulationParams(500,7,5)==-4); + assert(r.queries==1 && !r.writes && sameCache(r,old)); + } + for (int mode=0;mode<3;++mode) for(int error: {-10,-705,-707}) { + Radio r;r.ldroAuto=mode==0;r.ldrOptimize=mode==2;Radio old=r;r.result=error; + assert(r.setLoRaModulationParams(7.8f,12,8)==error); + assert(r.queries==1 && r.writes==1 && sameCache(r,old)); + // A following restoration/retry must still work. + r.result=0;assert(r.setLoRaModulationParams(125,9,7)==0); + assert(sameCache(r,old)); + } +} +''' + state = (ROOT / "src/helpers/radiolib/SX1262ProfileSwitchState.h").read_text().replace("#pragma once", "") + self.compile_run(harness.replace("@PRODUCTION@", production).replace("@STATE@", state)) + + def test_ordinary_setters_invalidate_acknowledged_tuple_even_on_failure(self): + source = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text() + signatures = ("int16_t setBandwidth(", "int16_t setSpreadingFactor(", + "int16_t setCodingRate(", "int16_t forceLDRO(") + methods = "\n".join(method(source, signature) for signature in signatures) + state = (ROOT / "src/helpers/radiolib/SX1262ProfileSwitchState.h").read_text().replace("#pragma once", "") + harness = r''' +#include +#include +@STATE@ +struct SX1262 { + int16_t result=0;unsigned calls=0;bool interleave=false; + virtual ~SX1262()=default; + virtual int16_t setBandwidth(float bw) { assert(bw==125);++calls;return result; } + virtual int16_t setSpreadingFactor(uint8_t sf) { assert(sf==9);++calls;return result; } + int16_t setCodingRate(uint8_t cr,bool li) { assert(cr==7);interleave=li;++calls;return result; } + int16_t forceLDRO(bool enabled) { assert(enabled);++calls;return result; } +}; +struct Radio: SX1262 { + SX1262ProfileSwitchState _profileSwitch{true}; + @METHODS@ +}; +int main() { + for(int setter=0;setter<5;++setter) for(int failure=0;failure<2;++failure) { + Radio r;r.result=failure ? -707:0; + r._profileSwitch.rxResult(0,true);r._profileSwitch.begin(true,true); + r._profileSwitch.standbyResult(0);r._profileSwitch.modulationResult(0,9,4,3,0); + assert(r._profileSwitch.matchesModulation(9,4,3,0)); + SX1262& base=r;int16_t rc=0; + switch(setter) { + case 0:rc=base.setBandwidth(125);break; + case 1:rc=base.setSpreadingFactor(9);break; + case 2:rc=r.setCodingRate(7);assert(!r.interleave);break; + case 3:rc=r.setCodingRate(7,true);assert(r.interleave);break; + case 4:rc=r.forceLDRO(true);break; + } + assert(rc==r.result && r.calls==1 && !r._profileSwitch.modulationValid); + assert(!r._profileSwitch.matchesModulation(9,4,3,0)); + } +} +''' + self.compile_run(harness.replace("@STATE@", state).replace("@METHODS@", methods)) diff --git a/test/test_radio_sx1262_modem_cache.py b/test/test_radio_sx1262_modem_cache.py new file mode 100644 index 000000000..893da30b8 --- /dev/null +++ b/test/test_radio_sx1262_modem_cache.py @@ -0,0 +1,170 @@ +"""Exercise production profile-switch ownership, caching and failure paths.""" +from pathlib import Path +from radio_profile_test_support import NativeTest, method +ROOT = Path(__file__).resolve().parents[1] + +class ModemCacheTests(NativeTest): + def test_confirmed_modem_requires_owned_warm_rx_and_survives_no_failure(self): + source = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text() + state = (ROOT / "src/helpers/radiolib/SX1262ProfileSwitchState.h").read_text().replace("#pragma once", "") + signatures = ("void invalidateProfileCache(", "void setProfileSwitchOptimization(", + "int16_t begin(const ConfigLoRa_t&", "int16_t beginFSK(const ConfigFSK_t&", + "int16_t beginLRFHSS(const ConfigLRFHSS_t&", "int16_t setModem(", + "int16_t setLoRaModulationParams(") + production = "\n".join(method(source, signature).replace(" override", "") for signature in signatures) + fixture = r''' +#include +#include +#include +#include +@DEFINES@ +@STATE@ +using std::isfinite; +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_INVALID_SPREADING_FACTOR -1 +#define RADIOLIB_ERR_INVALID_CODING_RATE -2 +#define RADIOLIB_ERR_INVALID_BANDWIDTH -3 +#define RADIOLIB_ERR_WRONG_MODEM -4 +#define RADIOLIB_SX126X_PACKET_TYPE_LORA 1 +#define RADIOLIB_SX126X_LORA_BW_7_8 0 +#define RADIOLIB_SX126X_LORA_BW_10_4 8 +#define RADIOLIB_SX126X_LORA_BW_15_6 1 +#define RADIOLIB_SX126X_LORA_BW_20_8 9 +#define RADIOLIB_SX126X_LORA_BW_31_25 2 +#define RADIOLIB_SX126X_LORA_BW_41_7 10 +#define RADIOLIB_SX126X_LORA_BW_62_5 3 +#define RADIOLIB_SX126X_LORA_BW_125_0 4 +#define RADIOLIB_SX126X_LORA_BW_250_0 5 +#define RADIOLIB_SX126X_LORA_BW_500_0 6 +struct ConfigLoRa_t {}; +struct ConfigFSK_t {}; +struct ConfigLRFHSS_t {}; +using ModemType_t = int; +struct SX1262 { + SX1262ProfileSwitchState* cache = nullptr; + int result = 0, initializations = 0; + int init() { + // The capability must be revoked BEFORE any possibly failing base call. + assert(cache && !cache->rxValid && !cache->active); + assert(!cache->modulationValid && !cache->packetValid); + ++initializations; + return result; + } + int16_t begin(const ConfigLoRa_t&) { return init(); } + virtual int16_t beginFSK(const ConfigFSK_t&) { return init(); } + virtual int16_t beginLRFHSS(const ConfigLRFHSS_t&) { return init(); } + int16_t beginFSK(float) { return beginFSK(ConfigFSK_t{}); } + int16_t beginLRFHSS(float) { return beginLRFHSS(ConfigLRFHSS_t{}); } + int16_t setModem(ModemType_t) { return init(); } +}; +struct Radio : SX1262 { + SX1262ProfileSwitchState _profileSwitch{true}; + bool _coldStandby = false, _tcxoWakePending = true; + uint8_t spreadingFactor = 9, bandwidth = 4, codingRate = 3, ldrOptimize = 0; + float bandwidthKhz = 125; + bool ldroAuto = true; + unsigned queries = 0, writes = 0; + uint8_t packetType = 1; + int16_t writeResult = 0; + using SX1262::beginFSK; + using SX1262::beginLRFHSS; + Radio() { cache = &_profileSwitch; } + uint8_t getPacketType() { ++queries; return packetType; } + int16_t setModulationParams(uint8_t sf, uint8_t bw, uint8_t cr, uint8_t ldro) { + assert(spreadingFactor == sf && bandwidth == bw && codingRate == cr); + ldrOptimize = ldroAuto ? ((1U << sf) / bandwidthKhz >= 16.0f) : ldro; + ++writes; + return writeResult; + } + @PRODUCTION@ +}; +bool skipsModem(const Radio& r) { + (void)r; + return MC_SX1262_FAST_MODEM_CACHE != 0; +} +void selectCache(Radio& r, bool enabled) { + (void)r; (void)enabled; +} +void prime(Radio& r) { + // Models the full RX path's successful live LoRa confirmation. The ordinary + // lifecycle tests separately execute startReceive's real confirmation path. + r._profileSwitch.end(true); + r._profileSwitch.rxResult(0, true); + r._profileSwitch.begin(true, true); + r._profileSwitch.standbyResult(0); +} +int main() { + for (int requested = 0; requested < 2; ++requested) { + Radio r; selectCache(r, requested); + // No existing RX context: even an opted-in image must perform the query. + assert(r.setLoRaModulationParams(125, 9, 7) == 0 && r.queries == 1); + prime(r); + assert(r.setLoRaModulationParams(125, 9, 7) == 0 && r.writes == 1); + assert(r.queries == (skipsModem(r) ? 1U : 2U)); + // A changed modulation tuple still writes the complete tuple. Only the + // confirmed packet-type read may disappear. + assert(r.setLoRaModulationParams(500, 8, 5) == 0 && r.writes == 2); + assert(r.queries == (skipsModem(r) ? 1U : 3U)); + // Every ownership gate independently restores the live query. + for (int gate = 0; gate < 9; ++gate) { + Radio x; selectCache(x, requested); prime(x); + switch (gate) { + case 0: x._profileSwitch.end(true); break; + case 1: x._profileSwitch.begin(true, true); break; // standby missing + case 2: x._profileSwitch.begin(false, true); break; // discontinuous RX + case 3: x._profileSwitch.begin(true, false); break; // cold oscillator + case 4: x.setProfileSwitchOptimization(false); break; + case 5: x._profileSwitch.rxResult(-707, true); break; + case 6: x._profileSwitch.standbyResult(-707); break; + case 7: x._profileSwitch.end(false); break; + case 8: x.invalidateProfileCache(); break; + } + assert(x.setLoRaModulationParams(125, 9, 7) == 0 && x.queries == 1); + } + // Failed modulation must revoke query reuse before the restoration path. + Radio failed; selectCache(failed, requested); prime(failed); + failed.writeResult = -707; + assert(failed.setLoRaModulationParams(500, 8, 5) == -707); + assert(!failed._profileSwitch.rxValid && failed._profileSwitch.failed()); + const auto before = failed.queries; + failed.writeResult = 0; + assert(failed.setLoRaModulationParams(125, 9, 7) == 0); + assert(failed.queries == before + 1); + + // A live wrong-modem/error-byte observation revokes the WHOLE context. + // Reopening it without a fresh full RX confirmation cannot restore reuse. + Radio wrong; selectCache(wrong, requested); prime(wrong); + wrong._profileSwitch.end(true); wrong.packetType = 255; + assert(wrong.setLoRaModulationParams(125, 9, 7) == -4 && wrong.queries == 1); + assert(!wrong._profileSwitch.rxValid && !wrong._profileSwitch.modulationValid); + wrong._profileSwitch.begin(true, true); wrong._profileSwitch.standbyResult(0); + wrong.packetType = 1; + assert(wrong.setLoRaModulationParams(125, 9, 7) == 0 && wrong.queries == 2); + } + // Public modem initialization calls invalidate before success AND failure, + // including inherited float overloads dispatching to the config overrides. + for (int api = 0; api < 6; ++api) for (int failure = 0; failure < 2; ++failure) { + Radio r; selectCache(r, true); prime(r); + r._profileSwitch.modulationResult(0, 9, 4, 3, 0); + r._profileSwitch.packetResult(0, 32, 1, 255, 0, 0); + r.result = failure ? -707 : 0; + int rc = 0; + switch (api) { + case 0: rc = r.begin(ConfigLoRa_t{}); break; + case 1: rc = r.beginFSK(ConfigFSK_t{}); break; + case 2: rc = r.beginLRFHSS(ConfigLRFHSS_t{}); break; + case 3: rc = r.setModem(0); break; + case 4: rc = r.beginFSK(910.0f); break; + case 5: rc = r.beginLRFHSS(910.0f); break; + } + assert(rc == r.result && r.initializations == 1); + assert(r._coldStandby && !r._tcxoWakePending); + assert(r.setLoRaModulationParams(125, 9, 7) == 0 && r.queries == 1); + } +} +''' + for policy in ("#define MC_SX1262_FAST_MODEM_CACHE 0", + "#define MC_SX1262_FAST_MODEM_CACHE 1"): + with self.subTest(policy=policy): + self.compile_run(fixture.replace("@DEFINES@", policy) + .replace("@STATE@", state).replace("@PRODUCTION@", production)) diff --git a/test/test_radio_sx1262_profile_switch.py b/test/test_radio_sx1262_profile_switch.py new file mode 100644 index 000000000..1fc05f381 --- /dev/null +++ b/test/test_radio_sx1262_profile_switch.py @@ -0,0 +1,213 @@ +"""Exercise production profile-switch ownership, caching and failure paths.""" +from pathlib import Path +from radio_profile_test_support import NativeTest, method +ROOT = Path(__file__).resolve().parents[1] + +class FastProfileSwitchTests(NativeTest): + def test_packet_tuple_requires_matching_acknowledged_owned_rx(self): + state = (ROOT / "src/helpers/radiolib/SX1262ProfileSwitchState.h").read_text() + harness = r''' +#include +#include +#include +@STATE@ +int main() { + SX1262ProfileSwitchState s(true); + s.packetResult(0, 32, 1, 255, 0, 0); + assert(!s.matchesPacket(32, 1, 255, 0, 0)); + s.rxResult(0, true); + s.begin(true, true); + s.standbyResult(0); + assert(s.matchesPacket(32, 1, 255, 0, 0)); + assert(!s.matchesPacket(33, 1, 255, 0, 0)); + assert(!s.matchesPacket(32, 0, 255, 0, 0)); + assert(!s.matchesPacket(32, 1, 16, 0, 0)); + assert(!s.matchesPacket(32, 1, 255, 1, 0)); + assert(!s.matchesPacket(32, 1, 255, 0, 1)); + s.invalidatePacket(); + assert(!s.matchesPacket(32, 1, 255, 0, 0)); + s.packetResult(0, 32, 1, 255, 0, 0); + assert(s.matchesPacket(32, 1, 255, 0, 0)); + s.packetResult(-100, 32, 1, 255, 0, 0); + assert(s.failed() && !s.packetValid && !s.rxValid); +} +''' + self.compile_run(harness.replace("@STATE@", state)) + + def test_owned_rx_window_commands_errors_and_lifecycle_invalidation(self): + src = (ROOT / "src/helpers/radiolib/CustomSX1262.h").read_text() + state = (ROOT / "src/helpers/radiolib/SX1262ProfileSwitchState.h").read_text().replace("#pragma once", "") + signatures = ["void setProfileSwitchOptimization(", "void beginProfileSwitch(", + "void endProfileSwitch(", "bool profileSwitchFailed(", "int16_t restoreTcxoAfterSleep(", + "int16_t standby()", "int16_t standby(uint8_t", "int16_t sleep()", + "int16_t sleep(bool", "int16_t reset(bool", "int16_t stageMode(", + "int16_t setPreambleLength(", "int16_t setCRC(", + "int16_t implicitHeader(", "int16_t explicitHeader()", + "int16_t invertIQ(", "int16_t startReceive()"] + methods = "\n".join(method(src, signature).replace(" override", "") for signature in signatures) + harness = r''' +#include +#include +#include +#include +@STATE@ +#define MC_SX1262_FAST_FS @FS@ +#define MC_SX1262_FAST_PACKET_CACHE @PACKET@ +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_SX126X_STANDBY_XOSC 1 +#define RADIOLIB_SX126X_STANDBY_RC 0 +#define RADIOLIB_SX126X_XOSC_START_ERR 32 +#define RADIOLIB_ERR_SPI_CMD_FAILED -200 +#define RADIOLIB_SX126X_RX_TIMEOUT_INF 0xffffff +#define RADIOLIB_SX126X_PACKET_TYPE_LORA 1 +#define RADIOLIB_RADIO_MODE_NONE 0 +#define RADIOLIB_IRQ_RX_DEFAULT_FLAGS 1 +#define RADIOLIB_IRQ_RX_DEFAULT_MASK 2 +#define RADIOLIB_IRQ_PREAMBLE_DETECTED 3 +using RadioModeType_t=int; +using RadioLibIrqFlags_t=uint32_t; +struct RadioModeConfig_t {}; +struct Module { + enum { MODE_IDLE=0, MODE_RX=1 };std::string* trace; + void setRfSwitchState(int mode) { assert(mode==MODE_IDLE || mode==MODE_RX);*trace+=mode==MODE_RX?'W':'I'; } +}; +struct SX1262 { + std::string trace;char fail=0;int modem=1;bool standbyXOSC=true; + size_t preambleLengthLoRa=32; + uint8_t crcTypeLoRa=1,implicitLen=255,headerType=0,invertIQEnabled=0; + uint32_t rxTimeout=0;int stagedMode=3; + float tcxoVoltage=1.8f;uint32_t tcxoDelay=1600;unsigned errors=32; + Module module{&trace};Module* mod=&module; + virtual ~SX1262()=default; + int16_t call(char c) { trace+=c;return fail==c ? -100:0; } + Module* getMod() { return mod; } + int16_t setFs() { return call('f'); } + virtual int16_t standby() { return call('S'); } + virtual int16_t standby(uint8_t) { return call('U'); } + int16_t standby(uint8_t mode,bool wake) { assert(mode==1);return call(wake?'S':'s'); } + virtual int16_t sleep() { return call('L'); } + int16_t sleep(bool) { return call('l'); } + int16_t reset(bool) { return call('Z'); } + unsigned getDeviceErrors() { call('E');return errors; } + int16_t setTCXO(float voltage,uint32_t delay) { + assert(voltage==tcxoVoltage && delay==1600);return call('V'); + } + virtual int16_t stageMode(RadioModeType_t mode,RadioModeConfig_t*) { return call(mode==1?'G':'T'); } + virtual int16_t setPreambleLength(size_t n) { preambleLengthLoRa=n;return call('M'); } + int16_t setCRC(uint8_t,uint16_t,uint16_t,bool) { return call('c'); } + int16_t implicitHeader(size_t) { return call('h'); } + int16_t explicitHeader() { return call('e'); } + virtual int16_t invertIQ(bool) { return call('i'); } + int16_t startReceive(uint32_t timeout,uint32_t flags,uint32_t mask,size_t len) { + assert(timeout==0xffffff && flags==9 && mask==2 && len==0); + int16_t rc=stageMode(1,nullptr);if(rc==0) rc=standby();if(rc==0) rc=call('F');return rc; + } + int16_t startReceiveDutyCycle(uint32_t,uint32_t,uint32_t,uint32_t) { return call('D'); } + uint8_t getPacketType() { call('Q');return modem; } + int16_t clearIrqStatus() { return call('C'); } + int16_t setPacketParams(size_t p,int crc,int len,int header,int iq) { + assert(p==preambleLengthLoRa && crc==1 && len==255 && header==0 && iq==0);return call('P'); + } + int16_t setRx(uint32_t timeout) { assert(timeout==0xffffff);return call('R'); } +}; +struct Radio: SX1262 { + SX1262ProfileSwitchState _profileSwitch{true};bool _rx_ps_rf_rx_disabled=false;uint32_t _profileFastResumes=0; + bool _coldStandby=true; + bool _tcxoWakePending=false; + @METHODS@ +}; +void prime(Radio& r) { + const bool cold=r._coldStandby; + const bool wake=r._tcxoWakePending; + r.trace.clear();assert(r.startReceive()==0);assert(r.trace==(wake ? "GUEVFQ":cold ? "GUFQ":"GSFQ")); + assert(!r._coldStandby); + assert(!r._profileSwitch.modulationValid); // full RX setup does not prime a tuple + assert(r._profileSwitch.rxValid && r._profileSwitch.packetValid);r.trace.clear(); +} +void fast(Radio& r) { + r.beginProfileSwitch(true);assert(r.standby()==0); + assert(r.trace==(MC_SX1262_FAST_FS ? "If" : "s"));r.trace.clear(); + r._profileSwitch.modulationResult(0,8,4,1,0); + assert(r._profileSwitch.matchesModulation(8,4,1,0)); + assert(r.setPreambleLength(120)==0 && r.trace.empty()); + const bool unchanged = r._profileSwitch.packetValid && r._profileSwitch.packetPreamble==120; + assert(r.startReceive()==0 && r.trace==(MC_SX1262_FAST_PACKET_CACHE && unchanged ? "CWR" : "CPWR")); + assert(r.stagedMode==0 && r._profileSwitch.rxValid); + assert(r._profileSwitch.modulationValid); // successful fast resume preserves it + r.endProfileSwitch(true);assert(!r._profileSwitch.open);r.trace.clear(); +} +int main() { + Radio r;prime(r);fast(r);fast(r); + // Deferring before standby changes no hardware and retains the RX context. + r.beginProfileSwitch(true);r.endProfileSwitch(true);fast(r); + // No shortcut is legal before a successful owned standby. + r.beginProfileSwitch(true);assert(r.setPreambleLength(88)==0 && r.trace=="M"); + r.endProfileSwitch(true);r.trace.clear(); + // Ordinary standby, mode staging (TX/CAD), sleep and reset revoke RX. + for(int event=0;event<6;++event) { + Radio x;prime(x); + x._profileSwitch.modulationResult(0,8,4,1,0); + switch(event) { + case 0:x.standby();break; + case 1:x.standby(uint8_t(0));break; + case 2:x.stageMode(2,nullptr);break; + case 3:x.sleep();break; + case 4:x.sleep(false);break; + case 5:x.reset();break; + + } + assert(!x._profileSwitch.rxValid && !x._profileSwitch.modulationValid + && !x._profileSwitch.packetValid); + x.trace.clear();x.beginProfileSwitch(true); + const bool cold=x._coldStandby; + const bool wake=x._tcxoWakePending; + assert(x.standby()==0 && x.trace==(wake ? "UEV":cold ? "U":"S"));x.endProfileSwitch(true);prime(x);fast(x); + } + for(int gate=0;gate<3;++gate) { + Radio x;prime(x); + if(gate==0) x.setProfileSwitchOptimization(false); + if(gate==1) x.standbyXOSC=false; + x.beginProfileSwitch(gate!=2);x.trace.clear(); + assert(x.standby()==0 && x.trace=="S");x.endProfileSwitch(true); + } + for(int setter=0;setter<5;++setter) { + Radio x;prime(x); + switch(setter) { + case 0:x.setPreambleLength(120);break; + case 1:x.setCRC(1);break; + case 2:x.implicitHeader(16);break; + case 3:x.explicitHeader();break; + case 4:x.invertIQ(true);break; + } + assert(!x._profileSwitch.packetValid && x._profileSwitch.rxValid); + } + const char standbyFail = MC_SX1262_FAST_FS ? 'f' : 's'; + for(char fail:std::string(1,standbyFail)+"CPR") { + Radio x;prime(x);x.beginProfileSwitch(true);x.fail=fail; + if(fail==standbyFail) assert(x.standby()==-100); + else { assert(x.standby()==0);if(fail=='P') x.setPreambleLength(120);assert(x.startReceive()==-100); } + assert(x.profileSwitchFailed() && !x._profileSwitch.rxValid); + assert(x.trace.back()==fail);x.endProfileSwitch(false);x.fail=0;prime(x);fast(x); + } + Radio x;prime(x);x.beginProfileSwitch(true);x.standby(); + // A modulation/frequency/packet apply failure must revoke the capability + // before the wrapper rolls back with normal setup. + x.endProfileSwitch(false);assert(!x._profileSwitch.rxValid);prime(x);fast(x); + Radio sleeper;prime(sleeper);assert(sleeper.sleep()==0 && sleeper.trace=="Ul"); + prime(sleeper);sleeper.fail='U';assert(sleeper.sleep(true)==-100 && sleeper.trace=="U"); + assert(!sleeper._profileSwitch.rxValid && sleeper._coldStandby); + Radio fault;prime(fault);fault.sleep();fault.trace.clear();fault.errors=4; + assert(fault.standby()==-200 && fault.trace=="UE" && fault._tcxoWakePending); + fault.errors=32;fault.trace.clear();assert(fault.standby()==0 && fault.trace=="UEV"); + assert(!fault._tcxoWakePending); + Radio wrong;wrong.modem=0;assert(wrong.startReceive()==0 && !wrong._profileSwitch.rxValid); + Radio off;off.setProfileSwitchOptimization(false);assert(off.startReceive()==0); + assert(off.trace=="GUF" && !off._profileSwitch.rxValid); +} +''' + for fs, packet in ((0, 0), (1, 1)): + with self.subTest(fs=fs, packet=packet): + self.compile_run(harness.replace("@STATE@", state) + .replace("@METHODS@", methods) + .replace("@FS@", str(fs)) + .replace("@PACKET@", str(packet))) diff --git a/variants/heltec_v4/target.cpp b/variants/heltec_v4/target.cpp index 8483f2768..80377ac5f 100644 --- a/variants/heltec_v4/target.cpp +++ b/variants/heltec_v4/target.cpp @@ -1,11 +1,19 @@ #include #include "target.h" +#if defined(USE_SX1262) +#include +#endif HeltecV4Board board; #if defined(P_LORA_SCLK) static SPIClass spi; +#if defined(USE_SX1262) + static ESP32BufferedRadioHal radio_hal(spi); + RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#else RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#endif #else RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); #endif diff --git a/variants/minewsemi_me25ls01/target.cpp b/variants/minewsemi_me25ls01/target.cpp index 41fc71a7d..d1fe6898c 100644 --- a/variants/minewsemi_me25ls01/target.cpp +++ b/variants/minewsemi_me25ls01/target.cpp @@ -1,9 +1,11 @@ #include #include "target.h" +#include MinewsemiME25LS01Board board; -RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); +Nrf52BufferedRadioHal radioHal(SPI); +RADIO_CLASS radio = new Module(&radioHal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); WRAPPER_CLASS radio_driver(radio, board); VolatileRTCClock rtc_clock; diff --git a/variants/station_g3_esp32/target.cpp b/variants/station_g3_esp32/target.cpp index d3de074ba..07e01b09d 100644 --- a/variants/station_g3_esp32/target.cpp +++ b/variants/station_g3_esp32/target.cpp @@ -1,11 +1,19 @@ #include #include "target.h" +#if defined(USE_SX1262) +#include +#endif StationG3Board board; #if defined(P_LORA_SCLK) static SPIClass spi; +#if defined(USE_SX1262) + static ESP32BufferedRadioHal radio_hal(spi); + RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#else RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#endif #else RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); #endif diff --git a/variants/t1000-e/target.cpp b/variants/t1000-e/target.cpp index 70a64359f..1403bb0f6 100644 --- a/variants/t1000-e/target.cpp +++ b/variants/t1000-e/target.cpp @@ -1,12 +1,14 @@ #include #include "t1000e_sensors.h" #include "target.h" +#include #include #include T1000eBoard board; -RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); +Nrf52BufferedRadioHal radioHal(SPI); +RADIO_CLASS radio = new Module(&radioHal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); WRAPPER_CLASS radio_driver(radio, board); diff --git a/variants/thinknode_m3/target.cpp b/variants/thinknode_m3/target.cpp index 1e6c2ad13..c6c10ca5e 100644 --- a/variants/thinknode_m3/target.cpp +++ b/variants/thinknode_m3/target.cpp @@ -1,10 +1,12 @@ #include #include "target.h" +#include #include ThinkNodeM3Board board; -RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); +Nrf52BufferedRadioHal radioHal(SPI); +RADIO_CLASS radio = new Module(&radioHal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); WRAPPER_CLASS radio_driver(radio, board); diff --git a/variants/thinknode_m7/target.cpp b/variants/thinknode_m7/target.cpp index f4b898d8e..1883bccd8 100644 --- a/variants/thinknode_m7/target.cpp +++ b/variants/thinknode_m7/target.cpp @@ -1,11 +1,13 @@ #include #include "target.h" +#include #include ThinkNodeM7Board board; static SPIClass spi; -RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +ESP32BufferedRadioHal radioHal(spi, 16000000); +RADIO_CLASS radio = new Module(&radioHal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); WRAPPER_CLASS radio_driver(radio, board); ESP32RTCClock fallback_clock; diff --git a/variants/thinknode_m9/target.cpp b/variants/thinknode_m9/target.cpp index 3cbd7d758..d3dfb6089 100644 --- a/variants/thinknode_m9/target.cpp +++ b/variants/thinknode_m9/target.cpp @@ -1,9 +1,11 @@ #include #include "target.h" +#include ThinkNodeM9Board board; -RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); +ESP32BufferedRadioHal radioHal(SPI, 16000000); +RADIO_CLASS radio = new Module(&radioHal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); WRAPPER_CLASS radio_driver(radio, board); ESP32RTCClock fallback_clock; diff --git a/variants/wio_wm1110/target.cpp b/variants/wio_wm1110/target.cpp index 1bb7cec8c..52a8ac75d 100644 --- a/variants/wio_wm1110/target.cpp +++ b/variants/wio_wm1110/target.cpp @@ -1,10 +1,12 @@ #include #include "target.h" +#include #include WioWM1110Board board; -RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); +Nrf52BufferedRadioHal radioHal(SPI); +RADIO_CLASS radio = new Module(&radioHal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); WRAPPER_CLASS radio_driver(radio, board); diff --git a/variants/xiao_s3_wio/target.cpp b/variants/xiao_s3_wio/target.cpp index 8a975959f..847f02c3c 100644 --- a/variants/xiao_s3_wio/target.cpp +++ b/variants/xiao_s3_wio/target.cpp @@ -1,11 +1,13 @@ #include #include "target.h" +#include XiaoS3WIOBoard board; #if defined(P_LORA_SCLK) static SPIClass spi; - RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); + static ESP32BufferedRadioHal radio_hal(spi); + RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); #else RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); #endif