diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 9f305e066..daa47d9b9 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -312,6 +312,10 @@ jobs: python3 -B test/test_meshtext_config.py -v python3 -B test/test_meshtext_transport.py -v python3 -B test/test_meshtext_license.py -v + python3 -B test/test_beacon_frames.py -v + python3 -B test/test_beacon_manager.py -v + python3 -B test/test_beacon_radio.py -v + python3 -B test/test_beacon_dispatcher.py -v python3 -B test/test_radio_profile_auto.py -v python3 -B test/test_companion_meshtext.py -v python3 -B test/test_companion_meshtext_storage.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index feb0a22ee..1a2e4df52 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2792,6 +2792,12 @@ device power saving off. --- +#### Periodic transmit-only text beacons + +See [TX beacons](tx_beacons.md) for the 12 independent MeshCore Public or +Meshtext slots. `set beacon.interval 12|24|36|48` selects the shared cycle; +the default is 24 hours. Slots are staggered and disabled until configured. + #### View or change the radio watchdog interval (MQTT observer only) **Usage:** - `get radio.watchdog` diff --git a/docs/tx_beacons.md b/docs/tx_beacons.md new file mode 100644 index 000000000..b02429613 --- /dev/null +++ b/docs/tx_beacons.md @@ -0,0 +1,79 @@ +# Transmit-only text beacons + +Up to 12 disabled-by-default slots can send a short text message using their +own frequency, bandwidth, spreading factor, coding rate, power, and preamble. +MeshCore beacons use the Public channel. Meshtext beacons use the public preset +channel, such as LongFast or MediumFast, with its standard public key. + +The default cycle is **24 hours**. Choose 12, 24, 36, or 48 hours: + +```text +get beacon.interval +set beacon.interval 24 +``` + +Slots are spread evenly across the selected cycle: 1, 2, 3, or 4 hours apart. +Slot 1 is due after the first spacing interval, slot 12 after a full cycle, +then the cycle repeats. Empty slots are not compacted. Scheduling uses uptime, +not UTC; correcting the clock does not compress the spacing. Reboot starts a +fresh schedule, with no immediate startup burst. + +## Configuration + +```text +set beacon 1 meshcore 910.525,62.5,7,5,14,32 A MeshCore node is listening here +set beacon 2 meshtext us longfast 14 A MeshCore node is also on LongFast +set beacon 3 meshtext us mediumfast 14 A MeshCore node is also on MediumFast +get beacon +get beacon 2 +delete beacon 2 +delete beacon all +``` + +MeshCore tuple order is `MHz,kHz,SF,CR,dBm[,preamble]`; CR is 5 through 8, +representing 4/5 through 4/8. The default beacon preamble is 32 symbols. +Meshtext takes an explicit `us`, `eu868`, `eu433`, or `anz` region, a recognized +preset, and dBm. Its frequency is calculated using the stock preset channel; +its preamble is fixed at 16 symbols. Invalid modem, region, power, or text +settings are rejected. Use settings permitted for your hardware and location. + +Each message body is limited to 80 bytes and is prefixed with the node name. +Settings are saved separately from ordinary preferences and channels. +Configuration changes do not immediately transmit a beacon. + +## Radio ownership and limits + +These are ordinary channel text messages, not MeshCore identity advertisements +or Meshtext NodeInfo. Meshtext beacons request no ACK and have no forwarding +budget. MeshCore Public uses one locally transmitted flood; other MeshCore +nodes may relay that flood according to their own policies. + +The transmitter briefly borrows the physical radio, sends once, and restores +its normal receive settings, power, and RX power saving. It does not occupy, +replace, or persist changes to `radio2`, including a temporary second profile. +Meshtext beacon identity is separate from Companion Meshtext chat; beacon +packet IDs are committed to storage before use to avoid reusing chat nonces. +Keep manually assigned Meshtext node IDs unique; a manual chat ID can still +collide with another sender, including the separate beacon sender. A missing +or unreadable active beacon ID file stops Meshtext beacons rather than rolling +back to a potentially older backup. MeshCore beacons remain independent. + +Beacons share the normal airtime budget and yield to due MeshCore traffic. +They do not interrupt a pending packet, Morse operation, carrier test, or +noise-floor calibration. Busy-channel, preparation, and transmission failures +skip that opportunity; there is no automatic RF retry or catch-up burst. A +separate transmit-start guard permits at most one beacon attempt per hour. +Receive restoration retains exclusive ownership until it is confirmed. +An unavailable/suspended radio does not transmit a beacon. + +The feature is available on supported ESP32, nRF52, and RP2040 LoRa builds +where flash and runtime RAM qualification pass. Tiny STM32 images leave it +disabled to preserve existing features and filesystem space. Meshtext TX is +supported only by the direct SX1262 modem implementation; an unsupported +modem rejects Meshtext slots rather than transmitting MeshCore in their place. +Enabling this feature does not enable Companion Meshtext receive/chat support +on other roles. + +Schedules are best effort. A busy or sleeping node can miss an opportunity; +the feature does not promise delivery, RF sensitivity, or a particular +retuning duration on unmeasured hardware. diff --git a/examples/companion_radio/BeaconCompanion.cpp b/examples/companion_radio/BeaconCompanion.cpp new file mode 100644 index 000000000..41aef5247 --- /dev/null +++ b/examples/companion_radio/BeaconCompanion.cpp @@ -0,0 +1,30 @@ +#include "MyMesh.h" + +#if MESH_ENABLE_TX_BEACONS +#include +#include + +void MyMesh::beginBeacons() { + auto* fs = _store->getPrimaryFS(); + if (_beacons || !fs || (!fs->exists("/beacons") && !fs->exists("/beacons.bak"))) return; + _beacons = new (std::nothrow) mesh::BeaconManager; + if (_beacons) _beacons->begin(fs, this, getRTCClock(), _prefs.node_name); +} + +void MyMesh::loopBeacons() { if (_beacons) _beacons->loop(millis()); } + +void MyMesh::suspendBeacons() { + if (_beacons) _beacons->suspend(); + cancelBeaconRadioFrame(); +} + +bool MyMesh::handleBeaconCommand(const char* command, char* reply, size_t capacity) { + if (!mesh::BeaconManager::isCommand(command)) return false; + if (!_beacons) { + _beacons = new (std::nothrow) mesh::BeaconManager; + if (_beacons) _beacons->begin(_store->getPrimaryFS(), this, getRTCClock(), _prefs.node_name); + } + if (!_beacons) { snprintf(reply, capacity, "Error: beacon allocation failed"); return true; } + return _beacons->handle(command, reply, capacity); +} +#endif diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7bf1ae2da..1dc200cef 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3158,6 +3158,9 @@ void MyMesh::begin(bool has_display, bool radio_available, #endif configureRadioFromPrefs(); +#if MESH_ENABLE_TX_BEACONS + beginBeacons(); +#endif #if MESH_ENABLE_MESHTEXT_CHAT beginMeshtext(); #endif @@ -3688,6 +3691,9 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, } if (handleTxRoutingCommand(command, reply, reply_size)) return true; +#if MESH_ENABLE_TX_BEACONS + if (handleBeaconCommand(command, reply, reply_size)) return true; +#endif #if MESH_ENABLE_MESHTEXT_CHAT if (handleMeshtextCommand(command, reply, reply_size)) return true; #endif @@ -7234,6 +7240,9 @@ void MyMesh::handleCmdFrame(size_t len) { MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)"); _serial->disable(); // Phone app disconnects before we can send OK frame so it's safe here } +#if MESH_ENABLE_TX_BEACONS + suspendBeacons(); +#endif bool success = _store->formatFileSystem(); if (success) { writeOKFrame(); @@ -10030,6 +10039,10 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, " board\r\n" " version\r\n"); terminalOutput().print(" get storage.layout\r\n"); +#if MESH_ENABLE_TX_BEACONS + terminalOutput().print(" get beacon [slot] / set beacon ...\r\n" + " delete beacon / set beacon.interval <12|24|36|48> (default 24h)\r\n"); +#endif #if MESH_ENABLE_MESHTEXT_CHAT terminalOutput().print(" get mt.status / get mt.region / get mt.slot / get mt.hops\r\n" " get mt.node / get mt.shortname\r\n" @@ -10303,6 +10316,9 @@ bool MyMesh::handleDirectCommand(const char* command, char* reply, size_t reply_ return true; } if (strcmp(command, "erase") == 0) { +#if MESH_ENABLE_TX_BEACONS + suspendBeacons(); +#endif if (_store->formatFileSystem()) { stopContactsIterator(); resetContacts(); @@ -10836,6 +10852,9 @@ complete_line: scanInternalExtraFS(output); #endif } else if (strcmp(cli_command, "rebuild") == 0) { +#if MESH_ENABLE_TX_BEACONS + suspendBeacons(); +#endif bool success = _store->formatFileSystem(); if (success) { const bool identity_saved = _store->saveMainIdentity(self_id); @@ -10862,6 +10881,9 @@ complete_line: output.println(" Error: erase failed"); } } else if (strcmp(cli_command, "erase") == 0) { +#if MESH_ENABLE_TX_BEACONS + suspendBeacons(); +#endif bool success = _store->formatFileSystem(); #if MESH_ENABLE_MESHTEXT_CHAT if (success && !eraseMeshtextSettings()) { @@ -11119,6 +11141,9 @@ void MyMesh::loop() { #endif if (!mesh::morseTimingActive(_radio)) _radio_profiles.loop(); +#if MESH_ENABLE_TX_BEACONS + if (!mesh::morseTimingActive(_radio)) loopBeacons(); +#endif #if MESH_ENABLE_MESHTEXT_CHAT refreshMeshtextProfile(); #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index de18a64fe..b5978edfa 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -29,6 +29,7 @@ #include #include #include +namespace mesh { class BeaconManager; } #if MESH_ENABLE_MESHTEXT_CHAT #include #endif @@ -985,6 +986,13 @@ private: void checkControlDataForPendingDiscovery(uint8_t payload[], size_t p_len); #endif +#if MESH_ENABLE_TX_BEACONS + mesh::BeaconManager* _beacons = nullptr; + void beginBeacons(); + void loopBeacons(); + void suspendBeacons(); + bool handleBeaconCommand(const char* command, char* reply, size_t capacity); +#endif }; extern MyMesh the_mesh; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index a3d0d2b6e..5c5288864 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -47,6 +47,9 @@ static uint32_t scaleCADDelayForQueue(uint32_t normal_delay, int ready_count, void Dispatcher::setRadioAvailable(bool available) { if (radio_available == available) return; +#if MESH_ENABLE_TX_BEACONS + if (!available) cancelBeaconRadioFrame(); +#endif radio_available = available; #if MESH_ENABLE_MESHTEXT_CHAT if (!available && hasForeignRadioFrame()) { @@ -56,6 +59,9 @@ void Dispatcher::setRadioAvailable(bool available) { #endif radio_nonrx_timer_armed = false; if (!dispatcher_started || !radio_available) return; +#if MESH_ENABLE_TX_BEACONS + if (serviceBeaconRadioFrame()) return; +#endif const unsigned long now = _ms->getMillis(); radio_nonrx_start = now; @@ -73,6 +79,10 @@ void Dispatcher::setRadioAvailable(bool available) { } void Dispatcher::begin() { +#if MESH_ENABLE_TX_BEACONS + cancelBeaconRadioFrame(); + if (hasBeaconRadioFrame()) return; // restoration owns the peripheral +#endif #if MESH_ENABLE_MESHTEXT_CHAT if (hasForeignRadioFrame()) { if (foreign_radio_sending && radio_available) _radio->onSendFinished(); @@ -282,6 +292,9 @@ bool Dispatcher::isForeignRadioCurrent() const { bool Dispatcher::queueForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation) { +#if MESH_ENABLE_TX_BEACONS + if (hasBeaconRadioFrame()) return false; +#endif const auto* profiles = _radio->profiles(); if (!bytes || !length || length > sizeof(foreign_radio_bytes) || hasForeignRadioFrame() || !radio_available || !_radio->supportsMeshtext() || _radio->isCarrierWaveActive() @@ -465,11 +478,26 @@ bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { const uint32_t now = _ms->getMillis(); bool found = false; uint32_t shortest_delay = 0; +#if MESH_ENABLE_TX_BEACONS + if (radio_available && hasBeaconRadioFrame()) { + // TX completion and restoration need the fast lifecycle, not timed sleep. + delay_millis = 0; + return true; + } + if (radio_available && beacon_wake_enabled) { + const int32_t wait = int32_t(beacon_wake_at - now); + shortest_delay = wait > 0 ? uint32_t(wait) : 0; + found = true; + } +#endif // Timed MCU sleep must not skip the adaptive maintenance/retry deadline. // Preserve the legacy queue-only query when power saving is off. if (radio_available && useAdaptiveAGC()) { - found = agc_reset.nextDelay(now, shortest_delay); - if (found && !agc_reset.recoveryMatches(_radio->getRecoveryCount())) { + uint32_t agc_delay; + const bool agc_found = agc_reset.nextDelay(now, agc_delay); + if (agc_found && (!found || agc_delay < shortest_delay)) shortest_delay = agc_delay; + found = found || agc_found; + if (agc_found && !agc_reset.recoveryMatches(_radio->getRecoveryCount())) { // A Dispatcher-owned recovery can occur after configure() this loop. // Poll once before sleeping so the base cadence is restored promptly. delay_millis = 0; @@ -535,6 +563,10 @@ void Dispatcher::loop() { return; } +#if MESH_ENABLE_TX_BEACONS + // Restore before any calibration/scan/watchdog can observe a foreign tuple. + if (serviceBeaconRadioFrame()) return; +#endif if (millisHasNowPassed(next_floor_calib_time)) { _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); _radio->setCADEnabled(getCADEnabled()); @@ -805,6 +837,9 @@ void Dispatcher::loop() { } #endif } +#if MESH_ENABLE_TX_BEACONS + if (checkBeaconRadioSend()) { releaseDroppedOutbound(); return; } +#endif #if MESH_ENABLE_MESHTEXT_CHAT if (!checkForeignRadioSend()) checkSend(); #else diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 4d6b54543..400f423d9 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -9,6 +9,7 @@ #include #include #include +#include #ifndef RADIO_LIVENESS_SOFT_ONLY #include #endif @@ -91,6 +92,15 @@ public: virtual const RadioProfiles* profiles() const { return nullptr; } virtual bool validateProfile(const RadioProfileParams&) const { return false; } virtual bool supportsMeshtext() const { return false; } +#if MESH_ENABLE_TX_BEACONS + virtual RadioParamApplyResult prepareBeaconTransmit(const RadioProfileParams&, int8_t) { + return RadioParamApplyResult::FAILED; + } + virtual RadioParamApplyResult restoreBeaconReceive() { return RadioParamApplyResult::APPLIED; } + virtual bool isBeaconRadioOwned() const { return false; } + virtual bool isBeaconChannelBusy() { return true; } + virtual uint32_t getBeaconAirtime(const RadioProfileParams&, int) { return 0; } +#endif virtual uint8_t receiveProfile() const { return 0; } virtual uint32_t receiveProfileGeneration() const { return profiles() ? profiles()->generation[receiveProfile()] : 0; @@ -553,6 +563,17 @@ protected: public: void begin(); void loop(); +#if MESH_ENABLE_TX_BEACONS + ~Dispatcher(); + bool queueBeaconRadioFrame(const uint8_t* bytes, size_t length, + const RadioProfileParams& params, int8_t power); + void cancelBeaconRadioFrame(); + bool hasBeaconRadioFrame() const { return beacon_tx != nullptr; } + void setBeaconWakeDelay(uint32_t now, uint32_t delay, bool enabled) { + beacon_wake_at = now + delay; + beacon_wake_enabled = enabled; + } +#endif Radio* getProfileRadio() { return _radio; } bool isDualRadioActive() const { return _radio->profiles() && _radio->profiles()->enabled(); } @@ -573,6 +594,9 @@ public: uint16_t getErrFlags() const { return _err_flags; } // Get error flags bool hasOutbound() const { return outbound != NULL +#if MESH_ENABLE_TX_BEACONS + || hasBeaconRadioFrame() +#endif #if MESH_ENABLE_MESHTEXT_CHAT || hasForeignRadioFrame() #endif @@ -595,6 +619,15 @@ private: uint32_t profile_cad_busy[2] = {}; uint8_t receive_context_profile = 0; bool receive_context_active = false; +#if MESH_ENABLE_TX_BEACONS + struct BeaconTransmit; + BeaconTransmit* beacon_tx = nullptr; + uint32_t beacon_wake_at = 0, beacon_last_start = 0; + bool beacon_wake_enabled = false, beacon_has_started = false; + bool serviceBeaconRadioFrame(); + bool checkBeaconRadioSend(); + void accountBeaconAirtime(); +#endif #if MESH_PACKET_LOGGING void logPacketLine(const char* direction, const Packet* packet, int len, bool include_rx_metrics, float score, diff --git a/src/DispatcherBeacon.cpp b/src/DispatcherBeacon.cpp new file mode 100644 index 000000000..8acd0e7c5 --- /dev/null +++ b/src/DispatcherBeacon.cpp @@ -0,0 +1,145 @@ +#include "Dispatcher.h" + +#if MESH_ENABLE_TX_BEACONS +#include + +namespace mesh { + +struct Dispatcher::BeaconTransmit { + enum Phase : uint8_t { Pending, Sending, Restoring } phase = Pending; + RadioProfileParams radio; + uint8_t bytes[MAX_TRANS_UNIT]; + uint16_t length = 0; + int8_t power = 0; + uint32_t retry_at = 0, prepare_expiry = 0, start = 0, expiry = 0; +}; + +Dispatcher::~Dispatcher() { delete beacon_tx; } + +bool Dispatcher::queueBeaconRadioFrame(const uint8_t* bytes, size_t length, + const RadioProfileParams& params, int8_t power) { + if (!bytes || !length || length > MAX_TRANS_UNIT || beacon_tx || outbound + || !radio_available || _radio->isCarrierWaveActive() + || _radio->isBeaconRadioOwned() + || _radio->isMorseTxActive() || _radio->isMorseRxActive() + || !RadioProfiles::valid(params) || !_radio->validateProfile(params)) return false; +#if MESH_ENABLE_MESHTEXT_CHAT + if (hasForeignRadioFrame()) return false; +#endif + const uint32_t now = _ms->getMillis(); + // Admit a due slot before checking the actual RF-start guard below. CAD + // and tuning made the preceding start slightly later than its scheduled + // hour; rejecting here would silently drop the next hourly slot. + auto* frame = new (std::nothrow) BeaconTransmit; + if (!frame) return false; + frame->radio = params; + frame->power = power; + frame->length = uint16_t(length); + memcpy(frame->bytes, bytes, length); + frame->retry_at = now; + frame->prepare_expiry = now + 30000UL; + beacon_tx = frame; + return true; +} + +void Dispatcher::accountBeaconAirtime() { + const uint32_t elapsed = uint32_t(_ms->getMillis()) - beacon_tx->start; + total_air_time += elapsed; + updateTxBudget(); + tx_budget_ms = elapsed >= tx_budget_ms ? 0 : tx_budget_ms - elapsed; + if (tx_budget_ms < 100) { + const float duty = 1.0f / (1.0f + getAirtimeBudgetFactor()); + next_tx_time = futureMillis((unsigned long)((100 - tx_budget_ms) / duty)); + } else next_tx_time = _ms->getMillis(); +} + +void Dispatcher::cancelBeaconRadioFrame() { + if (!beacon_tx) return; + if (!radio_available) { + // Suspension never performs SPI against an unavailable peripheral. + if (!_radio->isBeaconRadioOwned()) { delete beacon_tx; beacon_tx = nullptr; } + return; + } + if (beacon_tx->phase == BeaconTransmit::Sending) { + _radio->onSendFinished(); + accountBeaconAirtime(); + } + beacon_tx->phase = BeaconTransmit::Restoring; + serviceBeaconRadioFrame(); +} + +bool Dispatcher::serviceBeaconRadioFrame() { + if (!beacon_tx || beacon_tx->phase == BeaconTransmit::Pending) return false; + if (beacon_tx->phase == BeaconTransmit::Sending) { + const bool complete = _radio->isSendComplete(); + if (!complete && int32_t(uint32_t(_ms->getMillis()) - beacon_tx->expiry) < 0) return true; + _radio->onSendFinished(); + accountBeaconAirtime(); + beacon_tx->phase = BeaconTransmit::Restoring; + if (!complete) { + _err_flags |= ERR_EVENT_RADIO_WATCHDOG; + _radio->recoverRadio(true); + } + } + // Never let normal TX, AGC, or RX metadata processing use the beacon tuple. + if (_radio->restoreBeaconReceive() != RadioParamApplyResult::APPLIED) return true; + delete beacon_tx; + beacon_tx = nullptr; + radio_nonrx_start = _ms->getMillis(); + return false; +} + +bool Dispatcher::checkBeaconRadioSend() { + if (!beacon_tx || outbound || beacon_tx->phase != BeaconTransmit::Pending) return false; + const uint32_t now = _ms->getMillis(); + if (int32_t(now - beacon_tx->prepare_expiry) >= 0) { + cancelBeaconRadioFrame(); + return beacon_tx != nullptr; + } + if (int32_t(now - beacon_tx->retry_at) < 0) return false; + // Beacons are background work, never ahead of due MeshCore traffic. + if (_mgr->getOutboundCount(now) || !restoreOutboundTxOverrides() + || _radio->isCarrierWaveActive() || _radio->isMorseTxActive() + || _radio->isMorseRxActive() || _radio->isCalibratingNoiseFloor()) { + beacon_tx->retry_at = now + 101; + return false; + } + updateTxBudget(); + const uint32_t airtime = _radio->getBeaconAirtime(beacon_tx->radio, beacon_tx->length); + if (!airtime || airtime > UINT32_MAX / 1000) { + cancelBeaconRadioFrame(); + return beacon_tx != nullptr; + } + if (tx_budget_ms < airtime / 2 || !millisHasNowPassed(next_tx_time) + || (beacon_has_started && uint32_t(now - beacon_last_start) < 3600000UL)) { + beacon_tx->retry_at = now + 101; + return false; + } + const auto prepared = _radio->prepareBeaconTransmit(beacon_tx->radio, beacon_tx->power); + if (prepared == RadioParamApplyResult::BUSY) { + if (_radio->isBeaconRadioOwned()) { + cancelBeaconRadioFrame(); + return beacon_tx != nullptr; + } + // Coprime retry avoids repeatedly probing one RXPS sleep phase. + beacon_tx->retry_at = now + 11; + return false; + } + if (prepared != RadioParamApplyResult::APPLIED || _radio->isBeaconChannelBusy()) { + cancelBeaconRadioFrame(); + return beacon_tx != nullptr; + } + // Count attempts, not delivery: no automatic retry after an RF start. + beacon_has_started = true; + beacon_last_start = beacon_tx->start = _ms->getMillis(); + if (!_radio->startSendRaw(beacon_tx->bytes, beacon_tx->length)) { + cancelBeaconRadioFrame(); + return beacon_tx != nullptr; + } + beacon_tx->phase = BeaconTransmit::Sending; + beacon_tx->expiry = beacon_tx->start + airtime * 3 / 2 + 1000; + return true; +} + +} // namespace mesh +#endif diff --git a/src/Mesh.h b/src/Mesh.h index 3445f9cb8..9b8a3e843 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -618,6 +618,13 @@ public: void begin(); void loop(); +#if MESH_ENABLE_TX_BEACONS + bool validateTextBeacon(const BeaconSlot& slot) const; + bool queueTextBeacon(const BeaconSlot& slot, const char* node_name, + uint32_t mt_from, uint32_t mt_id); + uint32_t beaconNodeId() const; +#endif + LocalIdentity self_id; RNG* getRNG() const { return _rng; } diff --git a/src/MeshBeacon.cpp b/src/MeshBeacon.cpp new file mode 100644 index 000000000..0df2b58a2 --- /dev/null +++ b/src/MeshBeacon.cpp @@ -0,0 +1,63 @@ +#include + +#if MESH_ENABLE_TX_BEACONS + +#include +#include +#include + +namespace mesh { + +bool Mesh::validateTextBeacon(const BeaconSlot& slot) const { + if (!beacon::validText(slot.text) || slot.hops > 7 + || !RadioProfiles::valid(slot.radio) + || !RadioProfiles::safePreamble(slot.radio, slot.radio.preamble ? slot.radio.preamble : 32) + || slot.power < -9 || !isLoRaTxPowerValid(slot.power, slot.radio.freq) + || !_radio->validateProfile(slot.radio)) return false; + if (slot.radio.protocol == RadioProfileProtocol::MeshCore) return true; +#if MESH_ENABLE_MESHTEXT_WIRE + return slot.radio.protocol == RadioProfileProtocol::Meshtext + && _radio->supportsMeshtext() + && mt::presetName(static_cast(slot.preset)) != nullptr; +#else + return false; +#endif +} + +bool Mesh::queueTextBeacon(const BeaconSlot& slot, const char* node_name, + uint32_t mt_from, uint32_t mt_id) { + if (!validateTextBeacon(slot)) return false; + uint8_t raw[MAX_TRANS_UNIT]; + if (slot.radio.protocol == RadioProfileProtocol::Meshtext) { + const size_t length = beacon::meshtextFrame(static_cast(slot.preset), + mt_from, mt_id, slot.hops, node_name, slot.text, raw); + return length && queueBeaconRadioFrame(raw, length, slot.radio, slot.power); + } + + uint8_t plain[beacon::MaxPublicPayloadBytes]; + const size_t length = beacon::publicPayload(getRTCClock()->getCurrentTimeUnique(), + node_name, slot.text, plain, sizeof(plain)); + if (!length) return false; + GroupChannel channel{}; + memcpy(channel.secret, beacon::PublicKey, sizeof(beacon::PublicKey)); + Utils::sha256(channel.hash, sizeof(channel.hash), channel.secret, sizeof(beacon::PublicKey)); + Packet* packet = createGroupDatagram(PAYLOAD_TYPE_GRP_TXT, channel, plain, length); + if (!packet) return false; + // Serialize one unscoped Public flood. Do not enter sendFlood(), which can + // schedule reliable retries or fan the packet out across receive profiles. + packet->header = (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD; + packet->setPathHashSizeAndCount(1, 0); + const size_t wire_length = packet->writeTo(raw); + const bool queued = queueBeaconRadioFrame(raw, wire_length, slot.radio, slot.power); + if (queued) getTables()->markSent(packet); + releasePacket(packet); + return queued; +} + +uint32_t Mesh::beaconNodeId() const { + return beacon::nodeId(self_id.pub_key); +} + +} // namespace mesh + +#endif // MESH_ENABLE_TX_BEACONS diff --git a/src/helpers/BeaconConfig.h b/src/helpers/BeaconConfig.h new file mode 100644 index 000000000..996447876 --- /dev/null +++ b/src/helpers/BeaconConfig.h @@ -0,0 +1,19 @@ +#pragma once + +#include "BeaconFeatureConfig.h" + +#include + +namespace mesh { + +struct BeaconSlot { + static constexpr unsigned TextCapacity = 81; + RadioProfileParams radio; + char text[TextCapacity] = {}; + int8_t power = 0; + uint8_t preset = 0; // Meshtext preset; ignored for MeshCore Public. + uint8_t hops = 0; + bool enabled = false; +}; + +} // namespace mesh diff --git a/src/helpers/BeaconFeatureConfig.h b/src/helpers/BeaconFeatureConfig.h new file mode 100644 index 000000000..ec8b0b949 --- /dev/null +++ b/src/helpers/BeaconFeatureConfig.h @@ -0,0 +1,12 @@ +#pragma once + +// Fixed STM32 images retain their existing features and filesystem boundary. +// Host tests explicitly opt in when exercising the beacon implementation. +#ifndef MESH_ENABLE_TX_BEACONS + #if !defined(STM32_PLATFORM) && (defined(ESP32_PLATFORM) \ + || defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM)) + #define MESH_ENABLE_TX_BEACONS 1 + #else + #define MESH_ENABLE_TX_BEACONS 0 + #endif +#endif diff --git a/src/helpers/BeaconFrames.cpp b/src/helpers/BeaconFrames.cpp new file mode 100644 index 000000000..e9b345173 --- /dev/null +++ b/src/helpers/BeaconFrames.cpp @@ -0,0 +1,96 @@ +#include "BeaconFrames.h" + +#if MESH_ENABLE_TX_BEACONS + +#include "UTF8Helpers.h" +#include +#include + +namespace mesh { namespace beacon { + +const uint8_t PublicKey[16] = { + 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, + 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72 +}; + +bool validText(const char* text) { + if (!text) return false; + const size_t length = strnlen(text, MaxTextBytes + 1); + if (!length || length > MaxTextBytes + || validUtf8PrefixLength(text, length) != length) return false; + for (size_t i = 0; i < length; ++i) { + const uint8_t byte = static_cast(text[i]); + if (byte < 32 || byte == 127) return false; + } + return true; +} + +size_t formatText(const char* node_name, const char* text, char* out, size_t capacity) { + if (!node_name || !out || !validText(text)) return 0; + const size_t name_length = strnlen(node_name, MaxNameBytes + 1); + const size_t text_length = strlen(text); + if (!name_length || name_length > MaxNameBytes + || validUtf8PrefixLength(node_name, name_length) != name_length + || capacity < name_length + 2 + text_length + 1) return 0; + // The name and message use the normal MeshCore visible sender convention. + // A colon in a configured name must not create a second sender delimiter. + for (size_t i = 0; i < name_length; ++i) out[i] = node_name[i] == ':' ? ';' : node_name[i]; + out[name_length] = ':'; + out[name_length + 1] = ' '; + memcpy(out + name_length + 2, text, text_length); + const size_t length = name_length + 2 + text_length; + out[length] = 0; + return length; +} + +size_t publicPayload(uint32_t timestamp, const char* node_name, const char* text, + uint8_t* out, size_t capacity) { + if (!out || capacity < 6) return 0; + char formatted[MaxFormattedBytes + 1]; + const size_t length = formatText(node_name, text, formatted, sizeof(formatted)); + if (!length || capacity < 5 + length) return 0; + for (unsigned i = 0; i < 4; ++i) out[i] = static_cast(timestamp >> (8 * i)); + out[4] = 0; // TXT_TYPE_PLAIN, no ACK/response requested. + memcpy(out + 5, formatted, length); + return 5 + length; +} + +size_t meshtextFrame(mt::Preset preset, uint32_t from, uint32_t id, uint8_t hops, + const char* node_name, const char* text, + uint8_t out[mt::MaxFrameSize]) { +#if MESH_ENABLE_MESHTEXT_WIRE + const char* channel = mt::presetName(preset); + if (!channel || !out) return 0; + char formatted[MaxFormattedBytes + 1]; + const size_t length = formatText(node_name, text, formatted, sizeof(formatted)); + return length ? mt::encodeText(from, id, hops, channel, mt::DefaultPsk, + sizeof(mt::DefaultPsk), formatted, length, out) : 0; +#else + (void)preset; (void)from; (void)id; (void)hops; + (void)node_name; (void)text; (void)out; + return 0; +#endif +} + +uint32_t nodeId(const uint8_t public_key[32]) { + if (!public_key) return 0; + static const uint8_t domain[] = {'b', 'e', 'a', 'c', 'o', 'n'}; + uint8_t digest[4], chat_digest[4]; + Utils::sha256(digest, sizeof(digest), public_key, 32, domain, sizeof(domain)); + Utils::sha256(chat_digest, sizeof(chat_digest), public_key, 32); + const auto number = [](const uint8_t bytes[4]) { + return uint32_t(bytes[0]) | (uint32_t(bytes[1]) << 8) + | (uint32_t(bytes[2]) << 16) | (uint32_t(bytes[3]) << 24); + }; + uint32_t value = number(digest), chat = number(chat_digest); + if (value < 4 || value == UINT32_MAX) value = 0x0badf00d; + if (chat < 4 || chat == UINT32_MAX) chat = 0x0badf00d; + // Domain separation avoids systematic reuse. Also cover a truncated-hash + // collision with the default Companion identity without changing its ID. + if (value == chat) value = value == UINT32_MAX - 1 ? 4 : value + 1; + return value; +} + +} } // namespace mesh::beacon + +#endif // MESH_ENABLE_TX_BEACONS diff --git a/src/helpers/BeaconFrames.h b/src/helpers/BeaconFrames.h new file mode 100644 index 000000000..5331fe8cd --- /dev/null +++ b/src/helpers/BeaconFrames.h @@ -0,0 +1,30 @@ +#pragma once + +#include "BeaconConfig.h" +#include "MeshtextChat.h" +#include +#include + +namespace mesh { namespace beacon { + +static constexpr size_t MaxNameBytes = 31; +static constexpr size_t MaxTextBytes = BeaconSlot::TextCapacity - 1; +static constexpr size_t MaxFormattedBytes = MaxNameBytes + 2 + MaxTextBytes; +static constexpr size_t MaxPublicPayloadBytes = 5 + MaxFormattedBytes; +extern const uint8_t PublicKey[16]; + +// Inputs must be NUL terminated within their respective bounded capacities. +// Reject malformed/overlong text instead of cutting a UTF-8 scalar in half. +bool validText(const char* text); +size_t formatText(const char* node_name, const char* text, char* out, size_t capacity); +size_t publicPayload(uint32_t timestamp, const char* node_name, const char* text, + uint8_t* out, size_t capacity); +size_t meshtextFrame(mt::Preset preset, uint32_t from, uint32_t id, uint8_t hops, + const char* node_name, const char* text, + uint8_t out[mt::MaxFrameSize]); + +// Separate the automatic beacon sender from normal Companion channel chat. +// One persistent monotonic packet-ID allocator must serve this sender. +uint32_t nodeId(const uint8_t public_key[32]); + +} } // namespace mesh::beacon diff --git a/src/helpers/BeaconManager.cpp b/src/helpers/BeaconManager.cpp new file mode 100644 index 000000000..f57fd0bed --- /dev/null +++ b/src/helpers/BeaconManager.cpp @@ -0,0 +1,587 @@ +#include "BeaconManager.h" + +#if MESH_ENABLE_TX_BEACONS + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mesh { +namespace { +constexpr size_t HeaderSize = 8, RecordSize = 98; +constexpr size_t ConfigSize = HeaderSize + BeaconManager::SlotCount * RecordSize + 4; +constexpr size_t IdSize = 16; +const char* const ConfigPath = "/beacons"; +const char* const ConfigBackup = "/beacons.bak"; +const char* const ConfigTemp = "/beacons.tmp"; +const char* const IdPath = "/beacon_ids"; +const char* const IdBackup = "/beacon_ids.bak"; +const char* const IdTemp = "/beacon_ids.tmp"; + +uint32_t crcUpdate(uint32_t crc, const uint8_t* bytes, size_t size) { + while (size--) { + crc ^= *bytes++; + for (unsigned i = 0; i < 8; ++i) + crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0); + } + return crc; +} +void put32(uint8_t* bytes, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) bytes[i] = uint8_t(value >> (8 * i)); +} +uint32_t get32(const uint8_t* bytes) { + return uint32_t(bytes[0]) | (uint32_t(bytes[1]) << 8) + | (uint32_t(bytes[2]) << 16) | (uint32_t(bytes[3]) << 24); +} +bool validInterval(uint32_t hours) { + return hours == 12 || hours == 24 || hours == 36 || hours == 48; +} +void encodeSlot(const BeaconSlot& slot, uint8_t bytes[RecordSize]) { + memset(bytes, 0, RecordSize); + if (!slot.enabled) return; + static_assert(sizeof(float) == 4, "beacon images require 32-bit floats"); + uint32_t bits; + memcpy(&bits, &slot.radio.freq, 4); put32(bytes, bits); + memcpy(&bits, &slot.radio.bw, 4); put32(bytes + 4, bits); + bytes[8] = uint8_t(slot.radio.preamble); + bytes[9] = uint8_t(slot.radio.preamble >> 8); + bytes[10] = slot.radio.sf; bytes[11] = slot.radio.cr; + bytes[12] = uint8_t(slot.radio.protocol); bytes[13] = uint8_t(slot.power); + bytes[14] = slot.preset; bytes[15] = slot.hops; bytes[16] = 1; + memcpy(bytes + 17, slot.text, BeaconSlot::TextCapacity); +} +bool decodeSlot(const uint8_t bytes[RecordSize], BeaconSlot& slot) { + slot = {}; + if (bytes[16] == 0) { + for (size_t i = 0; i < RecordSize; ++i) if (bytes[i]) return false; + return true; + } + if (bytes[16] != 1 || bytes[12] > uint8_t(RadioProfileProtocol::Meshtext) + || bytes[15] > 7 || !memchr(bytes + 17, 0, BeaconSlot::TextCapacity) + || !bytes[17]) return false; + uint32_t bits = get32(bytes); memcpy(&slot.radio.freq, &bits, 4); + bits = get32(bytes + 4); memcpy(&slot.radio.bw, &bits, 4); + slot.radio.preamble = uint16_t(bytes[8]) | (uint16_t(bytes[9]) << 8); + slot.radio.sf = bytes[10]; slot.radio.cr = bytes[11]; + slot.radio.protocol = RadioProfileProtocol(bytes[12]); + slot.power = int8_t(bytes[13]); slot.preset = bytes[14]; slot.hops = bytes[15]; + slot.enabled = true; + memcpy(slot.text, bytes + 17, BeaconSlot::TextCapacity); + // Reject noncanonical trailing data instead of carrying hidden text bytes. + for (size_t i = strlen(slot.text) + 1; i < BeaconSlot::TextCapacity; ++i) + if (slot.text[i]) return false; + for (const unsigned char* c = reinterpret_cast(slot.text); *c; ++c) + if (*c < 32 || *c == 127) return false; + return isfinite(slot.radio.freq) && isfinite(slot.radio.bw) + && (slot.radio.protocol != RadioProfileProtocol::Meshtext + || (slot.preset <= uint8_t(mt::Preset::LongSlow) && slot.radio.preamble == 16)); +} +const char* skipSpace(const char* text) { + while (*text == ' ' || *text == '\t') ++text; + return text; +} +bool token(const char*& cursor, char* output, size_t size) { + cursor = skipSpace(cursor); + const char* begin = cursor; + while (*cursor && *cursor != ' ' && *cursor != '\t') ++cursor; + const size_t count = size_t(cursor - begin); + if (!count || count >= size) return false; + memcpy(output, begin, count); output[count] = 0; + cursor = skipSpace(cursor); + return true; +} +bool unsignedValue(const char* text, uint32_t& value) { + if (!*text) return false; + value = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9' || value > (UINT32_MAX - unsigned(*text - '0')) / 10) + return false; + value = value * 10 + unsigned(*text - '0'); + } + return true; +} +bool powerValue(const char* text, int8_t& value) { + bool negative = *text == '-'; + if (negative) ++text; + uint32_t parsed; + if (!unsignedValue(text, parsed) || parsed > (negative ? 128U : 127U)) return false; + value = int8_t(negative ? -int(parsed) : int(parsed)); + return true; +} +bool floatValue(const char* text, float& value) { + if (!*text || *text == ' ' || *text == '\t') return false; + errno = 0; + char* end = nullptr; + value = strtof(text, &end); + return end && !*end && errno != ERANGE && isfinite(value); +} +bool meshcoreTuple(char* text, BeaconSlot& slot) { + char* fields[6]; + unsigned count = 0; + do { + if (count == 6 || !*text) return false; + fields[count++] = text; + char* comma = strchr(text, ','); + if (!comma) break; + *comma = 0; text = comma + 1; + } while (true); + uint32_t sf, cr, preamble = 32; + if ((count != 5 && count != 6) || !floatValue(fields[0], slot.radio.freq) + || !floatValue(fields[1], slot.radio.bw) || !unsignedValue(fields[2], sf) + || !unsignedValue(fields[3], cr) || sf > 255 || cr > 255 + || !powerValue(fields[4], slot.power) + || (count == 6 && !unsignedValue(fields[5], preamble)) + || preamble < 8 || preamble > RadioProfiles::MaxPreamble) return false; + slot.radio.sf = uint8_t(sf); slot.radio.cr = uint8_t(cr); + slot.radio.preamble = uint16_t(preamble); + return true; +} +bool textValue(const char* text, char* output) { + const size_t size = strlen(text); + if (!size || size >= BeaconSlot::TextCapacity) return false; + for (size_t i = 0; i < size; ++i) { + const unsigned char c = static_cast(text[i]); + if (c < 32 || c == 127) return false; + } + memcpy(output, text, size + 1); + return true; +} +File makeFile(FILESYSTEM* fs) { +#if defined(NRF52_PLATFORM) + return File(*fs); +#else + (void)fs; + return File(); +#endif +} +bool openRead(FILESYSTEM* fs, const char* path, File& file) { +#if defined(NRF52_PLATFORM) + (void)fs; + return file.open(path, FILE_O_READ); +#elif defined(STM32_PLATFORM) + file = fs->open(path, FILE_O_READ); +#else + file = fs->open(path, "r"); +#endif + return bool(file); +} +bool openWrite(FILESYSTEM* fs, const char* path, File& file) { + bool present = false; + if (!filePresence(fs, path, present) || (present && !fs->remove(path))) return false; +#if defined(NRF52_PLATFORM) + return file.open(path, FILE_O_WRITE); +#elif defined(STM32_PLATFORM) + file = fs->open(path, FILE_O_WRITE); +#else + file = fs->open(path, "w"); +#endif + return bool(file); +} +} // namespace + +BeaconManager::~BeaconManager() { delete[] slots_; } +bool BeaconManager::allocate() { + if (!slots_) slots_ = new (std::nothrow) BeaconSlot[SlotCount]; + return slots_ != nullptr; +} +bool BeaconManager::anyEnabled() const { + if (!slots_) return false; + for (unsigned i = 0; i < SlotCount; ++i) if (slots_[i].enabled) return true; + return false; +} + +BeaconManager::ReadResult BeaconManager::readConfig( + const char* path, BeaconSlot* output, uint8_t& hours, bool& initialized) { + bool present = false; + if (!fs_ || !filePresence(fs_, path, present)) return ReadResult::Unreadable; + if (!present) return ReadResult::Missing; + File file = makeFile(fs_); + if (!openRead(fs_, path, file)) return ReadResult::Unreadable; + uint8_t header[HeaderSize]; + if (file.size() < HeaderSize || file.read(header, HeaderSize) != int(HeaderSize)) { + file.close(); return ReadResult::Unreadable; + } + if (memcmp(header, "BCN!", 4) != 0) { file.close(); return ReadResult::Invalid; } + if (header[4] != 1) { file.close(); return ReadResult::Future; } + if (file.size() != ConfigSize || header[5] != SlotCount || header[6] > 1 + || !validInterval(header[7])) { file.close(); return ReadResult::Invalid; } + uint32_t crc = crcUpdate(0xffffffffU, header, HeaderSize); + bool valid = true; + for (unsigned i = 0; i < SlotCount; ++i) { + uint8_t bytes[RecordSize]; + if (file.read(bytes, sizeof(bytes)) != int(sizeof(bytes))) { + file.close(); return ReadResult::Unreadable; + } + crc = crcUpdate(crc, bytes, sizeof(bytes)); + BeaconSlot slot; + if (!decodeSlot(bytes, slot) || (slot.enabled && (!mesh_ || !mesh_->validateTextBeacon(slot))) + || (slot.enabled && slot.radio.protocol == RadioProfileProtocol::Meshtext && !header[6])) + valid = false; + if (output) output[i] = slot; + } + uint8_t trailer[4]; + const bool read = file.read(trailer, 4) == 4; + file.close(); + if (!read) return ReadResult::Unreadable; + if (!valid || get32(trailer) != crc) return ReadResult::Invalid; + hours = header[7]; initialized = header[6] != 0; + return ReadResult::Valid; +} + +BeaconManager::ReadResult BeaconManager::readIds(const char* path, uint32_t& last) { + bool present = false; + if (!fs_ || !filePresence(fs_, path, present)) return ReadResult::Unreadable; + if (!present) return ReadResult::Missing; + File file = makeFile(fs_); + if (!openRead(fs_, path, file)) return ReadResult::Unreadable; + uint8_t bytes[IdSize]; + if (file.size() != IdSize) { file.close(); return ReadResult::Invalid; } + const bool read = file.read(bytes, sizeof(bytes)) == int(sizeof(bytes)); + file.close(); + if (!read) return ReadResult::Unreadable; + if (memcmp(bytes, "BCI!", 4)) return ReadResult::Invalid; + if (bytes[4] != 1) return ReadResult::Future; + if (bytes[5] || bytes[6] || bytes[7] + || get32(bytes + 12) != crcUpdate(0xffffffffU, bytes, 12)) return ReadResult::Invalid; + last = get32(bytes + 8); + return ReadResult::Valid; +} + +bool BeaconManager::commit(const char* path, const char* backup, + const char* temporary, bool& hold) { + bool active_present = false, backup_present = false; + if (!filePresence(fs_, path, active_present) || !filePresence(fs_, backup, backup_present)) return false; + if (active_present) { + if (backup_present && !fs_->remove(backup)) return false; + if (!fs_->rename(path, backup)) return false; + backup_present = true; + } + if (!fs_->rename(temporary, path)) { + if (backup_present && !fs_->rename(backup, path)) hold = true; + return false; + } + // A leftover backup after a successful commit is harmless. Never choose it + // over a valid active image at boot. + if (backup_present) fs_->remove(backup); + return true; +} + +bool BeaconManager::saveConfig(unsigned changed_slot, const BeaconSlot* changed, + uint8_t hours, bool initialized, bool clear_all) { + if (!fs_ || hold_ || !validInterval(hours)) return false; + File file = makeFile(fs_); + if (!openWrite(fs_, ConfigTemp, file)) return false; + uint8_t header[HeaderSize] = {'B', 'C', 'N', '!', 1, SlotCount, + uint8_t(initialized), hours}; + uint32_t crc = crcUpdate(0xffffffffU, header, sizeof(header)); + bool ok = file.write(header, sizeof(header)) == sizeof(header); + for (unsigned i = 0; i < SlotCount && ok; ++i) { + BeaconSlot empty; + const BeaconSlot& slot = clear_all ? empty + : (changed && changed_slot == i ? *changed : (slots_ ? slots_[i] : empty)); + uint8_t bytes[RecordSize]; encodeSlot(slot, bytes); + crc = crcUpdate(crc, bytes, sizeof(bytes)); + ok = file.write(bytes, sizeof(bytes)) == sizeof(bytes); + } + uint8_t trailer[4]; put32(trailer, crc); + if (ok) ok = file.write(trailer, sizeof(trailer)) == sizeof(trailer); + file.flush(); file.close(); + uint8_t verified_hours = 0; bool verified_initialized = false; + if (!ok || readConfig(ConfigTemp, nullptr, verified_hours, verified_initialized) != ReadResult::Valid + || verified_hours != hours || verified_initialized != initialized) return false; + return commit(ConfigPath, ConfigBackup, ConfigTemp, hold_); +} + +bool BeaconManager::saveIds(uint32_t last) { + if (!fs_ || ids_hold_) return false; + File file = makeFile(fs_); + if (!openWrite(fs_, IdTemp, file)) { ids_hold_ = true; return false; } + uint8_t bytes[IdSize] = {'B', 'C', 'I', '!', 1, 0, 0, 0}; + put32(bytes + 8, last); put32(bytes + 12, crcUpdate(0xffffffffU, bytes, 12)); + bool ok = file.write(bytes, sizeof(bytes)) == sizeof(bytes); + file.flush(); file.close(); + uint32_t verified = 0; + ok = ok && readIds(IdTemp, verified) == ReadResult::Valid && verified == last; + if (ok) ok = commit(IdPath, IdBackup, IdTemp, ids_hold_); + if (!ok) { ids_hold_ = true; return false; } + last_id_ = last; ids_known_ = true; + return true; +} + +bool BeaconManager::initializeIds() { + if (ids_hold_) return false; + if (ids_known_) return last_id_ != UINT32_MAX; + if (ids_initialized_) { ids_hold_ = true; return false; } + if (!mesh_ || !mesh_->getRNG()) return false; + uint32_t seed = 0; + mesh_->getRNG()->random(reinterpret_cast(&seed), sizeof(seed)); + // A deliberate filesystem wipe can preserve the node identity. Start at + // a random point rather than deterministically reusing its old CTR nonce. + // Ordinary reboots always continue the committed high-water mark. + seed = (seed & 0x7fffffffU) | 1U; + return saveIds(seed); +} +bool BeaconManager::nextId(uint32_t& id) { + if (!ids_initialized_ || !initializeIds() || last_id_ == UINT32_MAX) return false; + const uint32_t next = last_id_ + 1; + if (!saveIds(next)) return false; + id = next; + return true; +} + +void BeaconManager::begin(FILESYSTEM* fs, Mesh* mesh, RTCClock* rtc, const char* name) { + (void)rtc; // A wall-clock correction must never compress beacon spacing. + fs_ = fs; mesh_ = mesh; name_ = name; + delete[] slots_; slots_ = nullptr; + elapsed_ = anchor_ = handled_hour_ = last_send_ = 0; + memset(slot_last_send_, 0, sizeof(slot_last_send_)); + memset(slot_not_before_, 0, sizeof(slot_not_before_)); + last_now_ = millis(); interval_hours_ = 24; last_id_ = 0; + hold_ = ids_hold_ = ids_known_ = ids_initialized_ = false; + if (!fs_ || !mesh_) { hold_ = true; return; } + uint8_t hours = 24; bool initialized = false; + ReadResult result = readConfig(ConfigPath, nullptr, hours, initialized); + if (result == ReadResult::Future || result == ReadResult::Unreadable) hold_ = true; + else if (result != ReadResult::Valid) { + const ReadResult backup = readConfig(ConfigBackup, nullptr, hours, initialized); + if (backup == ReadResult::Valid) { + if ((fs_->exists(ConfigPath) && !fs_->remove(ConfigPath)) + || !fs_->rename(ConfigBackup, ConfigPath)) hold_ = true; + result = ReadResult::Valid; + } else if (result != ReadResult::Missing || backup != ReadResult::Missing) hold_ = true; + } + if (!hold_ && result == ReadResult::Valid) { + if (!allocate() || readConfig(ConfigPath, slots_, hours, initialized) != ReadResult::Valid) { + hold_ = true; + } else { interval_hours_ = hours; ids_initialized_ = initialized; } + } + if (hold_) { delete[] slots_; slots_ = nullptr; } + + uint32_t last = 0; + result = readIds(IdPath, last); + if (result == ReadResult::Future || result == ReadResult::Unreadable) ids_hold_ = true; + else if (result != ReadResult::Valid) { + const ReadResult backup = readIds(IdBackup, last); + if (backup == ReadResult::Valid) { + // Missing active metadata does not prove an interrupted rename. A + // stale backup can survive a cleanup failure after a newer ID was + // transmitted. Never rewind to it, even before the first MT setting. + ids_hold_ = true; + } else if (result != ReadResult::Missing || backup != ReadResult::Missing) ids_hold_ = true; + } + if (!ids_hold_ && result == ReadResult::Valid) { last_id_ = last; ids_known_ = true; } + if (ids_initialized_ && !ids_known_) ids_hold_ = true; + loop(last_now_); +} + +void BeaconManager::suspend() { + hold_ = true; + if (mesh_) { + mesh_->setBeaconWakeDelay(millis(), 0, false); + mesh_->cancelBeaconRadioFrame(); + } +} + +void BeaconManager::loop(uint32_t now) { + elapsed_ += uint32_t(now - last_now_); last_now_ = now; + if (!mesh_) return; + if (hold_ || !anyEnabled()) { mesh_->setBeaconWakeDelay(now, 0, false); return; } + const uint64_t spacing = uint64_t(interval_hours_ / SlotCount) * HourMillis; + const uint64_t period = uint64_t(interval_hours_) * HourMillis; + const uint64_t offset = elapsed_ - anchor_; + const uint64_t hour = offset / spacing; + uint64_t delay = spacing - offset % spacing; + if (hour > 0 && handled_hour_ != hour) { + const unsigned index = unsigned((hour - 1) % SlotCount); + BeaconSlot& slot = slots_[index]; + if (!slot.enabled) handled_hour_ = hour; + else { + uint64_t guard = last_send_ && elapsed_ - last_send_ < HourMillis + ? HourMillis - (elapsed_ - last_send_) : 0; + if (slot_not_before_[index] > elapsed_ && slot_not_before_[index] - elapsed_ > guard) + guard = slot_not_before_[index] - elapsed_; + if (slot_last_send_[index] && elapsed_ - slot_last_send_[index] < period) { + const uint64_t slot_guard = period - (elapsed_ - slot_last_send_[index]); + if (slot_guard > guard) guard = slot_guard; + } + if (!guard) { + // One admission attempt per slot window; never retry a failed TX. + handled_hour_ = hour; + uint32_t id = 0; + if ((slot.radio.protocol != RadioProfileProtocol::Meshtext || nextId(id)) + && mesh_->queueTextBeacon(slot, name_ ? name_ : "", mesh_->beaconNodeId(), id)) { + last_send_ = elapsed_; slot_last_send_[index] = elapsed_; + } + } else if (guard < delay) delay = guard; + else handled_hour_ = hour; // the old window expires before it is safe + } + } + // Empty slots do not need hourly wakeups. Find the next configured window. + if (handled_hour_ == hour || !hour) { + for (unsigned next = 1; next <= SlotCount; ++next) { + const uint64_t target = hour + next; + if (slots_[unsigned((target - 1) % SlotCount)].enabled) { + delay = target * spacing - offset; break; + } + } + } + mesh_->setBeaconWakeDelay(now, uint32_t(delay), true); +} + +void BeaconManager::formatSlot(unsigned index, char* reply, size_t capacity) const { + if (!slots_ || !slots_[index].enabled) { + snprintf(reply, capacity, "> beacon %u off", index + 1); return; + } + const BeaconSlot& slot = slots_[index]; + const char* protocol = slot.radio.protocol == RadioProfileProtocol::Meshtext ? "meshtext" : "meshcore"; + snprintf(reply, capacity, "> %u %s %.4f,%g,%u,%u,%d,%u %s", index + 1, protocol, + double(slot.radio.freq), double(slot.radio.bw), slot.radio.sf, slot.radio.cr, + int(slot.power), slot.radio.preamble, slot.text); +} + +bool BeaconManager::isCommand(const char* command) { + if (!command) return false; + const char* cursor = command; + char verb[8], setting[24]; + return token(cursor, verb, sizeof(verb)) && token(cursor, setting, sizeof(setting)) + && (!strcmp(verb, "get") || !strcmp(verb, "set") || !strcmp(verb, "delete") || !strcmp(verb, "del")) + && (!strcmp(setting, "beacon") || !strcmp(setting, "beacon.interval")); +} + +bool BeaconManager::handle(const char* command, char* reply, size_t capacity) { + if (!reply || !capacity || !isCommand(command)) return false; + if (hold_) { snprintf(reply, capacity, "Err - beacon settings unavailable; storage held"); return true; } + const bool interval_get = !strcmp(command, "get beacon.interval"); + const bool interval_set = !strncmp(command, "set beacon.interval ", 20); + if (interval_get || interval_set) { + if (interval_get) snprintf(reply, capacity, "> %u hours", interval_hours_); + else { + uint32_t hours; + if (!unsignedValue(command + 20, hours) || !validInterval(hours)) + snprintf(reply, capacity, "Err - beacon interval must be 12, 24, 36, or 48 hours"); + else if (!saveConfig(SlotCount, nullptr, uint8_t(hours), ids_initialized_)) + snprintf(reply, capacity, "Err - beacon settings not saved"); + else { + if (mesh_) mesh_->cancelBeaconRadioFrame(); + const uint32_t now = millis(); + elapsed_ += uint32_t(now - last_now_); last_now_ = now; + interval_hours_ = uint8_t(hours); anchor_ = elapsed_; handled_hour_ = 0; + snprintf(reply, capacity, "OK - beacon interval %lu hours", (unsigned long)hours); + loop(millis()); + } + } + return true; + } + const bool get = !strcmp(command, "get beacon") || !strncmp(command, "get beacon ", 11); + const bool set = !strncmp(command, "set beacon ", 11); + const bool del = !strncmp(command, "delete beacon ", 14) || !strncmp(command, "del beacon ", 11); + if (!get && !set && !del) { + snprintf(reply, capacity, "Err - use get/set beacon or beacon.interval, delete beacon"); return true; + } + const char* cursor = command + (get && !strcmp(command, "get beacon") ? 10 + : !strncmp(command, "delete ", 7) ? 14 : 11); + cursor = skipSpace(cursor); + if (get && (!*cursor || !strcmp(cursor, "all"))) { + size_t used = size_t(snprintf(reply, capacity, "> beacons %uh:", interval_hours_)); + for (unsigned i = 0; i < SlotCount && used < capacity; ++i) { + if (slots_ && slots_[i].enabled) { + const int count = snprintf(reply + used, capacity - used, " %u", i + 1); + if (count > 0) used += size_t(count); + } + } + return true; + } + if (del && !strcmp(cursor, "all")) { + if (!saveConfig(SlotCount, nullptr, interval_hours_, ids_initialized_, true)) + snprintf(reply, capacity, "Err - beacon settings not saved"); + else { + if (mesh_) mesh_->cancelBeaconRadioFrame(); + if (slots_) for (unsigned i = 0; i < SlotCount; ++i) slots_[i] = {}; + snprintf(reply, capacity, "OK - all beacons deleted"); loop(millis()); + } + return true; + } + char number[16]; uint32_t index; + if (!token(cursor, number, sizeof(number)) || !unsignedValue(number, index) + || !index || index > SlotCount) { + snprintf(reply, capacity, "Err - beacon slot must be 1..12"); return true; + } + --index; + if (get || del) { + if (*cursor) snprintf(reply, capacity, "Err - unexpected beacon arguments"); + else if (get) formatSlot(index, reply, capacity); + else { + BeaconSlot empty; + if (!saveConfig(index, &empty, interval_hours_, ids_initialized_)) + snprintf(reply, capacity, "Err - beacon settings not saved"); + else { + if (mesh_) mesh_->cancelBeaconRadioFrame(); + if (slots_) slots_[index] = {}; + snprintf(reply, capacity, "OK - beacon %lu deleted", (unsigned long)index + 1); + loop(millis()); + } + } + return true; + } + BeaconSlot candidate; + char protocol[16], argument[96]; + bool ok = token(cursor, protocol, sizeof(protocol)); + if (ok && !strcmp(protocol, "meshcore")) { + ok = token(cursor, argument, sizeof(argument)) && meshcoreTuple(argument, candidate); + } else if (ok && !strcmp(protocol, "meshtext")) { +#if MESH_ENABLE_MESHTEXT_WIRE + char region_text[12], preset_text[24], power_text[16]; + mt::Region region = mt::Region::Unset; mt::Preset preset = mt::Preset::LongFast; + ok = token(cursor, region_text, sizeof(region_text)) + && token(cursor, preset_text, sizeof(preset_text)) && token(cursor, power_text, sizeof(power_text)); + if (ok) { + for (char* c = region_text; *c; ++c) *c = char(tolower(static_cast(*c))); + if (!strcmp(region_text, "us")) region = mt::Region::US; + else if (!strcmp(region_text, "eu868")) region = mt::Region::EU868; + else if (!strcmp(region_text, "eu433")) region = mt::Region::EU433; + else if (!strcmp(region_text, "anz")) region = mt::Region::ANZ; + ok = mt::parsePreset(preset_text, preset) && mt::modemConfig(region, preset, candidate.radio) + && powerValue(power_text, candidate.power); + candidate.preset = uint8_t(preset); + candidate.radio.protocol = RadioProfileProtocol::Meshtext; + candidate.radio.preamble = 16; + } +#else + snprintf(reply, capacity, "Err - Meshtext TX is unavailable on this modem"); return true; +#endif + } else ok = false; + candidate.enabled = true; + if (!ok || !textValue(cursor, candidate.text) || !mesh_ + || !mesh_->validateTextBeacon(candidate)) { + snprintf(reply, capacity, "Err - invalid beacon radio, power, preset, or text (1..80 bytes)"); return true; + } + bool initialized = ids_initialized_; + if (candidate.radio.protocol == RadioProfileProtocol::Meshtext) { + if (!initializeIds()) { + snprintf(reply, capacity, "Err - Meshtext beacon ID storage unavailable"); return true; + } + initialized = true; + } + if (!allocate() || !saveConfig(index, &candidate, interval_hours_, initialized)) { + snprintf(reply, capacity, "Err - beacon settings not saved"); return true; + } + if (mesh_) mesh_->cancelBeaconRadioFrame(); + const uint32_t now = millis(); + elapsed_ += uint32_t(now - last_now_); last_now_ = now; + slots_[index] = candidate; ids_initialized_ = initialized; + slot_not_before_[index] = elapsed_ + HourMillis; + snprintf(reply, capacity, "OK - beacon %lu saved; %uh cycle", (unsigned long)index + 1, interval_hours_); + loop(millis()); + return true; +} +} // namespace mesh + +#endif diff --git a/src/helpers/BeaconManager.h b/src/helpers/BeaconManager.h new file mode 100644 index 000000000..f291b3ea0 --- /dev/null +++ b/src/helpers/BeaconManager.h @@ -0,0 +1,59 @@ +#pragma once + +#include + +#if MESH_ENABLE_TX_BEACONS + +#include + +namespace mesh { +class Mesh; +class RTCClock; + +// Configuration and a monotonic, best-effort schedule for TX-only beacons. +// The radio/profile transaction itself belongs to Mesh and Dispatcher. +class BeaconManager { + public: + static constexpr unsigned SlotCount = 12; + static constexpr uint32_t HourMillis = 3600000; + + BeaconManager() = default; + ~BeaconManager(); + BeaconManager(const BeaconManager&) = delete; + BeaconManager& operator=(const BeaconManager&) = delete; + void begin(FILESYSTEM* fs, Mesh* mesh, RTCClock* rtc, const char* node_name); + void suspend(); + void loop(uint32_t now); + static bool isCommand(const char* command); + bool handle(const char* command, char* reply, size_t capacity); + + private: + enum class ReadResult : uint8_t { Missing, Valid, Invalid, Unreadable, Future }; + FILESYSTEM* fs_ = nullptr; + Mesh* mesh_ = nullptr; + const char* name_ = nullptr; + BeaconSlot* slots_ = nullptr; + uint64_t elapsed_ = 0, anchor_ = 0, handled_hour_ = 0; + uint64_t last_send_ = 0, slot_last_send_[SlotCount] = {}, slot_not_before_[SlotCount] = {}; + uint32_t last_now_ = 0, last_id_ = 0; + uint8_t interval_hours_ = 24; + bool hold_ = false, ids_hold_ = false, ids_known_ = false; + bool ids_initialized_ = false; + + bool allocate(); + bool anyEnabled() const; + ReadResult readConfig(const char* path, BeaconSlot* output, + uint8_t& hours, bool& ids_initialized); + ReadResult readIds(const char* path, uint32_t& last); + bool saveConfig(unsigned changed_slot, const BeaconSlot* changed, + uint8_t hours, bool ids_initialized, bool clear_all = false); + bool saveIds(uint32_t last); + bool commit(const char* path, const char* backup, const char* temporary, + bool& hold); + bool initializeIds(); + bool nextId(uint32_t& id); + void formatSlot(unsigned index, char* reply, size_t capacity) const; +}; +} // namespace mesh + +#endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 3f349b64d..4b0deff4c 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -175,6 +175,9 @@ void CommonCLI::loop() { if (!mesh::morseTimingActive(_callbacks->getProfileRadio())) { _radio_profiles.loop(); loopManagement(); +#if MESH_ENABLE_TX_BEACONS + loopBeacons(); +#endif } #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) _user_gpio.loop(); @@ -2950,6 +2953,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #endif if (mesh::handleUsbLoggingWatchdogCommand(command, reply, 160)) return; if (handleManagementCommand(command, reply)) return; +#if MESH_ENABLE_TX_BEACONS + if (handleBeaconCommand(command, reply, 157)) return; +#endif if (mesh::wireless::control().handle(command, reply, 160, millis(), _callbacks->wirelessCommandSource(sender_timestamp))) return; // Infrastructure callers can already have reflected a three-byte CLI @@ -3249,6 +3255,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (memcmp(command, "del ", 4) == 0) { handleDelCmd(command, reply); } else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) { +#if MESH_ENABLE_TX_BEACONS + suspendBeacons(); +#endif bool s = _callbacks->formatFileSystem(); sprintf(reply, "File system erase: %s", s ? "OK" : "Err"); } else if (memcmp(command, "ver", 3) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 8025285d8..d2eec52ff 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -806,6 +806,7 @@ class LegacyUpgradeGate; namespace mesh { class ManagementReporter; struct DataRouteState; +class BeaconManager; } class CommonCLI { mesh::ManagementReporter* _management = nullptr; @@ -857,6 +858,13 @@ class CommonCLI { bool _common_save_succeeded = false; uint32_t _prefs_save_failures = 0; mesh::RadioProfileCLI _radio_profiles; +#if MESH_ENABLE_TX_BEACONS + mesh::BeaconManager* _beacons = nullptr; + void beginBeacons(); + void loopBeacons(); + void suspendBeacons(); + bool handleBeaconCommand(const char* command, char* reply, size_t capacity); +#endif mesh::MorseReplyHandoff _morse_reply; #if MESH_ENABLE_FLEET_CONTROL mesh::FleetChannel* _fleet_channel = nullptr; // allocate only when enrolled diff --git a/src/helpers/CommonCLI_Beacons.cpp b/src/helpers/CommonCLI_Beacons.cpp new file mode 100644 index 000000000..88567bb7a --- /dev/null +++ b/src/helpers/CommonCLI_Beacons.cpp @@ -0,0 +1,36 @@ +#include "CommonCLI.h" + +#if MESH_ENABLE_TX_BEACONS +#include "BeaconManager.h" +#include + +void CommonCLI::beginBeacons() { + if (!_management_fs || !_management_mesh || _beacons) return; + if (!_management_fs->exists("/beacons") && !_management_fs->exists("/beacons.bak")) return; + _beacons = new (std::nothrow) mesh::BeaconManager; + if (_beacons) _beacons->begin(_management_fs, _management_mesh, _rtc, _prefs->node_name); +} + +void CommonCLI::loopBeacons() { + if (_beacons) _beacons->loop(millis()); +} + +void CommonCLI::suspendBeacons() { + if (_beacons) _beacons->suspend(); + if (_management_mesh) _management_mesh->cancelBeaconRadioFrame(); +} + +bool CommonCLI::handleBeaconCommand(const char* command, char* reply, size_t capacity) { + if (!mesh::BeaconManager::isCommand(command)) return false; + if (!_management_mesh || !_management_fs) { + snprintf(reply, capacity, "Error: beacon storage unavailable"); + return true; + } + if (!_beacons) { + _beacons = new (std::nothrow) mesh::BeaconManager; + if (_beacons) _beacons->begin(_management_fs, _management_mesh, _rtc, _prefs->node_name); + } + if (!_beacons) { snprintf(reply, capacity, "Error: beacon allocation failed"); return true; } + return _beacons->handle(command, reply, capacity); +} +#endif diff --git a/src/helpers/CommonCLI_Management.cpp b/src/helpers/CommonCLI_Management.cpp index 0ffeb1393..ab93508af 100644 --- a/src/helpers/CommonCLI_Management.cpp +++ b/src/helpers/CommonCLI_Management.cpp @@ -244,6 +244,9 @@ static bool resolveDataScope(DataRouteState& route, TransportKey& scope, void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) { _management_mesh = &mesh; _management_fs = fs; +#if MESH_ENABLE_TX_BEACONS + beginBeacons(); +#endif _management_last_ms = millis(); _management_uptime_ms = _management_last_ms; #if MESH_ENABLE_FLEET_CONTROL if (_callbacks->supportsFleetControl() diff --git a/src/helpers/MeshtextChat.cpp b/src/helpers/MeshtextChat.cpp index 935c95a2f..5e25a2134 100644 --- a/src/helpers/MeshtextChat.cpp +++ b/src/helpers/MeshtextChat.cpp @@ -1,6 +1,6 @@ #include "MeshtextChat.h" -#if defined(MESH_ENABLE_MESHTEXT_CHAT) && MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE #include #include diff --git a/src/helpers/MeshtextConfig.h b/src/helpers/MeshtextConfig.h index f885ff365..cfe189a41 100644 --- a/src/helpers/MeshtextConfig.h +++ b/src/helpers/MeshtextConfig.h @@ -1,5 +1,7 @@ #pragma once +#include "BeaconFeatureConfig.h" + // Resolve the chip in every translation unit, including Dispatcher.cpp which // includes this before Arduino.h. Otherwise different class layouts could be // selected for the same build depending on include order. @@ -24,3 +26,17 @@ #define MESH_ENABLE_MESHTEXT_CHAT 0 #endif #endif + +// Compile the bounded wire codec uniformly for beacon-enabled builds. Some +// variants discover their radio chip only when Arduino includes the variant +// header; using USE_SX1262 here would select inconsistent class layouts and +// omit codec definitions in translation units that do not include Arduino. +// Concrete driver capability still gates Meshtext TX independently. This +// does not enable Companion chat, RX handling, or automatic radio2 management. +#ifndef MESH_ENABLE_MESHTEXT_WIRE + #if MESH_ENABLE_MESHTEXT_CHAT || MESH_ENABLE_TX_BEACONS + #define MESH_ENABLE_MESHTEXT_WIRE 1 + #else + #define MESH_ENABLE_MESHTEXT_WIRE 0 + #endif +#endif diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 687880b1e..1098764cb 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -41,7 +41,8 @@ public: CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsCarrierWave() const override { return true; } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE && (MESH_ENABLE_MESHTEXT_CHAT \ + || (MESH_ENABLE_TX_BEACONS && !defined(SENSECAP_INDICATOR_LORA))) bool supportsMeshtext() const override { return true; } #endif @@ -70,8 +71,11 @@ protected: return restoreSX126xPacketRamp(static_cast(_radio)) == RADIOLIB_ERR_NONE && RadioLibWrapper::restoreCarrierWaveModem(); } -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE bool applyProtocol(mesh::RadioProfileProtocol protocol) override { + // Compiling a shared codec must not change unqualified MC-only modem + // behavior or require its foreign sync-word SPI path. + if (!supportsMeshtext()) return protocol == mesh::RadioProfileProtocol::MeshCore; auto* radio = static_cast(_radio); const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; // Stage shared packet invariants only in a proven owned RX hop; normal @@ -106,6 +110,9 @@ protected: public: bool setCodingRate(uint8_t cr) override { +#if MESH_ENABLE_TX_BEACONS + if (isBeaconRadioOwned()) return false; +#endif if (isCarrierWaveActive()) return false; return ((CustomSX1262 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } @@ -143,6 +150,9 @@ public: } bool setRxPowerSavingRfRxDisabled(bool disabled) override { +#if MESH_ENABLE_TX_BEACONS + if (isBeaconRadioOwned()) return false; +#endif if (isCarrierWaveActive()) return false; if (!supportsRxPowerSavingRfRxDisable()) return false; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 0af37d658..a05916c5d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -168,6 +168,9 @@ bool RadioLibWrapper::claimRadioInterrupt() { } void RadioLibWrapper::begin() { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return; +#endif syncProfileClock(_profiles.enabled()); #ifdef USE_CC310_HW_CRYPTO // Initialize CryptoCell once from normal task context. The session helper @@ -235,6 +238,9 @@ uint32_t RadioLibWrapper::getRngSeed() { } bool RadioLibWrapper::setTxPower(int8_t dbm) { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return false; +#endif // A CW power-meter sweep may re-key the carrier. During Morse that would // turn an inter-symbol gap into RF and corrupt the text, so keep the whole // message exclusive (including cleanup retries). @@ -276,6 +282,9 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) { } uint8_t RadioLibWrapper::beginReconfigure() { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned && !_beacon_restoring) return 2; +#endif if (_cw_active) return 2; const uint8_t base_state = state; // On SX126x/LR11xx duty-cycle RX, BUSY may remain asserted during the sleep @@ -305,6 +314,9 @@ void RadioLibWrapper::endReconfigure(bool resume_rx) { } bool RadioLibWrapper::restoreAfterDeepInit() { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) cacheBeaconRestoreParams(); +#endif if (!radioDeepInit()) return false; _rx_ps_armed = false; @@ -318,7 +330,7 @@ bool RadioLibWrapper::restoreAfterDeepInit() { restored = _radio->setPreambleLength(currentPreambleLength()) == RADIOLIB_ERR_NONE; } -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE restored = restored && applyProtocol(_physical_protocol); #endif if (_dbm_valid) { @@ -346,7 +358,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, const uint8_t previous_sf = _cur_sf; const uint8_t previous_cr = _cur_cr; const uint16_t previous_preamble = _physical_preamble; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE const auto previous_protocol = _physical_protocol; #endif const uint8_t previous_profile = _active_profile; @@ -363,7 +375,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, _physical_preamble = profilePreamble(0); bool success = applyParams(freq, bw, sf, cr); -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE success = success && applyProtocol(requested.protocol); if (success) _physical_protocol = requested.protocol; #endif @@ -397,7 +409,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, _profile_generation = previous_generation; _profile_refresh_required = previous_refresh; _physical_preamble = previous_preamble; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE _physical_protocol = previous_protocol; #endif _rx_ps_enabled = previous_rxps; @@ -408,7 +420,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, bool restored = had_previous_params && _radio->standby() == RADIOLIB_ERR_NONE && applyParams(previous_freq, previous_bw, previous_sf, previous_cr); -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE restored = restored && applyProtocol(previous_protocol); #endif @@ -448,6 +460,9 @@ bool RadioLibWrapper::validateProfile(const mesh::RadioProfileParams& p) const { } mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned && !_beacon_restoring) return mesh::RadioParamApplyResult::BUSY; +#endif if (_cw_active) return mesh::RadioParamApplyResult::BUSY; if (profile > 1 || (profile == 1 && !_profiles.enabled())) return mesh::RadioParamApplyResult::FAILED; const auto& p = _profiles.params(profile); @@ -469,12 +484,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { const bool old_refresh = _profile_refresh_required; const bool old_params_valid = _params_valid; const uint16_t old_preamble = _physical_preamble; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE const auto old_protocol = _physical_protocol; #endif _physical_preamble = preamble; bool applied = applyParams(p.freq, p.bw, p.sf, p.cr); -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE // Changing only the protocol still changes the sync word. Conversely a // failed write must not publish the new generation or cached wire format. if (applied && (_physical_protocol != p.protocol || _profile_refresh_required)) { @@ -504,12 +519,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { _profile_generation = old_generation; _profile_refresh_required = old_refresh; _physical_preamble = old_preamble; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE _physical_protocol = old_protocol; #endif bool restored = old_params_valid && _radio->standby() == RADIOLIB_ERR_NONE && applyParams(old_freq, old_bw, old_sf, old_cr); -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE restored = restored && applyProtocol(old_protocol); #endif if (!restored) restored = restoreAfterDeepInit(); @@ -533,11 +548,142 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { return applied ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; } +#if MESH_ENABLE_TX_BEACONS +uint32_t RadioLibWrapper::getBeaconAirtime(const mesh::RadioProfileParams& p, int length) { + if (length <= 0 || length > MAX_TRANS_UNIT || !validateProfile(p)) return 0; + const uint16_t preamble = p.protocol == mesh::RadioProfileProtocol::Meshtext + ? 16 : (p.preamble ? p.preamble : 32); + if (!mesh::RadioProfiles::safePreamble(p, preamble)) return 0; + const double symbol = mesh::RadioProfiles::symbolUs(p); + const int de = symbol >= 16000 ? 1 : 0; + const double coded = ceil((8.0 * length - 4 * p.sf + (p.sf <= 6 ? 20 : 28) + 16) + / (4 * (p.sf - 2 * de))); + const double payload = 8 + (coded > 0 ? coded * p.cr : 0); + return static_cast(ceil((preamble + (p.sf <= 6 ? 6.25 : 4.25) + payload) + * symbol / 1000.0)); +} + +void RadioLibWrapper::cacheBeaconRestoreParams() { + const uint8_t profile = _beacon_restore_profile == 1 && _profiles.enabled() ? 1 : 0; + const auto& p = _profiles.params(profile); + // These remain NORMAL cached settings even while the physical modem is + // briefly elsewhere. A hard recovery must never resurrect a beacon modem. + cacheParams(p.freq, p.bw, p.sf, p.cr); + _active_profile = profile; + _profile_generation = _profiles.generation[profile]; + _physical_preamble = profilePreamble(profile); +#if MESH_ENABLE_MESHTEXT_WIRE + _physical_protocol = p.protocol; +#endif + _profile_refresh_required = true; +} + +mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit( + const mesh::RadioProfileParams& p, int8_t power) { + using Result = mesh::RadioParamApplyResult; + if (_beacon_owned || _cw_active) return Result::BUSY; + if (!_params_valid || !_dbm_valid || !getBeaconAirtime(p, MAX_TRANS_UNIT)) return Result::FAILED; + if (isCalibratingNoiseFloor()) return Result::BUSY; + + beginProfileRetune(isInRecvMode() && !_rx_ps_armed && !_rx_ps_enabled); + const uint8_t resume = beginReconfigure(); + if (resume > 1) { + endProfileRetune(true); + return Result::BUSY; + } + // Admission consumed no pending receive. From this point every error keeps + // an exclusive lease until the ordinary receiver has been restored. + _beacon_restore_profile = _active_profile; + _beacon_owned = true; + _beacon_restore_at = 0; + _beacon_cad_timeout = cadScanTimeoutMillis(p.sf, p.bw); + _physical_preamble = p.protocol == mesh::RadioProfileProtocol::Meshtext + ? 16 : (p.preamble ? p.preamble : 32); + bool applied = _radio->standby() == RADIOLIB_ERR_NONE + && applyParams(p.freq, p.bw, p.sf, p.cr); +#if MESH_ENABLE_MESHTEXT_WIRE + applied = applied && applyProtocol(p.protocol); + if (applied) _physical_protocol = p.protocol; +#endif + applied = applied && applyCachedTxPower(power) == RADIOLIB_ERR_NONE; + endProfileRetune(applied); + _rx_hold_continuous = false; + state = STATE_IDLE; + // Do not startReceive: this is a TX-only excursion, not a third RX profile. + return applied ? Result::APPLIED : Result::FAILED; +} + +bool RadioLibWrapper::isBeaconChannelBusy() { + if (!_beacon_owned || _beacon_restoring || state == STATE_TX_WAIT || isChipBusy()) return true; + const int16_t result = performChannelScanWithTimeout(_beacon_cad_timeout); + // CAD IRQs are not packets. Stop CAD even on failure before TX/restore, and + // never compare this frequency's RSSI with either normal profile's floor. + const bool stopped = _radio->standby() == RADIOLIB_ERR_NONE; + const bool cleared = _radio->clearIrqFlags(UINT32_MAX) == RADIOLIB_ERR_NONE; + claimRadioInterrupt(); + state = STATE_IDLE; + return result != RADIOLIB_CHANNEL_FREE || !stopped || !cleared; +} + +mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive() { + using Result = mesh::RadioParamApplyResult; + if (!_beacon_owned) return Result::APPLIED; + if (state == STATE_TX_WAIT) return Result::BUSY; + const uint32_t now = millis(); + if (_beacon_restore_at && static_cast(now - _beacon_restore_at) < 0) return Result::BUSY; + _beacon_restoring = true; + cacheBeaconRestoreParams(); + const auto& p = _profiles.params(_active_profile); + bool restored = validateProfile(p) && !isChipBusy() + && _radio->standby() == RADIOLIB_ERR_NONE; + beginProfileRetune(false); + restored = restored && applyParams(p.freq, p.bw, p.sf, p.cr); +#if MESH_ENABLE_MESHTEXT_WIRE + restored = restored && applyProtocol(p.protocol); +#endif + restored = restored && applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE; + endProfileRetune(restored); + if (restored) { + // No RX ran on the beacon tuple, so any remaining event belongs to its + // CAD/TX lifecycle. Retire it before the normal receiver is armed. + restored = _radio->clearIrqFlags(UINT32_MAX) == RADIOLIB_ERR_NONE; + } + if (!restored) { + restored = restoreAfterDeepInit() + && _radio->clearIrqFlags(UINT32_MAX) == RADIOLIB_ERR_NONE; + } + claimRadioInterrupt(); + if (restored) { + state = STATE_IDLE; + startRecv(); + restored = isInRecvMode(); + if (!restored && restoreAfterDeepInit()) { + state = STATE_IDLE; + startRecv(); + restored = isInRecvMode(); + } + } + _beacon_restoring = false; + if (!restored) { + _beacon_restore_at = now + 100; + if (!_beacon_restore_at) _beacon_restore_at = 1; + return Result::BUSY; + } + _profile_refresh_required = false; + _beacon_restore_at = 0; + _beacon_owned = false; + return Result::APPLIED; +} +#endif + mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile) { return prepareTransmitProfile(profile, false); } mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool reply_rx_override) { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return mesh::RadioParamApplyResult::BUSY; +#endif if (!_profiles.canTransmit(profile, reply_rx_override)) return mesh::RadioParamApplyResult::FAILED; if (_profiles.enabled() && (isChipBusy() || isPacketPendingOrReceiving())) { return mesh::RadioParamApplyResult::BUSY; @@ -727,6 +873,9 @@ void RadioLibWrapper::idle() { } bool RadioLibWrapper::setTrackerSleep(bool sleeping) { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return false; +#endif if (_cw_active || (state & 0x0f) == STATE_TX_WAIT) return false; if (sleeping && isPacketPendingOrReceiving()) return false; if (_radio->standby() != RADIOLIB_ERR_NONE) return false; @@ -825,6 +974,9 @@ bool RadioLibWrapper::tryResetAGC() { } bool RadioLibWrapper::resetAGCIfIdle() { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return false; +#endif if (_cw_active) return false; // make sure we're not mid-receiving and mid-sending of packet! if (isPacketPendingOrReceiving() || (state == STATE_TX_WAIT)) return false; @@ -845,6 +997,11 @@ bool RadioLibWrapper::resetAGCIfIdle() { } bool RadioLibWrapper::recoverRadio(bool hard) { +#if MESH_ENABLE_TX_BEACONS + // A failed startSendRaw() may request recovery. Defer that to the owner's + // restore transaction so a reset never reapplies the beacon wire settings. + if (_beacon_owned && !_beacon_restoring) return false; +#endif if (_cw_active) return false; const uint8_t base_state = state; if (hasPendingRadioInterrupt() || base_state == STATE_TX_WAIT) return false; @@ -1475,6 +1632,9 @@ bool RadioLibWrapper::serviceCarrierWave() { } void RadioLibWrapper::loop() { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return; +#endif syncProfileClock(_profiles.enabled()); if (serviceCarrierWave()) return; if (_profiles.enabled()) { @@ -1618,6 +1778,9 @@ void RadioLibWrapper::loop() { } void RadioLibWrapper::startRecv() { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned && !_beacon_restoring) return; +#endif if (_cw_active) return; int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { @@ -1687,6 +1850,9 @@ bool RadioLibWrapper::isInRecvMode() const { // RX PowerSaving bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return false; +#endif if (_cw_active) return false; if (_profile_rxps_suspended) { _profile_saved_rxps = enabled; @@ -1711,6 +1877,9 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl } int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return 0; +#endif if (_cw_active) return 0; int len = 0; #if defined(USE_LR2021) diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index f7186384d..16c9a532b 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -89,7 +89,7 @@ protected: mesh::RadioProfiles _profiles; uint8_t _active_profile = 0; uint16_t _physical_preamble = 0; -#if MESH_ENABLE_MESHTEXT_CHAT +#if MESH_ENABLE_MESHTEXT_WIRE mesh::RadioProfileProtocol _physical_protocol = mesh::RadioProfileProtocol::MeshCore; // Wire settings are a transaction with the modulation tuple. Unsupported // wrappers decline rather than claiming a profile was applied. @@ -104,6 +104,16 @@ protected: bool _profile_rxps_suspended = false; bool _profile_saved_rxps = false; bool _profile_refresh_required = false; +#if MESH_ENABLE_TX_BEACONS + // The normal cache is never overwritten by a transmit-only excursion. + // Retain ownership through failed TX and restoration retries. + bool _beacon_owned = false; + bool _beacon_restoring = false; + uint8_t _beacon_restore_profile = 0; + uint32_t _beacon_restore_at = 0; + uint32_t _beacon_cad_timeout = 0; + void cacheBeaconRestoreParams(); +#endif bool _cw_active = false; bool _cw_stopping = false; bool _cw_board_tx = false; @@ -374,6 +384,14 @@ public: uint32_t receiveProfileGeneration() const override { return _profile_generation; } mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override; mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile, bool reply_rx_override) override; +#if MESH_ENABLE_TX_BEACONS + mesh::RadioParamApplyResult prepareBeaconTransmit(const mesh::RadioProfileParams& params, + int8_t power) override; + mesh::RadioParamApplyResult restoreBeaconReceive() override; + bool isBeaconChannelBusy() override; + uint32_t getBeaconAirtime(const mesh::RadioProfileParams& params, int length) override; + bool isBeaconRadioOwned() const override { return _beacon_owned; } +#endif mesh::RadioParamApplyResult tryRestoreCodingRate(uint8_t cr) override; mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams& params, bool temporary, const uint32_t* timings = nullptr) override; diff --git a/test/fixtures/beacons/manager_test.cpp b/test/fixtures/beacons/manager_test.cpp new file mode 100644 index 000000000..3a264d872 --- /dev/null +++ b/test/fixtures/beacons/manager_test.cpp @@ -0,0 +1,364 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +uint64_t beacon_test_time = 0; +constexpr uint64_t H = mesh::BeaconManager::HourMillis; + +struct Fixture { + MemoryFS fs; + mesh::Mesh mesh; + mesh::RTCClock clock; + mesh::BeaconManager manager; + char reply[256] = {}; + char name[32] = "Beacon test"; + uint64_t now = 0; + explicit Fixture(uint32_t boot = 0) { + g_mock_millis = boot; beacon_test_time = 0; + manager.begin(&fs, &mesh, &clock, name); + } + void tick(uint64_t elapsed) { + now += elapsed; beacon_test_time = now; g_mock_millis += uint32_t(elapsed); + manager.loop(g_mock_millis); + } + const char* cmd(const std::string& command, bool ok = true) { + std::memset(reply, 0x55, sizeof(reply)); + const bool handled = manager.handle(command.c_str(), reply, sizeof(reply)); + assert(handled && std::memchr(reply, 0, sizeof(reply))); + const bool actual_ok = std::strncmp(reply, "Err", 3) != 0; + if (actual_ok != ok) { std::fprintf(stderr, "%s => %s\n", command.c_str(), reply); assert(false); } + return reply; + } + void mc(unsigned slot, const char* text = "Hello") { + cmd("set beacon " + std::to_string(slot) + " meshcore 906.5,62.5,7,5,14 " + text); + } + void all(unsigned hours) { + cmd("set beacon.interval " + std::to_string(hours)); + for (unsigned i = 1; i <= 12; ++i) mc(i, std::to_string(i).c_str()); + } + void mt(unsigned slot = 1) { cmd("set beacon " + std::to_string(slot) + " meshtext US LongFast 14 Hello MT"); } +}; + +uint32_t crc(const uint8_t* bytes, size_t size) { + uint32_t value = 0xffffffffU; + while (size--) { + value ^= *bytes++; + for (unsigned i = 0; i < 8; ++i) value = (value >> 1) ^ ((value & 1) ? 0xedb88320U : 0); + } + return value; +} +void put32(uint8_t* bytes, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) bytes[i] = uint8_t(value >> (8 * i)); +} +void fixCrc(std::vector& bytes) { put32(bytes.data() + bytes.size() - 4, crc(bytes.data(), bytes.size() - 4)); } + +void commands() { + Fixture f; + assert(!f.mesh.wake_enabled && f.fs.files.empty()); + f.tick(48 * H); + assert(f.mesh.sends.empty() && f.fs.files.empty()); + assert(std::strstr(f.cmd("get beacon.interval"), "24")); + assert(!mesh::BeaconManager::isCommand(nullptr)); + for (const char* command : {"set beacons 1", "get beaconry", "set beacon.intervalx 12", "get radio", "send beacon 1"}) + assert(!mesh::BeaconManager::isCommand(command)); + for (const char* command : {"get beacon", "get beacon 1", "set beacon", "set beacon.interval", "delete beacon all", "del beacon 1"}) + assert(mesh::BeaconManager::isCommand(command)); + for (const char* command : { + "set beacon", "set beacon.interval", "get beacon.interval garbage", "set beacon.interval 0", + "set beacon.interval 13", "set beacon.interval 4294967296", "set beacon.interval 12 garbage", + "get beacon 0", "get beacon 13", "get beacon -1", "get beacon 4294967296", "get beacon 1 extra", + "delete beacon 1 extra", "set beacon 1 unknown 1 Hello", + "set beacon 1 meshcore nan,62.5,7,5,14 Hello", "set beacon 1 meshcore inf,62.5,7,5,14 Hello", + "set beacon 1 meshcore 906.5,nan,7,5,14 Hello", "set beacon 1 meshcore 906.5,62.5,256,5,14 Hello", + "set beacon 1 meshcore 906.5,62.5,7,5,128 Hello", "set beacon 1 meshcore 906.5,62.5,7,5,23 Hello", + "set beacon 1 meshcore 906.5,62.5,7,5,14,7 Hello", "set beacon 1 meshcore 906.5,62.5,7,5,14,65536 Hello", + "set beacon 1 meshcore 906.5,62.5,7,5,14,32,1 Hello", "set beacon 1 meshcore 906.5,62.5,7,5,14", + "set beacon 1 meshtext bad LongFast 14 Hello", "set beacon 1 meshtext US bad 14 Hello", + "set beacon 1 meshtext US LongFast 128 Hello"}) f.cmd(command, false); + f.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 " + std::string(80, 'A')); + assert(std::strstr(f.cmd("get beacon 1"), std::string(80, 'A').c_str())); + f.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 " + std::string(81, 'A'), false); + f.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 Hello\nthere", false); + f.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 \xc3\xa9"); + assert(std::strstr(f.cmd("get beacon"), "1")); + f.cmd("delete beacon all"); + assert(!f.mesh.wake_enabled); + char tiny[2] = {'x', 'x'}; + assert(f.manager.handle("get beacon", tiny, sizeof(tiny)) && tiny[1] == 0); + assert(!f.manager.handle("get beacon", tiny, 0)); +} + +void schedule() { + for (unsigned cycle : {12U, 24U, 36U, 48U}) { + for (bool wrap : {false, true}) { + Fixture f(wrap ? UINT32_MAX - uint32_t(H) : 0); + f.all(cycle); + const uint64_t spacing = cycle / 12 * H; + assert(f.mesh.wake_enabled && f.mesh.wake_delay == spacing); + for (unsigned minute = 1; minute <= 48 * 60; ++minute) f.tick(60000); + const unsigned expected = 48 / (cycle / 12); + assert(f.mesh.sends.size() == expected); + for (unsigned i = 0; i < expected; ++i) { + const auto& send = f.mesh.sends[i]; + assert(send.time == (i + 1) * spacing); + assert(std::string(send.slot.text) == std::to_string(i % 12 + 1)); + assert(send.slot.radio.preamble == 32 && send.slot.radio.protocol == mesh::RadioProfileProtocol::MeshCore); + if (i) assert(send.time - f.mesh.sends[i - 1].time >= H); + if (i >= 12) assert(send.time - f.mesh.sends[i - 12].time >= cycle * H); + } + } + } + // Sparse slots retain their hour positions; a long sleep never catches up + // all missed slots in a burst. + Fixture sparse; + sparse.mc(1); sparse.mc(12); + sparse.tick(13 * H); + assert(sparse.mesh.sends.empty()); + assert(sparse.mesh.wake_delay == 11 * H); + sparse.tick(11 * H); + assert(sparse.mesh.sends.size() == 1); + sparse.tick(24 * H); + assert(sparse.mesh.sends.size() == 2); +} + +void rolling() { + Fixture f; + f.all(12); + f.tick(H + 59 * 60000); + assert(f.mesh.sends.size() == 1); + f.tick(60000); + assert(f.mesh.sends.size() == 1 && f.mesh.wake_delay == 59 * 60000); + for (unsigned minute = 1; minute <= 24 * 60; ++minute) f.tick(60000); + for (unsigned i = 1; i < f.mesh.sends.size(); ++i) { + assert(f.mesh.sends[i].time - f.mesh.sends[i - 1].time >= H); + if (i >= 12) assert(f.mesh.sends[i].time - f.mesh.sends[i - 12].time >= 12 * H); + } + // Failed queue admission consumes this window and causes no fast retry. + Fixture failed; + failed.mc(1); failed.mesh.accept = false; + failed.tick(2 * H); + assert(failed.mesh.attempts == 1 && failed.mesh.sends.empty()); + for (unsigned i = 0; i < 100; ++i) failed.tick(1000); + assert(failed.mesh.attempts == 1); + failed.mesh.accept = true; failed.tick(24 * H - 100000); + assert(failed.mesh.attempts == 2 && failed.mesh.sends.size() == 1); + // Interval changes start a new anchor, rather than sending immediately. + failed.cmd("set beacon.interval 12"); + failed.tick(H - 1); assert(failed.mesh.sends.size() == 1); + failed.tick(1); assert(failed.mesh.sends.size() == 1); // per-slot rolling guard + const unsigned cancellations = failed.mesh.cancellations; + failed.manager.suspend(); + assert(!failed.mesh.wake_enabled && failed.mesh.cancellations == cancellations + 1); + failed.tick(100 * H); assert(failed.mesh.sends.size() == 1); + failed.cmd("set beacon 1 meshcore 906.5,62.5,7,5,14 Gone", false); + // An object created by an early get must not transmit when a later set + // enables a slot whose current window is already due, before the CLI ack. + Fixture edit; + edit.cmd("set beacon.interval 12"); edit.tick(H + 10 * 60000); + edit.mc(1); assert(edit.mesh.sends.empty()); + edit.tick(50 * 60000); assert(edit.mesh.sends.empty()); + edit.tick(11 * H); assert(edit.mesh.sends.size() == 1); +} + +void storage() { + Fixture original; + original.mc(1, "Old"); + const auto baseline = original.fs.files; + const size_t size = baseline.at("/beacons").size(); + assert(size == 1188); + for (size_t boundary = 0; boundary < size; ++boundary) { + Fixture f; f.fs.files = baseline; f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + f.fs.fail_write_after = int(boundary); + f.cmd("set beacon 1 meshcore 907.5,125,8,6,10 New", false); + assert(std::strstr(f.cmd("get beacon 1"), "Old")); + f.fs.fail_write_after = -1; + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + assert(std::strstr(f.cmd("get beacon 1"), "Old")); + } + for (int boundary : {0, 7, 8, 105, 1187}) { + Fixture f; f.fs.files = baseline; f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + f.fs.fail_read_after = boundary; + f.cmd("set beacon 1 meshcore 907.5,125,8,6,10 New", false); + assert(std::strstr(f.cmd("get beacon 1"), "Old")); + } + for (int rename : {1, 2}) { + Fixture f; f.fs.files = baseline; f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + f.fs.fail_rename = rename; + f.cmd("set beacon 1 meshcore 907.5,125,8,6,10 New", false); + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + assert(std::strstr(f.cmd("get beacon 1"), "Old")); + } + { + Fixture f; f.fs.files = baseline; + f.fs.files["/beacons.tmp"] = baseline.at("/beacons"); + f.fs.files.erase("/beacons"); + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + assert(!std::strstr(f.cmd("get beacon 1"), "Old")); // temp is not committed + } + for (bool future : {false, true}) { + Fixture f; f.fs.files = baseline; + f.fs.files["/beacons"][future ? 4 : 40] ^= future ? 3 : 1; + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + f.cmd("get beacon", false); f.cmd("get beacon.interval", false); f.cmd("delete beacon all", false); + f.tick(48 * H); assert(f.mesh.sends.empty()); + } + { + Fixture f; f.fs.files = baseline; + f.fs.files["/beacons.bak"] = baseline.at("/beacons"); + f.fs.files["/beacons"][40] ^= 1; + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + assert(std::strstr(f.cmd("get beacon 1"), "Old")); + } + { + Fixture f; f.fs.files = baseline; + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + f.fs.fail_rename_from = {"/beacons.tmp", "/beacons.bak"}; + f.cmd("set beacon 1 meshcore 907.5,125,8,6,10 New", false); + f.cmd("get beacon", false); + f.tick(48 * H); assert(f.mesh.sends.empty()); + } + { + Fixture f; f.fs.files = baseline; f.fs.fail_read_open = true; + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + f.cmd("delete beacon all", false); + assert(f.fs.files.at("/beacons") == baseline.at("/beacons")); + } + // CRC-valid but semantically invalid configuration is held as a unit. + { + Fixture f; f.fs.files = baseline; + auto& bytes = f.fs.files["/beacons"]; + bytes[8 + 10] = 255; fixCrc(bytes); + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + f.cmd("get beacon", false); + } +} + +void ids() { + Fixture f; + f.mt(); + assert(f.fs.files.count("/beacon_ids")); + f.tick(2 * H); + assert(f.mesh.sends.size() == 1 && f.mesh.sends[0].id == 0x1234567a); + assert(f.mesh.sends[0].from == 0x88776655); + assert(f.mesh.sends[0].slot.radio.preamble == 16 && f.mesh.sends[0].slot.hops == 0); + const auto baseline = f.fs.files; + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); // reboots use the durable counter + f.tick(2 * H); + assert(f.mesh.sends.size() == 2 && f.mesh.sends.back().id == 0x1234567b); + f.cmd("delete beacon all"); f.mt(); + f.tick(24 * H); + assert(f.mesh.sends.back().id == 0x1234567c); + for (unsigned fault = 0; fault < 4; ++fault) { + Fixture bad; bad.fs.files = baseline; + if (fault == 0) bad.fs.files.erase("/beacon_ids"); + if (fault == 1) bad.fs.files["/beacon_ids"][8] ^= 1; + if (fault == 2) bad.fs.files["/beacon_ids"][4] = 2; + if (fault == 3) { + auto& bytes = bad.fs.files["/beacon_ids"]; + put32(bytes.data() + 8, UINT32_MAX); fixCrc(bytes); + } + bad.manager.begin(&bad.fs, &bad.mesh, &bad.clock, bad.name); + bad.tick(2 * H); assert(bad.mesh.sends.empty()); + bad.cmd("delete beacon all"); + bad.cmd("set beacon 1 meshtext US LongFast 14 Hello MT", false); + // A broken MT counter must not disable independent MeshCore slots. + bad.mc(2); bad.tick(2 * H); assert(bad.mesh.sends.size() == 1); + } + // Every counter short-write boundary fails closed for this boot. Previous + // durable state is unchanged, so the next boot cannot repeat an on-air ID. + for (int boundary = 0; boundary < 16; ++boundary) { + Fixture bad; bad.fs.files = baseline; bad.manager.begin(&bad.fs, &bad.mesh, &bad.clock, bad.name); + bad.fs.fail_write_after = boundary; + bad.tick(2 * H); assert(bad.mesh.sends.empty()); + bad.fs.fail_write_after = -1; + bad.tick(24 * H); assert(bad.mesh.sends.empty()); + bad.manager.begin(&bad.fs, &bad.mesh, &bad.clock, bad.name); + bad.tick(2 * H); assert(bad.mesh.sends.size() == 1 && bad.mesh.sends[0].id == 0x1234567b); + } + { + Fixture rejected; rejected.mt(); rejected.mesh.accept = false; + rejected.tick(2 * H); assert(rejected.mesh.attempts == 1); + rejected.mesh.accept = true; rejected.tick(24 * H); + assert(rejected.mesh.sends.size() == 1 && rejected.mesh.sends[0].id == 0x1234567b); + } +} + +void mutations() { + for (const char* command : { + "set beacon 1 meshcore 907.5,125,8,6,10 New", + "set beacon 2 meshcore 907.5,125,8,6,10 Other", + "delete beacon 1", "delete beacon all", "set beacon.interval 12"}) { + for (bool rename_failure : {false, true}) { + Fixture f; + f.mc(1, "Old"); f.tick(2 * H); + assert(f.mesh.pending && f.mesh.sends.size() == 1); + const unsigned cancellations = f.mesh.cancellations; + f.cmd("get beacon 1"); + assert(f.mesh.pending && f.mesh.cancellations == cancellations); + if (rename_failure) f.fs.fail_rename = 2; + else f.fs.fail_write = true; + f.cmd(command, false); + assert(f.mesh.pending && f.mesh.cancellations == cancellations); + f.tick(1000); + assert(f.mesh.pending && f.mesh.sends.size() == 1); + f.fs.fail_write = false; + f.cmd(command); + assert(!f.mesh.pending && f.mesh.cancellations == cancellations + 1); + f.tick(10000); + assert(!f.mesh.pending && f.mesh.sends.size() == 1); + } + } +} + +void staleBackup() { + Fixture f; + f.mt(); + const auto stale = f.fs.files.at("/beacon_ids"); + f.tick(2 * H); + assert(f.mesh.sends.size() == 1 && f.mesh.sends[0].id == 0x1234567a); + assert(stale != f.fs.files.at("/beacon_ids")); + // A previous backup-remove failure can leave this lower ID behind. Losing + // only the active directory entry must not make the on-air ID reusable. + f.fs.files["/beacon_ids.bak"] = stale; + f.fs.files.erase("/beacon_ids"); + f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name); + f.tick(2 * H); + assert(f.mesh.sends.size() == 1); + assert(!f.fs.files.count("/beacon_ids") && f.fs.files.at("/beacon_ids.bak") == stale); + f.cmd("delete beacon all"); + f.cmd("set beacon 1 meshtext US LongFast 14 Hello MT", false); + f.mc(2); f.tick(2 * H); + assert(f.mesh.sends.size() == 2 && f.mesh.sends.back().slot.radio.protocol == mesh::RadioProfileProtocol::MeshCore); + + // Absence of the config marker is not evidence that an independent backup + // has never been used. A fresh MT setter must remain fail closed too. + Fixture uninitialized; + uninitialized.mc(2); + assert(uninitialized.fs.files.at("/beacons")[6] == 0); + uninitialized.fs.files["/beacon_ids.bak"] = stale; + uninitialized.manager.begin(&uninitialized.fs, &uninitialized.mesh, &uninitialized.clock, uninitialized.name); + uninitialized.cmd("set beacon 1 meshtext US LongFast 14 Hello MT", false); + uninitialized.tick(4 * H); + assert(uninitialized.mesh.sends.size() == 1 + && uninitialized.mesh.sends[0].slot.radio.protocol == mesh::RadioProfileProtocol::MeshCore); + assert(!uninitialized.fs.files.count("/beacon_ids") + && uninitialized.fs.files.at("/beacon_ids.bak") == stale); +} + +int main(int argc, char** argv) { + assert(argc == 2); + if (!std::strcmp(argv[1], "commands")) commands(); + else if (!std::strcmp(argv[1], "schedule")) schedule(); + else if (!std::strcmp(argv[1], "rolling")) rolling(); + else if (!std::strcmp(argv[1], "storage")) storage(); + else if (!std::strcmp(argv[1], "ids")) ids(); + else if (!std::strcmp(argv[1], "mutations")) mutations(); + else if (!std::strcmp(argv[1], "stale_backup")) staleBackup(); + else assert(false); + std::printf("beacon manager %s passed\n", argv[1]); +} diff --git a/test/fixtures/beacons/mocks/AES.h b/test/fixtures/beacons/mocks/AES.h new file mode 100644 index 000000000..0a6852edb --- /dev/null +++ b/test/fixtures/beacons/mocks/AES.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include +#include + +// BeaconManager uses only the production preset parser/modem calculator. +// Encryption has its independent real-AES oracle in test_meshtext_chat.py. +class BeaconUnusedAES { + public: + bool setKey(const uint8_t*, size_t) { return false; } + void encryptBlock(uint8_t* output, const uint8_t* input) { std::memcpy(output, input, 16); } + void clear() {} +}; +using AES128 = BeaconUnusedAES; +using AES256 = BeaconUnusedAES; diff --git a/test/fixtures/beacons/mocks/Mesh.h b/test/fixtures/beacons/mocks/Mesh.h new file mode 100644 index 000000000..f037f3f1a --- /dev/null +++ b/test/fixtures/beacons/mocks/Mesh.h @@ -0,0 +1,49 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +extern uint64_t beacon_test_time; + +namespace mesh { +class RTCClock {}; +class RNG { + public: + void random(uint8_t* output, size_t size) { + const uint32_t value = 0x12345678; + std::memcpy(output, &value, size); + } +}; +class Mesh { + public: + struct Send { uint64_t time; BeaconSlot slot; uint32_t from, id; }; + std::vector sends; + bool accept = true, wake_enabled = false, wire_supported = true, pending = false; + uint32_t wake_delay = 0, wake_now = 0, attempts = 0, cancellations = 0; + bool validateTextBeacon(const BeaconSlot& slot) const { + const auto& p = slot.radio; + return slot.enabled && RadioProfiles::valid(p) && p.freq >= 300 && p.freq <= 1000 + && (p.bw == 31.25f || p.bw == 62.5f || p.bw == 125 || p.bw == 250 || p.bw == 500) + && slot.power >= -9 && slot.power <= 22 && slot.text[0] + && (p.protocol != RadioProfileProtocol::Meshtext || wire_supported); + } + bool queueTextBeacon(const BeaconSlot& slot, const char*, uint32_t from, uint32_t id) { + ++attempts; + if (!accept) return false; + pending = true; + sends.push_back({beacon_test_time, slot, from, id}); + return true; + } + void setBeaconWakeDelay(uint32_t now, uint32_t delay, bool enabled) { + wake_now = now; wake_delay = delay; wake_enabled = enabled; + } + uint32_t beaconNodeId() const { return 0x88776655; } + RNG* getRNG() { return &rng_; } + void cancelBeaconRadioFrame() { ++cancellations; pending = false; } + private: + RNG rng_; +}; +} // namespace mesh diff --git a/test/test_beacon_dispatcher.py b/test/test_beacon_dispatcher.py new file mode 100644 index 000000000..277228d2b --- /dev/null +++ b/test/test_beacon_dispatcher.py @@ -0,0 +1,446 @@ +"""Execute the production TX-only beacon owner and Dispatcher lifecycle. + +The test uses the actual Dispatcher, packet serializer, and static pool. Radio +and clock boundaries are counted adapters. No PlatformIO or hardware is used. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +using namespace mesh; +class Clock : public MillisecondClock { +public: + uint32_t now; + explicit Clock(uint32_t initial = 1000) : now(initial) {} + unsigned long getMillis() override { return now; } +}; +class TestRadio : public Radio { +public: + RadioProfiles p; + RadioProfileParams physical, saved; + int8_t power = 22, saved_power = 22; + uint8_t active = 0; + bool owned = false, receiving = false, complete = false, sending = false; + bool start_ok = true, carrier = false, morse_tx = false, morse_rx = false; + bool calibrating = false, incoming = false, channel_busy = false; + bool valid = true, busy_owns = false, fail_owns = false; + unsigned sends = 0, beacon_sends = 0, normal_sends = 0, finishes = 0, recovery = 0; + unsigned processed = 0, prepare_calls = 0, restore_calls = 0, loops = 0; + unsigned agc = 0, noise = 0, begins = 0, begin_while_owned = 0; + uint32_t airtime = 100; + Clock* clock = nullptr; + uint32_t cad_delay = 0; + RadioParamApplyResult prepare = RadioParamApplyResult::APPLIED; + RadioParamApplyResult restore = RadioParamApplyResult::APPLIED; + std::vector sent; + std::vector beacon_starts; + TestRadio() { + p.primary.freq = 909.5f; p.primary.bw = 125; p.primary.sf = 7; p.primary.cr = 5; + p.primary.preamble = 32; + p.secondary.params = p.primary; p.secondary.params.freq = 910; + physical = saved = p.primary; + } + RadioProfiles* profiles() override { return &p; } + const RadioProfiles* profiles() const override { return &p; } + bool supportsMeshtext() const override { return true; } + bool validateProfile(const RadioProfileParams& params) const override { + return valid && RadioProfiles::valid(params); + } + uint8_t receiveProfile() const override { return active; } + int recvRaw(uint8_t* bytes, int) override { + assert(!owned); + if (!incoming) return 0; + incoming = false; bytes[0] = 0x15; bytes[1] = 0; bytes[2] = 0x23; return 3; + } + void onReceiveProcessed() override { ++processed; } + uint32_t getEstAirtimeFor(int) override { return 100; } + uint32_t getProfileAirtime(uint8_t, int, uint8_t = 0) override { return 100; } + uint32_t getBeaconAirtime(const RadioProfileParams&, int) override { return airtime; } + float packetScore(float, int) override { return 1; } + RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override { + assert(!owned); active = profile; physical = p.params(profile); return RadioParamApplyResult::APPLIED; + } + RadioParamApplyResult prepareBeaconTransmit(const RadioProfileParams& params, int8_t requested_power) override { + ++prepare_calls; + if (prepare == RadioParamApplyResult::APPLIED || busy_owns || fail_owns) { + if (!owned) { saved = physical; saved_power = power; } + owned = true; physical = params; power = requested_power; + } + return prepare; + } + RadioParamApplyResult restoreBeaconReceive() override { + ++restore_calls; + if (restore == RadioParamApplyResult::APPLIED) { + if (owned) { physical = saved; power = saved_power; } + owned = false; + } + return restore; + } + bool isBeaconRadioOwned() const override { return owned; } + bool isBeaconChannelBusy() override { + assert(owned); + if (clock) clock->now += cad_delay; + return channel_busy; + } + bool startSendRaw(const uint8_t* bytes, int length) override { + ++sends; + if (owned) { + ++beacon_sends; + if (clock) beacon_starts.push_back(clock->now); + } else ++normal_sends; + sent.assign(bytes, bytes + length); sending = start_ok; complete = false; return start_ok; + } + bool isSendComplete() override { return complete; } + void onSendFinished() override { ++finishes; sending = false; } + bool isInRecvMode() const override { return !sending; } + bool isReceiving() override { return receiving; } + bool isCarrierWaveActive() const override { return carrier; } + bool isMorseTxActive() const override { return morse_tx; } + bool isMorseRxActive() const override { return morse_rx; } + bool isCalibratingNoiseFloor() const override { return calibrating; } + bool recoverRadio(bool) override { ++recovery; return true; } + uint32_t getRecoveryCount() const override { return recovery; } + void loop() override { assert(!owned); ++loops; } + void begin() override { ++begins; if (owned) ++begin_while_owned; } + void resetAGC() override { assert(!owned); ++agc; } + bool tryResetAGC() override { resetAGC(); return true; } + void triggerNoiseFloorCalibrate(int) override { assert(!owned); ++noise; } +}; +class TestDispatcher : public Dispatcher { +public: + unsigned received = 0, mc_sent = 0, failed = 0; + unsigned long duty_window = 3600000; + int interval = 0; + bool adaptive = false; + TestDispatcher(TestRadio& radio, Clock& clock, StaticPoolPacketManager& manager) + : Dispatcher(radio, clock, manager) {} + using Dispatcher::setRadioAvailable; + using Dispatcher::getNextQueueWakeDelay; + using Dispatcher::queueForeignRadioFrame; + DispatcherAction onRecvPacket(Packet*) override { ++received; return ACTION_RELEASE; } + void onSendComplete(Packet*) override { ++mc_sent; } + void onSendFail(Packet*) override { ++failed; } + unsigned long getDutyCycleWindowMs() const override { return duty_window; } + int getAGCResetInterval() const override { return interval; } + bool useAdaptiveAGC() const override { return adaptive; } +}; +struct Fixture { + Clock clock; + TestRadio radio; + StaticPoolPacketManager manager{8}; + TestDispatcher d{radio, clock, manager}; + explicit Fixture(uint32_t initial = 1000) : clock(initial) { radio.clock = &clock; d.begin(); } + void tick(uint32_t delta = 1) { clock.now += delta; d.loop(); } +}; +static const uint8_t bytes[] = {0x15, 0, 0x23}; +static RadioProfileParams beacon_params() { + RadioProfileParams p; + p.freq = 906.875f; p.bw = 250; p.sf = 11; p.cr = 5; p.preamble = 16; + p.protocol = RadioProfileProtocol::Meshtext; + return p; +} +static bool queue(Fixture& f) { + return f.d.queueBeaconRadioFrame(bytes, sizeof(bytes), beacon_params(), 17); +} +static Packet* normal(Fixture& f, uint32_t delay = 0) { + Packet* packet = f.d.obtainNewPacket(); assert(packet); + packet->header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); + packet->tx_radio = RADIO_TX_PRIMARY; packet->payload[0] = 0x12; packet->payload_len = 1; + assert(f.d.sendPacket(packet, 0, delay)); + return packet; +} +static void basic() { + Fixture f; + f.radio.p.secondary.mode = RadioProfileMode::RxTx; + f.radio.p.secondary_temporary = true; + const auto saved = f.radio.p; + assert(queue(f)); assert(f.d.hasOutbound() && !queue(f)); + uint32_t delay = 99; + assert(f.d.getNextQueueWakeDelay(delay) && delay == 0); + f.tick(); + assert(f.radio.beacon_sends == 1 && f.radio.owned && f.radio.physical == beacon_params()); + assert(f.radio.sent == std::vector(bytes, bytes + sizeof(bytes))); + assert(f.radio.power == 17 && !f.d.queueForeignRadioFrame(bytes, sizeof(bytes), 1, 1)); + f.tick(20); assert(f.d.hasOutbound() && f.radio.finishes == 0); + f.radio.complete = true; f.tick(); + assert(!f.d.hasOutbound() && !f.radio.owned && f.radio.finishes == 1); + assert(f.radio.physical == saved.primary && f.radio.power == 22); + assert(f.radio.p.primary == saved.primary && f.radio.p.secondary.params == saved.secondary.params); + assert(f.radio.p.secondary.mode == saved.secondary.mode && f.radio.p.secondary_temporary); + assert(f.radio.p.generation[0] == saved.generation[0] && f.radio.p.generation[1] == saved.generation[1]); + assert(!f.d.getNumSentFlood() && !f.d.getNumSentDirect() && !f.d.mc_sent); + assert(f.d.getTotalAirTime() == 21); +} +static void preparation() { + { Fixture f; f.radio.prepare = RadioParamApplyResult::BUSY; + assert(queue(f)); f.tick(); assert(f.radio.prepare_calls == 1 && !f.radio.owned && !f.radio.sends); + f.tick(10); assert(f.radio.prepare_calls == 1); + f.tick(1); assert(f.radio.prepare_calls == 2); + f.tick(29987); assert(f.d.hasBeaconRadioFrame()); + f.tick(1); assert(!f.d.hasBeaconRadioFrame() && !f.radio.sends && !f.radio.owned); + assert(queue(f)); // No RF attempt means the one-hour guard was not consumed. + f.d.cancelBeaconRadioFrame(); } + { Fixture f; f.radio.prepare = RadioParamApplyResult::BUSY; f.radio.busy_owns = true; + f.radio.restore = RadioParamApplyResult::BUSY; assert(queue(f)); f.tick(); + assert(f.d.hasBeaconRadioFrame() && f.radio.owned && f.radio.restore_calls == 1); + const unsigned loops = f.radio.loops; f.tick(500); + assert(f.radio.loops == loops && !f.radio.sends); + f.radio.restore = RadioParamApplyResult::APPLIED; f.tick(); + assert(!f.d.hasBeaconRadioFrame() && !f.radio.owned); } + { Fixture f; f.radio.prepare = RadioParamApplyResult::FAILED; f.radio.fail_owns = true; + assert(queue(f)); f.tick(); assert(!f.radio.owned && !f.d.hasBeaconRadioFrame() && !f.radio.sends); } +} +static void failures() { + { Fixture f; f.radio.channel_busy = true; assert(queue(f)); f.tick(); + assert(!f.radio.sends && !f.d.hasBeaconRadioFrame() && !f.radio.owned); + f.radio.channel_busy = false; assert(queue(f)); f.d.cancelBeaconRadioFrame(); } + { Fixture f; f.radio.start_ok = false; assert(queue(f)); f.tick(); + assert(f.radio.sends == 1 && !f.d.hasBeaconRadioFrame() && !f.radio.owned); + assert(queue(f)); f.tick(); assert(f.radio.sends == 1); + f.tick(30000); assert(!f.d.hasBeaconRadioFrame() && f.radio.sends == 1); + f.tick(3600000); assert(queue(f)); f.d.cancelBeaconRadioFrame(); } + { Fixture f; f.radio.airtime = 0; assert(queue(f)); f.tick(); + assert(!f.d.hasBeaconRadioFrame() && !f.radio.sends); } + { Fixture f; f.radio.airtime = UINT32_MAX; assert(queue(f)); f.tick(); + assert(!f.d.hasBeaconRadioFrame() && !f.radio.sends); } + { Fixture f; assert(queue(f)); f.tick(); f.tick(1150); + assert(!f.d.hasBeaconRadioFrame() && !f.radio.owned && f.radio.finishes == 1 && f.radio.recovery == 1); + assert(f.d.getErrFlags() & ERR_EVENT_RADIO_WATCHDOG); + f.tick(10000); assert(f.radio.sends == 1); } +} +static void restoration() { + Fixture f; f.d.interval = 8; f.d.adaptive = true; + assert(queue(f)); f.tick(); + const unsigned loops = f.radio.loops, agc = f.radio.agc, noise = f.radio.noise; + f.radio.restore = RadioParamApplyResult::BUSY; + f.radio.complete = true; f.radio.incoming = true; + normal(f); f.tick(20); + assert(f.radio.owned && f.d.hasBeaconRadioFrame() && f.radio.finishes == 1); + f.tick(30000); + assert(f.radio.loops == loops && f.radio.agc == agc && f.radio.noise == noise); + assert(f.radio.normal_sends == 0 && f.radio.incoming && f.manager.getOutboundTotal() == 1); + f.radio.restore = RadioParamApplyResult::FAILED; f.tick(); + assert(f.radio.owned && f.radio.loops == loops && !f.radio.normal_sends); + f.radio.restore = RadioParamApplyResult::APPLIED; f.tick(); + assert(!f.radio.owned && !f.d.hasBeaconRadioFrame() && !f.radio.incoming); + assert(f.radio.normal_sends == 1 && f.radio.agc > agc); + f.radio.complete = true; f.tick(); +} +static void availability() { + { Fixture f; assert(queue(f)); f.d.setRadioAvailable(false); + assert(!f.d.hasBeaconRadioFrame() && !queue(f)); + const unsigned restores = f.radio.restore_calls, loops = f.radio.loops; + f.tick(10000); assert(f.radio.restore_calls == restores && f.radio.loops == loops); + f.d.setRadioAvailable(true); assert(queue(f)); f.d.cancelBeaconRadioFrame(); } + { Fixture f; assert(queue(f)); f.tick(); f.radio.restore = RadioParamApplyResult::BUSY; + f.d.setRadioAvailable(false); assert(f.radio.owned && f.d.hasBeaconRadioFrame()); + const unsigned restores = f.radio.restore_calls, finishes = f.radio.finishes, loops = f.radio.loops; + f.tick(10000); + assert(f.radio.restore_calls == restores && f.radio.finishes == finishes && f.radio.loops == loops); + f.d.setRadioAvailable(true); + assert(!f.radio.begin_while_owned); // Never initialize through an outstanding beacon lease. + f.radio.restore = RadioParamApplyResult::APPLIED; f.tick(); + assert(!f.radio.owned && !f.d.hasBeaconRadioFrame()); } +} +static void priority() { + { Fixture f; assert(queue(f)); normal(f); f.tick(); + assert(f.radio.normal_sends == 1 && !f.radio.beacon_sends && !f.radio.owned); + assert(f.d.hasBeaconRadioFrame()); f.radio.complete = true; f.tick(); f.tick(101); + assert(f.radio.beacon_sends == 1 && f.radio.owned); + f.radio.complete = true; f.tick(); } + { Fixture f; normal(f, 5000); assert(queue(f)); f.tick(); + assert(f.radio.beacon_sends == 1 && !f.radio.normal_sends); + f.radio.complete = true; f.tick(); f.tick(5000); + assert(f.radio.normal_sends == 1); f.radio.complete = true; f.tick(); } + { Fixture f; normal(f); f.tick(); assert(!queue(f)); f.radio.complete = true; f.tick(); } + { Fixture f; f.radio.p.secondary.params.protocol = RadioProfileProtocol::Meshtext; + f.radio.p.secondary.mode = RadioProfileMode::RxTx; + assert(f.d.queueForeignRadioFrame(bytes, sizeof(bytes), 1, f.radio.p.generation[1])); + assert(!queue(f)); f.tick(); f.radio.complete = true; f.tick(); } +} +static void budget_and_hour() { + { Fixture f; f.d.duty_window = 200; f.d.begin(); + assert(queue(f)); f.tick(); f.radio.complete = true; f.tick(100); + assert(f.d.getRemainingTxBudget() == 0 && f.d.getTotalAirTime() == 100); + assert(queue(f)); f.tick(); assert(f.radio.beacon_sends == 1); + f.d.cancelBeaconRadioFrame(); + f.clock.now = 3601000; assert(queue(f)); // Admission just before the actual RF deadline is valid. + f.d.loop(); assert(f.radio.beacon_sends == 1); + f.clock.now = 3601001; f.d.loop(); assert(f.radio.beacon_sends == 1); + f.clock.now = 3601101; f.d.loop(); assert(f.radio.beacon_sends == 2); + f.radio.complete = true; f.tick(100); } + { Fixture f; f.d.duty_window = 20; f.d.begin(); assert(queue(f)); f.tick(); + assert(!f.radio.sends && f.d.hasBeaconRadioFrame()); + f.tick(30000); assert(!f.d.hasBeaconRadioFrame() && !f.radio.sends); } + { Fixture f(UINT32_MAX - 500); assert(queue(f)); f.tick(); + f.radio.complete = true; f.tick(50); assert(queue(f)); + f.tick(); assert(f.radio.beacon_sends == 1); f.d.cancelBeaconRadioFrame(); + f.clock.now = uint32_t(UINT32_MAX - 499 + uint64_t(3600000)); + assert(queue(f)); f.tick(); assert(f.radio.beacon_sends == 2); + f.radio.complete = true; f.tick(); } +} +static void hourly_cad_delay() { + Fixture f; + f.radio.cad_delay = 30; + const uint32_t first_admission = f.clock.now; + assert(queue(f)); f.tick(); + assert(f.radio.beacon_sends == 1 && f.radio.beacon_starts[0] == first_admission + 31); + f.radio.complete = true; f.tick(); + // The manager staggers admission, not the end of synchronous CAD. A new + // hourly slot must wait for the RF guard instead of being dropped here. + f.clock.now = first_admission + 3600000; + assert(queue(f)); + f.tick(); assert(f.radio.beacon_sends == 1 && !f.radio.owned); + f.tick(30); assert(f.radio.beacon_sends == 1 && !f.radio.owned); + f.tick(71); assert(f.radio.beacon_sends == 2 && f.radio.owned); + assert(uint32_t(f.radio.beacon_starts[1] - f.radio.beacon_starts[0]) >= 3600000); + f.radio.complete = true; f.tick(); +} +static void sleep_deadlines() { + Fixture f; f.d.interval = 8000; f.d.adaptive = true; f.tick(); + uint32_t delay = 0; + f.d.setBeaconWakeDelay(f.clock.now, 3600000, true); + assert(f.d.getNextQueueWakeDelay(delay) && delay == 8000); + f.d.setBeaconWakeDelay(f.clock.now, 1000, true); + assert(f.d.getNextQueueWakeDelay(delay) && delay == 1000); + f.d.setBeaconWakeDelay(f.clock.now, 1000, false); + assert(f.d.getNextQueueWakeDelay(delay) && delay == 8000); + assert(queue(f)); assert(f.d.getNextQueueWakeDelay(delay) && delay == 0); + f.d.cancelBeaconRadioFrame(); + f.d.setBeaconWakeDelay(f.clock.now, 0, true); + assert(f.d.getNextQueueWakeDelay(delay) && delay == 0); + f.d.setRadioAvailable(false); + assert(!f.d.getNextQueueWakeDelay(delay)); +} +static void begin_lifecycle() { + Fixture f; assert(queue(f)); f.tick(); + assert(f.radio.owned); + f.d.begin(); + assert(!f.radio.begin_while_owned && !f.radio.owned && !f.d.hasBeaconRadioFrame()); + assert(f.radio.finishes == 1 && f.radio.restore_calls == 1); +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "basic")) basic(); + else if (!strcmp(argv[1], "preparation")) preparation(); + else if (!strcmp(argv[1], "failures")) failures(); + else if (!strcmp(argv[1], "restoration")) restoration(); + else if (!strcmp(argv[1], "availability")) availability(); + else if (!strcmp(argv[1], "priority")) priority(); + else if (!strcmp(argv[1], "budget")) budget_and_hour(); + else if (!strcmp(argv[1], "hourly")) hourly_cad_delay(); + else if (!strcmp(argv[1], "sleep")) sleep_deadlines(); + else if (!strcmp(argv[1], "begin")) begin_lifecycle(); + else assert(false); + puts("PASS"); +} +''' + + +class BeaconDispatcherTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise unittest.SkipTest("a host C++ compiler is required") + cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-beacon-dispatcher-") + cls.addClassCleanup(cls.temp.cleanup) + work = Path(cls.temp.name) + cpp = work / "dispatcher.cpp" + cpp.write_text(HARNESS, encoding="ascii") + cls.binary = work / ("dispatcher.exe" if sys.platform == "win32" else "dispatcher") + flags = ["-std=c++17", "-Wall", "-Wextra", "-Wno-unused-parameter", + "-DMESH_ENABLE_TX_BEACONS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=1"] + if sys.platform.startswith("linux"): + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + cls.compiler, cls.flags, cls.cpp, cls.work = compiler, flags, cpp, work + result = cls.compile_variant(ROOT / "src/DispatcherBeacon.cpp", cls.binary) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + + @classmethod + def compile_variant(cls, beacon_source, binary): + return subprocess.run( + [cls.compiler, *cls.flags, "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"), + str(ROOT / "src/Dispatcher.cpp"), str(beacon_source), + str(ROOT / "src/Packet.cpp"), str(ROOT / "src/helpers/StaticPoolPacketManager.cpp"), + str(cls.cpp), "-o", str(binary)], capture_output=True, text=True, timeout=90) + + def run_case(self, name): + env = os.environ.copy() + # The legacy static pool intentionally lives for the entire firmware + # lifetime and has no teardown. Keep ASan/UBSan fatal, exclude only LSAN. + env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0" + result = subprocess.run( + [str(self.binary), name], capture_output=True, text=True, + env=env, cwd=self.work, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS", result.stdout) + + def test_one_tx_uses_private_tuple_preserves_radio2_and_accounts_airtime(self): + self.run_case("basic") + + def test_prepare_busy_is_bounded_and_owned_failures_restore_exclusively(self): + self.run_case("preparation") + + def test_cad_start_failure_invalid_airtime_and_tx_timeout_do_not_retry(self): + self.run_case("failures") + + def test_restore_busy_blocks_normal_tx_rx_agc_and_noise_until_repaired(self): + self.run_case("restoration") + + def test_suspension_and_reactivation_do_not_initialize_through_a_beacon_lease(self): + self.run_case("availability") + + def test_due_normal_traffic_wins_and_meshtext_ownership_is_exclusive(self): + self.run_case("priority") + + def test_budget_hour_guard_and_millisecond_rollover(self): + self.run_case("budget") + + def test_hourly_admission_waits_for_actual_rf_guard_after_cad_delay(self): + self.run_case("hourly") + + def test_old_hour_guard_at_admission_is_rejected_by_regression(self): + source = (ROOT / "src/DispatcherBeacon.cpp").read_text(encoding="utf-8") + marker = " const uint32_t now = _ms->getMillis();\n" + old_guard = " if (beacon_has_started && uint32_t(now - beacon_last_start) < 3600000UL) return false;\n" + self.assertIn(marker, source) + broken = self.work / "old-admission.cpp" + broken.write_text(source.replace(marker, marker + old_guard, 1), encoding="ascii") + binary = self.work / ("old-admission.exe" if sys.platform == "win32" else "old-admission") + compile_result = self.compile_variant(broken, binary) + self.assertEqual(compile_result.returncode, 0, compile_result.stdout + compile_result.stderr) + env = os.environ.copy() + env["ASAN_OPTIONS"] = env.get("ASAN_OPTIONS", "") + ":detect_leaks=0" + result = subprocess.run([str(binary), "hourly"], capture_output=True, text=True, + env=env, cwd=self.work, timeout=30) + self.assertNotEqual(result.returncode, 0, "old admission guard unexpectedly passed") + self.assertIn("queue(f)", result.stderr) + + def test_beacon_sleep_deadline_is_merged_with_adaptive_agc(self): + self.run_case("sleep") + + def test_begin_retires_an_in_flight_beacon_before_radio_initialization(self): + self.run_case("begin") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_beacon_frames.py b/test/test_beacon_frames.py new file mode 100644 index 000000000..0e4221bab --- /dev/null +++ b/test/test_beacon_frames.py @@ -0,0 +1,370 @@ +"""Test TX-only beacon frames with real AES/SHA and independent wire oracles. + +Only the radio queue, clock and packet-pool boundary are substituted. The +production group-datagram builder, packet serializer and Mesh adapter run as +written; no PlatformIO, hardware or downloaded dependency is needed. +""" + +import hashlib +import hmac +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile +import unittest + +from cpp_source import body +from test_meshtext_chat import DEFAULT_KEY, data_text, reference_frame + + +ROOT = Path(__file__).resolve().parents[1] +PUBLIC_KEY = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72") + + +ADAPTER = r''' +#pragma once +#include +#include +#include +#include +#include +namespace mesh { +struct GroupChannel { + uint8_t hash[PATH_HASH_SIZE] = {}; + uint8_t secret[PUB_KEY_SIZE] = {}; + uint8_t tx_radio = RADIO_TX_AUTO; +}; +class TestRadio { +public: + bool valid = true, meshtext = true; + RadioProfiles profiles; + bool validateProfile(const RadioProfileParams& p) const { return valid && RadioProfiles::valid(p); } + bool supportsMeshtext() const { return meshtext; } +}; +class TestClock { +public: + uint32_t now = 0x12345678; + uint32_t getCurrentTimeUnique() { return now++; } +}; +class TestTables { +public: + unsigned sent = 0; + std::vector marked; + void markSent(const Packet* packet) { + ++sent; + uint8_t raw[MAX_TRANS_UNIT]; + const size_t length = packet->writeTo(raw); + marked.assign(raw, raw + length); + } +}; +class Mesh { +public: + TestRadio radio; + TestRadio* _radio = &radio; + TestClock clock; + TestTables tables; + struct { uint8_t pub_key[32] = {}; } self_id; + bool temporary = false, accept = true, pool_available = true; + unsigned obtained = 0, released = 0, queues = 0; + std::vector queued; + RadioProfileParams queued_params; + int8_t queued_power = 0; + Packet pool_packet; + bool validateTextBeacon(const BeaconSlot&) const; + bool queueTextBeacon(const BeaconSlot&, const char*, uint32_t, uint32_t); + uint32_t beaconNodeId() const; + bool isAnyTempRadioActive() const { return temporary; } + TestClock* getRTCClock() { return &clock; } + TestTables* getTables() { return &tables; } + Packet* obtainNewPacket() { + if (!pool_available) return nullptr; + ++obtained; + pool_packet = Packet(); + return &pool_packet; + } + void releasePacket(Packet* p) { assert(p == &pool_packet); ++released; } + Packet* createGroupDatagram(uint8_t, const GroupChannel&, const uint8_t*, size_t); + bool queueBeaconRadioFrame(const uint8_t* bytes, size_t size, + const RadioProfileParams& params, int8_t power) { + ++queues; + if (!accept) return false; + queued.assign(bytes, bytes + size); + queued_params = params; + queued_power = power; + return true; + } +}; +} +''' + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +using namespace mesh; +static std::vector unhex(const char* input) { + const std::string text(input); + assert(text.size() % 2 == 0); + std::vector bytes; + for (size_t i = 0; i < text.size(); i += 2) + bytes.push_back(uint8_t(std::stoul(text.substr(i, 2), nullptr, 16))); + return bytes; +} +static BeaconSlot slot(bool mt = false) { + BeaconSlot result; + result.radio.freq = 906.875f; + result.radio.bw = 250; + result.radio.sf = 11; + result.radio.cr = 5; + result.radio.preamble = mt ? 16 : 32; + result.radio.protocol = mt ? RadioProfileProtocol::Meshtext : RadioProfileProtocol::MeshCore; + result.power = 17; + result.preset = static_cast(mt::Preset::LongFast); + result.hops = 3; + result.enabled = true; + strcpy(result.text, "hello"); + return result; +} +static void public_frame(const char* expected) { + Mesh mesh; + const auto before = mesh.radio.profiles; + auto config = slot(); + assert(mesh.validateTextBeacon(config)); + assert(mesh.queueTextBeacon(config, "Node:name", 0, 0)); + assert(mesh.queued == unhex(expected)); + assert(mesh.tables.sent == 1 && mesh.tables.marked == mesh.queued); + assert(mesh.obtained == 1 && mesh.released == 1 && mesh.queues == 1); + assert(mesh.queued_params == config.radio && mesh.queued_power == 17); + assert(mesh.radio.profiles.primary == before.primary); + assert(mesh.radio.profiles.secondary.params == before.secondary.params); + assert(mesh.radio.profiles.secondary.mode == before.secondary.mode); + assert(mesh.radio.profiles.generation[0] == before.generation[0]); + assert(mesh.radio.profiles.generation[1] == before.generation[1]); +} +static void meshtext_frame(const char* expected, bool medium) { + Mesh mesh; + auto config = slot(true); + if (medium) { + config.preset = static_cast(mt::Preset::MediumFast); + config.radio.sf = 9; + config.hops = 7; + } + strcpy(config.text, "caf\xc3\xa9"); + mesh.radio.profiles.secondary.params.freq = 915; + mesh.radio.profiles.secondary.mode = RadioProfileMode::RxTx; + const auto before = mesh.radio.profiles; + assert(mesh.queueTextBeacon(config, "Node", 0x11223344, 0x89abcdef)); + assert(mesh.queued == unhex(expected)); + assert(mesh.obtained == 0 && mesh.released == 0 && mesh.tables.sent == 0); + assert(mesh.queues == 1 && mesh.queued_params == config.radio); + assert(mesh.radio.profiles.secondary.params == before.secondary.params); + assert(mesh.radio.profiles.secondary.mode == before.secondary.mode); + assert(mesh.radio.profiles.generation[0] == before.generation[0]); + assert(mesh.radio.profiles.generation[1] == before.generation[1]); + assert((mesh.queued[12] & 0x18) == 0); // No want_ack/via_mqtt bits. + mt::DecodedText decoded; + assert(mt::decodeText(mesh.queued.data(), mesh.queued.size(), medium ? "MediumFast" : "LongFast", + mt::DefaultPsk, sizeof(mt::DefaultPsk), 0x99887766, decoded)); + assert(!strcmp(decoded.text, "Node: caf\xc3\xa9")); +} +static void bounds() { + char output[beacon::MaxFormattedBytes + 2]; + uint8_t raw[mt::MaxFrameSize + 1]; + char name[33], text[82]; + memset(name, 'n', sizeof(name)); name[31] = 0; + memset(text, 't', sizeof(text)); text[80] = 0; + memset(output, 0xa5, sizeof(output)); + assert(beacon::formatText(name, text, output, sizeof(output)) == beacon::MaxFormattedBytes); + assert(output[beacon::MaxFormattedBytes] == 0); + assert(uint8_t(output[beacon::MaxFormattedBytes + 1]) == 0xa5); + assert(!beacon::formatText(name, text, output, beacon::MaxFormattedBytes)); + assert(!beacon::formatText(nullptr, text, output, sizeof(output))); + assert(!beacon::formatText("", text, output, sizeof(output))); + assert(!beacon::formatText(name, text, nullptr, sizeof(output))); + name[31] = 'n'; name[32] = 0; + assert(!beacon::formatText(name, text, output, sizeof(output))); + name[31] = 0; + text[80] = 't'; text[81] = 0; + assert(!beacon::validText(text)); + assert(!beacon::validText(nullptr) && !beacon::validText("")); + for (unsigned control = 1; control <= 127; ++control) { + if (control >= 32 && control != 127) continue; + const char controlled[] = {'t', static_cast(control), 'x', 0}; + assert(!beacon::validText(controlled)); + assert(!beacon::formatText("node", controlled, output, sizeof(output))); + } + for (const char* invalid : {"\x80", "\xc0\xaf", "\xe0\x80\x80", "\xed\xa0\x80", + "\xf0\x80\x80\x80", "\xf4\x90\x80\x80", "\xc2", "\xe2\x82"}) { + assert(!beacon::validText(invalid)); + assert(!beacon::formatText(invalid, "text", output, sizeof(output))); + } + // Exact maximum byte lengths, including multibyte scalars, stay intact. + memset(text, 't', 77); memcpy(text + 77, "\xe2\x82\xac", 4); + assert(beacon::validText(text)); + memset(name, 'n', 27); memcpy(name + 27, "\xf0\x9f\x9a\x80", 5); + assert(beacon::formatText(name, text, output, sizeof(output)) == beacon::MaxFormattedBytes); + memset(raw, 0xa5, sizeof(raw)); + assert(!beacon::meshtextFrame(static_cast(255), 4, 1, 0, "n", "text", raw)); + for (uint32_t from : {0U, UINT32_MAX}) + assert(!beacon::meshtextFrame(mt::Preset::LongFast, from, 1, 0, "n", "text", raw)); + assert(!beacon::meshtextFrame(mt::Preset::LongFast, 4, 0, 0, "n", "text", raw)); + assert(!beacon::meshtextFrame(mt::Preset::LongFast, 4, 1, 8, "n", "text", raw)); + for (uint8_t byte : raw) assert(byte == 0xa5); // Rejection produces no partial frame. + assert(beacon::meshtextFrame(mt::Preset::LongFast, 4, 1, 0, name, text, raw)); + assert(raw[mt::MaxFrameSize] == 0xa5); + uint8_t payload[beacon::MaxPublicPayloadBytes + 1]; + memset(payload, 0xa5, sizeof(payload)); + assert(!beacon::publicPayload(1, name, text, payload, beacon::MaxPublicPayloadBytes - 1)); + for (uint8_t byte : payload) assert(byte == 0xa5); + assert(beacon::publicPayload(1, name, text, payload, sizeof(payload)) == beacon::MaxPublicPayloadBytes); + assert(payload[beacon::MaxPublicPayloadBytes] == 0xa5); +} +static void admission() { + auto config = slot(); + { Mesh mesh; mesh.accept = false; + assert(!mesh.queueTextBeacon(config, "Node", 0, 0)); + assert(mesh.obtained == 1 && mesh.released == 1 && mesh.tables.sent == 0 && mesh.queues == 1); } + { Mesh mesh; mesh.pool_available = false; + assert(!mesh.queueTextBeacon(config, "Node", 0, 0)); + assert(mesh.obtained == 0 && mesh.released == 0 && mesh.queues == 0); } + { Mesh mesh; mesh.temporary = true; + assert(mesh.queueTextBeacon(config, "Node", 0, 0)); + assert(mesh.queues == 1 && mesh.obtained == 1 && mesh.released == 1); } + { Mesh mesh; mesh.radio.valid = false; + assert(!mesh.validateTextBeacon(config)); + assert(!mesh.queueTextBeacon(config, "Node", 0, 0) && !mesh.queues); } + { Mesh mesh; config.radio.sf = 0; assert(!mesh.validateTextBeacon(config)); } + { Mesh mesh; config = slot(); config.radio.preamble = 7; assert(!mesh.validateTextBeacon(config)); } + { Mesh mesh; config = slot(); config.radio.preamble = 65528; config.radio.sf = 12; + config.radio.bw = 7.8f; assert(!mesh.validateTextBeacon(config)); } + { Mesh mesh; config = slot(); config.power = -10; assert(!mesh.validateTextBeacon(config)); } + { Mesh mesh; config = slot(); config.power = 23; assert(!mesh.validateTextBeacon(config)); } + { Mesh mesh; config = slot(); config.hops = 8; assert(!mesh.validateTextBeacon(config)); } + { Mesh mesh; config = slot(); config.radio.protocol = static_cast(2); + assert(!mesh.validateTextBeacon(config)); } + { Mesh mesh; config = slot(true); mesh.radio.meshtext = false; assert(!mesh.validateTextBeacon(config)); } + { Mesh mesh; config = slot(true); config.preset = 255; assert(!mesh.validateTextBeacon(config)); } + { Mesh mesh; config = slot(true); assert(!mesh.queueTextBeacon(config, "Node", 4, 0)); + assert(!mesh.queues && !mesh.obtained); } + { Mesh mesh; config = slot(); assert(!mesh.queueTextBeacon(config, "", 0, 0)); + assert(!mesh.queues && !mesh.obtained); } +} +static void identity(const char* public_hex, const char* expected) { + const auto public_key = unhex(public_hex); + assert(public_key.size() == 32); + const uint32_t wanted = static_cast(strtoul(expected, nullptr, 10)); + assert(beacon::nodeId(public_key.data()) == wanted); + assert(beacon::nodeId(nullptr) == 0); + Mesh mesh; + memcpy(mesh.self_id.pub_key, public_key.data(), 32); + assert(mesh.beaconNodeId() == wanted && wanted >= 4 && wanted != UINT32_MAX); +} +int main(int argc, char** argv) { + assert(argc >= 2); + if (!strcmp(argv[1], "public")) { assert(argc == 3); public_frame(argv[2]); } + else if (!strcmp(argv[1], "longfast")) { assert(argc == 3); meshtext_frame(argv[2], false); } + else if (!strcmp(argv[1], "mediumfast")) { assert(argc == 3); meshtext_frame(argv[2], true); } + else if (!strcmp(argv[1], "bounds")) bounds(); + else if (!strcmp(argv[1], "admission")) admission(); + else if (!strcmp(argv[1], "identity")) { assert(argc == 4); identity(argv[2], argv[3]); } + else assert(false); + puts("PASS"); +} +''' + + +class BeaconFramesTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + try: + from cryptography.hazmat.primitives.ciphers import Cipher # noqa: F401 + except ImportError: + raise unittest.SkipTest("Python cryptography is required for independent frame vectors") + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + raise unittest.SkipTest("a host C++ compiler is required") + required = ["AESCommon.cpp", "AES128.cpp", "AES256.cpp", "BlockCipher.cpp", + "Crypto.cpp", "SHA256.cpp", "Hash.cpp"] + override = os.environ.get("MESHCORE_CRYPTO_DIR") + candidates = [Path(override)] if override else sorted((ROOT / ".pio/libdeps").glob("*/Crypto")) + crypto = next((path for path in candidates if all((path / name).is_file() for name in required)), None) + if not crypto: + raise unittest.SkipTest("an existing rweather/Crypto checkout is required (MESHCORE_CRYPTO_DIR)") + cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-beacon-frames-") + cls.addClassCleanup(cls.temp.cleanup) + work = Path(cls.temp.name) + (work / "Stream.h").write_text( + "#pragma once\nclass Stream { public: void print(char) {} };\n", encoding="ascii") + (work / "Mesh.h").write_text(ADAPTER, encoding="ascii") + group_builder = body((ROOT / "src/Mesh.cpp").read_text(encoding="utf-8"), + "Packet* Mesh::createGroupDatagram(") + cpp = work / "beacon.cpp" + cpp.write_text(HARNESS + "\nnamespace mesh {\n" + group_builder + "\n}\n", encoding="ascii") + cls.binary = work / ("beacon.exe" if sys.platform == "win32" else "beacon") + sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + if sys.platform.startswith("linux") else []) + result = subprocess.run( + [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-DMESH_ENABLE_TX_BEACONS=1", + "-DMESH_ENABLE_MESHTEXT_WIRE=1", "-DMESH_ENABLE_MESHTEXT_CHAT=0", + "-DMIN_LORA_TX_POWER=-9", "-DMAX_LORA_TX_POWER=22", *sanitizers, + "-I" + str(work), "-I" + str(crypto), "-I" + str(ROOT / "src"), str(cpp), + str(ROOT / "src/helpers/BeaconFrames.cpp"), str(ROOT / "src/MeshBeacon.cpp"), + str(ROOT / "src/helpers/MeshtextChat.cpp"), str(ROOT / "src/Utils.cpp"), + str(ROOT / "src/Packet.cpp"), *[str(crypto / name) for name in required], + "-o", str(cls.binary)], capture_output=True, text=True, timeout=90) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + + def run_case(self, *arguments): + result = subprocess.run([str(self.binary), *arguments], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS", result.stdout) + + def test_public_frame_matches_independent_aes_ecb_hmac_oracle_and_dedup(self): + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + plain = struct.pack("rename(IdBackup, IdPath)) ids_hold_ = true; + else result = ReadResult::Valid;""" + self.assertEqual(source.count(held), 1) + self.negative_control("rewound", source.replace(held, restored), "stale_backup") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_beacon_radio.py b/test/test_beacon_radio.py new file mode 100644 index 000000000..e4f6d5ecb --- /dev/null +++ b/test/test_beacon_radio.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Execute the actual transient beacon radio methods against a faultable modem.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def method(source, signature): + start = source.index(signature) + end = source.index("{", start) + 1 + depth = 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +HARNESS = r''' +#include +#include +#include +#include +#include +#define MESH_ENABLE_TX_BEACONS 1 +#define MESH_ENABLE_MESHTEXT_WIRE 1 +#define STATE_IDLE 0 +#define STATE_RX 1 +#define STATE_TX_WAIT 3 +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_SPI_CMD_TIMEOUT -705 +#define RADIOLIB_CHANNEL_FREE -15 +#define MAX_TRANS_UNIT 255 +#define NF_CALIB_SETTLE_MS 7 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; } +using Result = mesh::RadioParamApplyResult; +uint32_t now_ms = 100; +uint32_t millis() { return now_ms; } +uint32_t profileTimestamp(bool) { return now_ms * 1000; } +struct Board { + unsigned before = 0, after = 0; + void onBeforeTransmit() { ++before; } + void onAfterTransmit() { ++after; } +}; +struct Modem { + bool standby_fail = false, clear_fail = false, param_fail = false, arm_fail = false; + int tx_result = 0, scan_result = RADIOLIB_CHANNEL_FREE; + float freq = 906, bw = 62.5; + uint8_t sf = 7, cr = 5; + uint16_t preamble = 32; + mesh::RadioProfileProtocol protocol = mesh::RadioProfileProtocol::MeshCore; + int8_t power = 20; + unsigned standbys = 0, clears = 0, finishes = 0; + int standby() { ++standbys; return standby_fail ? -1 : 0; } + int clearIrqFlags(uint32_t) { ++clears; return clear_fail ? -1 : 0; } + int finishTransmit() { ++finishes; return 0; } + int startTransmit(uint8_t*, int) { return tx_result; } + int setPreambleLength(uint16_t value) { preamble = value; return 0; } +}; +struct RadioLibWrapper { + Modem modem; + Modem* _radio = &modem; + Board board; + Board* _board = &board; + mesh::RadioProfiles _profiles; + bool _beacon_owned = false, _beacon_restoring = false; + uint8_t _beacon_restore_profile = 0; + uint32_t _beacon_restore_at = 0, _beacon_cad_timeout = 0; + uint8_t state = STATE_RX, _active_profile = 0; + uint32_t _profile_generation = 1, _profile_visit_stamp = 0; + bool _profile_refresh_required = false; + uint16_t _physical_preamble = 32; + mesh::RadioProfileProtocol _physical_protocol = mesh::RadioProfileProtocol::MeshCore; + float _cur_freq = 906, _cur_bw = 62.5; + uint8_t _cur_sf = 7, _cur_cr = 5; + int8_t _cur_dbm = 20; + bool _params_valid = true, _dbm_valid = true; + bool _cw_active = false, pending = false, packet = false, busy = false, calibrating = false; + bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_hold_continuous = false; + bool _rx_ps_continuous_fallback = false, _nf_calib_active = false; + bool _rx_boosted_gain_valid = true, _cur_rx_boosted_gain = true; + bool _wd_last_busy = false; + uint8_t _startrx_fails = 0, _irq_probe_failures = 0; + uint32_t _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000; + uint32_t _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0; + uint32_t n_wd_soft = 0, n_wd_hard = 0; + unsigned long _nf_sample_from = 0; + unsigned applications = 0, protocols = 0, power_sets = 0, arms = 0, stops = 0; + unsigned deep_calls = 0, resets = 0, recalibrations = 0; + unsigned retune_begins = 0, retune_ends = 0, scans = 0; + bool deep_ok = true, repair_on_deep = false, power_fail = false, protocol_fail = false; + RadioLibWrapper() { + _profiles.primary.freq = 906; _profiles.primary.bw = 62.5; + _profiles.primary.sf = 7; _profiles.primary.cr = 5; + } + bool validateProfile(const mesh::RadioProfileParams& p) const { + return mesh::RadioProfiles::valid(p) && p.freq <= 960 && p.bw <= 500; + } + bool isInRecvMode() const { return state == STATE_RX; } + bool isCalibratingNoiseFloor() const { return calibrating; } + bool isChipBusy() { return busy; } + bool isReceivingPacket() { return packet; } + bool hasPendingRadioInterrupt() const { return pending; } + bool claimRadioInterrupt() { bool had = pending; pending = false; return had; } + void beginProfileRetune(bool) { ++retune_begins; } + void endProfileRetune(bool) { ++retune_ends; } + void cacheParams(float f, float b, uint8_t s, uint8_t c) { + _cur_freq = f; _cur_bw = b; _cur_sf = s; _cur_cr = c; _params_valid = true; + } + uint16_t profilePreamble(uint8_t profile) const { return _profiles.preamble(profile, 32); } + uint16_t currentPreambleLength() const { return _physical_preamble; } + bool applyParams(float f, float b, uint8_t s, uint8_t c) { + ++applications; + if (modem.param_fail) return false; + modem.freq = f; modem.bw = b; modem.sf = s; modem.cr = c; + modem.preamble = _physical_preamble; + return true; + } + bool applyProtocol(mesh::RadioProfileProtocol p) { + ++protocols; + if (protocol_fail) return false; + modem.protocol = p; return true; + } + int applyCachedTxPower(int8_t power) { + ++power_sets; if (power_fail) return -1; modem.power = power; return 0; + } + uint32_t cadScanTimeoutMillis(uint8_t sf, float) const { return sf * 10; } + int performChannelScanWithTimeout(uint32_t timeout) { + assert(timeout == _beacon_cad_timeout); ++scans; pending = true; + return modem.scan_result; + } + void stopReceiveDutyCycle() { ++stops; _rx_ps_armed = false; } + int startReceiveMode() { + ++arms; + if (modem.arm_fail) return -1; + _rx_ps_armed = _rx_ps_enabled; return 0; + } + uint32_t getEstAirtimeFor(int) { return 100; } + bool registerInterruptAction() { return true; } + void recalibrateNoiseFloor() { ++recalibrations; } + bool applyRxBoostedGainMode(bool) { return true; } + bool supportsRadioDeepInit() const { return true; } + bool radioDeepInit() { + ++deep_calls; + if (!deep_ok) return false; + if (repair_on_deep) { + busy = false; modem.standby_fail = modem.clear_fail = modem.param_fail = modem.arm_fail = false; + power_fail = protocol_fail = false; + } + return true; + } + void resetAGC() { ++resets; } + void idle() { modem.standby(); state = STATE_IDLE; } + uint8_t beginReconfigure(); + bool restoreAfterDeepInit(); + bool recoverRadio(bool hard); + void startRecv(); + bool startSendRaw(const uint8_t*, int); + void onSendFinished(); + uint32_t getBeaconAirtime(const mesh::RadioProfileParams&, int); + void cacheBeaconRestoreParams(); + Result prepareBeaconTransmit(const mesh::RadioProfileParams&, int8_t); + Result restoreBeaconReceive(); + bool isBeaconChannelBusy(); +}; +@METHODS@ +mesh::RadioProfileParams beacon(bool meshtext = false) { + mesh::RadioProfileParams p; + p.freq = 915; p.bw = 250; p.sf = 11; p.cr = 5; + if (meshtext) p.protocol = mesh::RadioProfileProtocol::Meshtext; + return p; +} +int main() { + unsigned cases = 0; + for (bool dual : {false, true}) for (bool rxps : {false, true}) for (bool mt : {false, true}) { + RadioLibWrapper w; now_ms = 100; + if (dual) { + w._profiles.secondary.params = w._profiles.primary; + w._profiles.secondary.params.freq = 908; + w._profiles.secondary.mode = mesh::RadioProfileMode::RxTx; + w._active_profile = 1; w._cur_freq = 908; w.modem.freq = 908; + } + w._rx_ps_enabled = w._rx_ps_armed = rxps; + const auto before = w._profiles; + assert(w.prepareBeaconTransmit(beacon(mt), 10) == Result::APPLIED); + assert(w._beacon_owned && w.state == STATE_IDLE && w.arms == 0); + assert(w.modem.freq == 915 && w.modem.power == 10); + assert(w.modem.preamble == (mt ? 16 : 32)); + assert(w._cur_freq == (dual ? 908 : 906) && w._cur_dbm == 20); + assert(w._profiles.primary == before.primary && w._profiles.secondary.params == before.secondary.params); + assert(w._profiles.generation[0] == before.generation[0] && w._profiles.generation[1] == before.generation[1]); + assert(w._profiles.switches == 0 && w._profiles.switch_test_samples[0] == 0); + assert(w.beginReconfigure() == 2); + w.startRecv(); assert(w.arms == 0); + assert(w.recoverRadio(true) == false && w.deep_calls == 0); + assert(!w.isBeaconChannelBusy() && !w.pending && w.state == STATE_IDLE); + assert(w.restoreBeaconReceive() == Result::APPLIED); + assert(!w._beacon_owned && w.state == STATE_RX && w._rx_ps_armed == rxps); + assert(w.modem.freq == (dual ? 908 : 906) && w.modem.power == 20); + assert(w.modem.protocol == mesh::RadioProfileProtocol::MeshCore); + assert(w.recalibrations == 0 && w.deep_calls == 0); + ++cases; + } + for (unsigned reason = 0; reason < 5; ++reason) { + RadioLibWrapper w; + if (reason == 0) w.pending = true; + if (reason == 1) w.packet = true; + if (reason == 2) w.busy = true; + if (reason == 3) w._cw_active = true; + if (reason == 4) w.calibrating = true; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::BUSY); + assert(!w._beacon_owned && w.applications == 0 && w.power_sets == 0); + ++cases; + } + for (unsigned failure = 0; failure < 4; ++failure) { + RadioLibWrapper w; + if (failure == 0) w.modem.param_fail = true; + if (failure == 1) w.protocol_fail = true; + if (failure == 2) w.power_fail = true; + if (failure == 3) w.modem.standby_fail = true; + assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::FAILED && w._beacon_owned); + w.repair_on_deep = true; + assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned); + assert(w.modem.freq == 906 && w.modem.protocol == mesh::RadioProfileProtocol::MeshCore && w.modem.power == 20); + ++cases; + } + for (unsigned changed = 0; changed < 2; ++changed) { + RadioLibWrapper w; + w._profiles.secondary.params = w._profiles.primary; + w._profiles.secondary.params.freq = 908; + w._profiles.secondary.mode = mesh::RadioProfileMode::RxTx; + w._active_profile = 1; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED); + if (changed == 0) { + w._profiles.secondary.params.freq = 909; ++w._profiles.generation[1]; + } else w._profiles.secondary.mode = mesh::RadioProfileMode::Off; + assert(w.restoreBeaconReceive() == Result::APPLIED); + assert(w.modem.freq == (changed == 0 ? 909 : 906)); + assert(w._active_profile == (changed == 0 ? 1 : 0)); + ++cases; + } + for (int cad_result : {RADIOLIB_CHANNEL_FREE, -6, -16, -702}) { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED); + w.modem.scan_result = cad_result; + assert(w.isBeaconChannelBusy() == (cad_result != RADIOLIB_CHANNEL_FREE)); + assert(!w.pending && w.arms == 0 && w.state == STATE_IDLE); + assert(w.restoreBeaconReceive() == Result::APPLIED); + ++cases; + } + for (bool spi_timeout : {false, true}) { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED); + w.modem.tx_result = spi_timeout ? RADIOLIB_ERR_SPI_CMD_TIMEOUT : -1; + const uint8_t bytes[] = {1, 2}; + assert(!w.startSendRaw(bytes, 2) && w._beacon_owned && w.deep_calls == 0); + assert(w._cur_freq == 906 && w._cur_dbm == 20); + assert(w.board.before == 1 && w.board.after == 1); + assert(w.restoreBeaconReceive() == Result::APPLIED && w.modem.freq == 906); + ++cases; + } + { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(), 10) == Result::APPLIED); + const uint8_t bytes[] = {1, 2}; + assert(w.startSendRaw(bytes, 2)); + assert(w.restoreBeaconReceive() == Result::BUSY && w._beacon_owned); + w.onSendFinished(); + assert(w.restoreBeaconReceive() == Result::APPLIED); + assert(w.board.before == 1 && w.board.after == 1 && w.modem.finishes == 1); + ++cases; + } + for (unsigned failure = 0; failure < 5; ++failure) { + RadioLibWrapper w; now_ms = UINT32_MAX - 50; + assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED); + if (failure == 0) w.modem.standby_fail = true; + if (failure == 1) w.modem.param_fail = true; + if (failure == 2) w.modem.clear_fail = true; + if (failure == 3) w.modem.arm_fail = true; + if (failure == 4) w.busy = true; + w.deep_ok = false; + assert(w.restoreBeaconReceive() == Result::BUSY && w._beacon_owned); + const unsigned before = w.applications; + ++now_ms; + assert(w.restoreBeaconReceive() == Result::BUSY && w.applications == before); + now_ms += 100; w.deep_ok = w.repair_on_deep = true; + assert(w.restoreBeaconReceive() == Result::APPLIED && !w._beacon_owned); + assert(w.modem.freq == 906 && w.modem.power == 20 && w.state == STATE_RX); + ++cases; + } + { + RadioLibWrapper w; + assert(w.prepareBeaconTransmit(beacon(true), 10) == Result::APPLIED); + assert(w.restoreAfterDeepInit()); + assert(w.modem.freq == 906 && w.modem.power == 20); + assert(w.modem.protocol == mesh::RadioProfileProtocol::MeshCore && w._beacon_owned); + assert(w.restoreBeaconReceive() == Result::APPLIED); + ++cases; + } + { + RadioLibWrapper w; + auto p = beacon(); + assert(w.getBeaconAirtime(p, 255) > 0); + assert(!w.getBeaconAirtime(p, 0) && !w.getBeaconAirtime(p, 256)); + p.freq = 1000; + assert(!w.getBeaconAirtime(p, 20)); + assert(w.prepareBeaconTransmit(p, 10) == Result::FAILED && !w._beacon_owned); + ++cases; + } + std::printf("PASS: %u actual-method beacon radio cases\n", cases); +} +''' + + +class BeaconRadioTest(unittest.TestCase): + def source(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + signatures = [ + "uint8_t RadioLibWrapper::beginReconfigure()", + "bool RadioLibWrapper::restoreAfterDeepInit()", + "bool RadioLibWrapper::recoverRadio(bool hard)", + "void RadioLibWrapper::startRecv()", + "bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len)", + "void RadioLibWrapper::onSendFinished()", + "uint32_t RadioLibWrapper::getBeaconAirtime(", + "void RadioLibWrapper::cacheBeaconRestoreParams()", + "mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit(", + "bool RadioLibWrapper::isBeaconChannelBusy()", + "mesh::RadioParamApplyResult RadioLibWrapper::restoreBeaconReceive()", + ] + return HARNESS.replace("@METHODS@", "\n\n".join(method(source, name) for name in signatures)) + + def run_harness(self, code, expect_pass=True): + with tempfile.TemporaryDirectory(prefix="meshcore-beacon-radio-") as directory: + cpp = Path(directory) / "test.cpp" + binary = Path(directory) / "test" + cpp.write_text(code, encoding="ascii") + command = ["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "src"), + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", + str(cpp), "-o", str(binary)] + build = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(build.returncode, 0, build.stdout + build.stderr) + env = dict(os.environ, ASAN_OPTIONS="detect_leaks=1:halt_on_error=1", + UBSAN_OPTIONS="halt_on_error=1:print_stacktrace=1") + result = subprocess.run([str(binary)], capture_output=True, text=True, env=env) + if expect_pass: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS:", result.stdout) + print(result.stdout.strip()) + else: + self.assertNotEqual(result.returncode, 0, "negative control unexpectedly passed") + self.assertIn("Assertion", result.stderr) + + def test_actual_radio_transaction(self): + self.run_harness(self.source()) + + def test_ownership_and_snapshot_negative_controls(self): + source = self.source() + mutations = [ + ("if (_beacon_owned && !_beacon_restoring) return 2;", "/* remove ownership guard */"), + ("if (_beacon_owned && !_beacon_restoring) return;", "/* permit RX on beacon tuple */"), + ("bool applied = _radio->standby() == RADIOLIB_ERR_NONE", + "cacheParams(p.freq, p.bw, p.sf, p.cr); bool applied = _radio->standby() == RADIOLIB_ERR_NONE"), + ] + for old, new in mutations: + with self.subTest(mutation=old): + self.assertIn(old, source) + self.run_harness(source.replace(old, new, 1), expect_pass=False) + + def test_feature_gate_matrix(self): + cases = [ + ([], 0, 0), + (["STM32_PLATFORM"], 0, 0), + (["USE_SX1262"], 0, 0), + (["NRF52_PLATFORM", "USE_SX1262"], 0, 1), + # The codec is stable across translation units; the concrete + # unqualified Indicator driver still declines Meshtext TX. + (["NRF52_PLATFORM", "USE_SX1262", "SENSECAP_INDICATOR_LORA"], 0, 1), + (["USE_SX1262", "MESH_ENABLE_TX_BEACONS=0"], 0, 0), + (["USE_SX1262", "MESH_PRIMARY_ESPNOW=1"], 0, 0), + (["ESP32_PLATFORM", "CONFIG_IDF_TARGET_ESP32S3", "COMPANION_RADIO_FULL", "USE_SX1262"], 1, 1), + (["ESP32_PLATFORM", "CONFIG_IDF_TARGET_ESP32S3", "COMPANION_RADIO_FULL", "USE_SX1262", + "MESH_ENABLE_TX_BEACONS=0"], 1, 1), + (["NRF52_PLATFORM", "RAK_4631", "COMPANION_RADIO_FULL", "USE_SX1262"], 1, 1), + ] + for flags, chat, wire in cases: + with self.subTest(flags=flags): + code = '#include \n' + code += f'static_assert(MESH_ENABLE_MESHTEXT_CHAT == {chat}, "chat gate");\n' + code += f'static_assert(MESH_ENABLE_MESHTEXT_WIRE == {wire}, "wire gate");\n' + command = ["g++", "-std=c++17", "-fsyntax-only", "-x", "c++", "-I", str(ROOT / "src")] + command += ["-D" + flag for flag in flags] + ["-"] + result = subprocess.run(command, input=code, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshtext_config.py b/test/test_meshtext_config.py index 97e6a4684..838b0c473 100644 --- a/test/test_meshtext_config.py +++ b/test/test_meshtext_config.py @@ -22,7 +22,8 @@ class MeshtextConfigTest(unittest.TestCase): if self.compiler is None: self.skipTest("a C++ preprocessor is required") - def effective(self, defines, *, arduino_first=False, sdk_target=None): + def effective(self, defines, *, arduino_first=False, sdk_target=None, + feature="MESH_ENABLE_MESHTEXT_CHAT"): with tempfile.TemporaryDirectory(prefix="meshcore-mt-config-") as folder: work = Path(folder) (work / "Arduino.h").write_text( @@ -37,7 +38,7 @@ class MeshtextConfigTest(unittest.TestCase): if arduino_first: includes.reverse() source = "".join("#include <" + name + ">\n" for name in includes) - source += "MESH_ENABLE_MESHTEXT_CHAT\n" + source += feature + "\n" result = subprocess.run( [self.compiler, "-E", "-P", "-x", "c++", "-", "-I", str(work), "-I", str(ROOT / "src"), @@ -85,6 +86,114 @@ class MeshtextConfigTest(unittest.TestCase): "USE_SX1262", "MESH_ENABLE_MESHTEXT_CHAT=0"], 0, sdk_target="CONFIG_IDF_TARGET_ESP32S3") + def test_tx_beacon_wire_support_does_not_enable_companion_chat(self): + for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "RP2040_PLATFORM"): + flags = [platform, "USE_SX1262"] + self.assert_orders(flags, 0) + self.assert_orders(flags, 1, feature="MESH_ENABLE_MESHTEXT_WIRE") + self.assert_orders(flags + ["MESH_ENABLE_TX_BEACONS=0"], 0, + feature="MESH_ENABLE_MESHTEXT_WIRE") + self.assert_orders(flags + ["MESH_PRIMARY_ESPNOW=1"], 1, + feature="MESH_ENABLE_MESHTEXT_WIRE") + self.assert_orders(flags + ["SENSECAP_INDICATOR_LORA"], 1, + feature="MESH_ENABLE_MESHTEXT_WIRE") + self.assert_orders([platform, "USE_LR1110"], 1, + feature="MESH_ENABLE_MESHTEXT_WIRE") + for flags in (["STM32_PLATFORM", "USE_SX1262"], []): + self.assert_orders(flags, 0, feature="MESH_ENABLE_MESHTEXT_WIRE") + + def test_late_variant_chip_discovery_does_not_change_wire_layout(self): + with tempfile.TemporaryDirectory(prefix="meshcore-mt-late-variant-") as folder: + work = Path(folder) + # Nano G2 Ultra declares USE_SX1262 in its Arduino variant header, + # not in global flags. The codec translation unit omits Arduino. + (work / "Arduino.h").write_text( + "#pragma once\n#define USE_SX1262 1\n", encoding="ascii") + for arduino_first in (False, True): + source = "#include \n" if arduino_first else "" + source += "#include \n" + source += "static_assert(MESH_ENABLE_MESHTEXT_CHAT == 0, \"chat stays disabled\");\n" + source += "static_assert(MESH_ENABLE_MESHTEXT_WIRE == 1, \"consistent wire layout\");\n" + result = subprocess.run( + [self.compiler, "-std=c++17", "-fsyntax-only", "-x", "c++", "-", + "-DNRF52_PLATFORM", "-I", str(work), "-I", str(ROOT / "src")], + input=source, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_actual_driver_capability_and_mc_only_no_spi(self): + source = (ROOT / "src/helpers/radiolib/CustomSX1262Wrapper.h").read_text() + support = source.index(" bool supportsMeshtext() const override") + start = source.rfind("#if", 0, support) + end = source.index("#endif", support) + len("#endif") + capability = source[start:end] + start = source.index("bool applyProtocol(") + end = source.index("{", start) + 1 + depth = 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + protocol = source[start:end].replace(" override", "") + code = r''' +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +namespace mesh { enum class RadioProfileProtocol { MeshCore, Meshtext }; } +struct Base { virtual bool supportsMeshtext() const { return false; } }; +struct CustomSX1262 { + unsigned writes = 0; + int setProfileProtocol(uint8_t) { ++writes; return 0; } +}; +struct Actual : Base { + CustomSX1262 chip; + CustomSX1262* _radio = &chip; + @CAPABILITY@ +#if MESH_ENABLE_MESHTEXT_WIRE + @PROTOCOL@ +#endif +}; +int main() { + Actual driver; + assert(driver.supportsMeshtext() == EXPECTED); +#if MESH_ENABLE_MESHTEXT_WIRE + assert(driver.applyProtocol(mesh::RadioProfileProtocol::MeshCore)); + assert(driver.applyProtocol(mesh::RadioProfileProtocol::Meshtext) == EXPECTED); + assert(driver.chip.writes == (EXPECTED ? 2U : 0U)); +#endif +} +'''.replace("@CAPABILITY@", capability).replace("@PROTOCOL@", protocol) + cases = [ + (["NRF52_PLATFORM", "USE_SX1262"], True), + (["NRF52_PLATFORM", "USE_SX1262", "SENSECAP_INDICATOR_LORA"], False), + (["NRF52_PLATFORM", "USE_SX1262", "SENSECAP_INDICATOR_LORA", + "MESH_ENABLE_MESHTEXT_CHAT=1"], True), + (["NRF52_PLATFORM", "USE_SX1262", "MESH_ENABLE_MESHTEXT_WIRE=0"], False), + (["NRF52_PLATFORM", "USE_SX1262", "MESH_ENABLE_MESHTEXT_CHAT=1", + "MESH_ENABLE_MESHTEXT_WIRE=0"], False), + ] + with tempfile.TemporaryDirectory(prefix="meshcore-mt-driver-gate-") as folder: + binary = Path(folder) / "test" + for flags, expected in cases: + with self.subTest(flags=flags): + command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-x", "c++", "-", "-I", str(ROOT / "src"), "-o", str(binary)] + command += ["-D" + flag for flag in flags] + ["-DEXPECTED=" + str(int(expected))] + result = subprocess.run(command, input=code, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_chat_keeps_wire_support_with_beacons_disabled(self): + flags = ["NRF52_PLATFORM", "RAK_4631", "COMPANION_RADIO_FULL", "USE_SX1262", + "MESH_ENABLE_TX_BEACONS=0"] + self.assert_orders(flags, 1) + self.assert_orders(flags, 1, feature="MESH_ENABLE_MESHTEXT_WIRE") + flags = ["ESP32_PLATFORM", "ARDUINO=10819", "COMPANION_RADIO_FULL", "USE_SX1262", + "MESH_ENABLE_TX_BEACONS=0"] + self.assert_orders(flags, 1, sdk_target="CONFIG_IDF_TARGET_ESP32S3") + self.assert_orders(flags, 1, feature="MESH_ENABLE_MESHTEXT_WIRE", + sdk_target="CONFIG_IDF_TARGET_ESP32S3") + if __name__ == "__main__": unittest.main() diff --git a/test/test_meshtext_transport.py b/test/test_meshtext_transport.py index 169050dc6..e921af4e3 100644 --- a/test/test_meshtext_transport.py +++ b/test/test_meshtext_transport.py @@ -277,7 +277,8 @@ int main() { } } ''' - self.compile_and_run(harness + methods + cases, ['-DMESH_ENABLE_MESHTEXT_CHAT=1']) + self.compile_and_run(harness + methods + cases, + ['-DMESH_ENABLE_MESHTEXT_CHAT=1', '-DMESH_ENABLE_MESHTEXT_WIRE=1']) def test_sx1262_wire_settings_and_deep_recovery(self): wrapper = (ROOT/'src/helpers/radiolib/CustomSX1262Wrapper.h').read_text() @@ -314,6 +315,8 @@ struct CustomSX1262: SX1262 { }; struct RadioLibWrapper { CustomSX1262 chip;CustomSX1262* _radio=&chip; + bool supported=true; + bool supportsMeshtext() const { return supported; } bool _rx_ps_armed=true,_params_valid=true,_dbm_valid=false,_rx_boosted_gain_valid=false; int state=1;uint8_t _cur_sf=11,_cur_cr=5;float _cur_freq=906.875,_cur_bw=250; int8_t _cur_dbm=22;bool _cur_rx_boosted_gain=false; @@ -343,11 +346,14 @@ int main() { assert(w.chip.sync==0x2b && w.chip.crc && w.chip.explicit_header && !w.chip.iq); assert(!w._rx_ps_armed && w.state==STATE_IDLE); w._physical_protocol=Protocol::MeshCore;assert(w.restoreAfterDeepInit());assert(w.chip.sync==0x12); + RadioLibWrapper disabled;disabled.supported=false; + assert(disabled.applyProtocol(Protocol::MeshCore)); + assert(!disabled.applyProtocol(Protocol::Meshtext) && disabled.chip.step==0); } ''' self.compile_and_run('#include \n' + harness.replace('@WIRE@',wire) .replace('@PROTOCOL@',protocol).replace('@RESTORE@',restore), - ['-DMESH_ENABLE_MESHTEXT_CHAT=1']) + ['-DMESH_ENABLE_MESHTEXT_CHAT=1', '-DMESH_ENABLE_MESHTEXT_WIRE=1']) if __name__ == '__main__':