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