diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index daa47d9b9..501f85561 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -316,6 +316,13 @@ jobs: 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_beacon_legacy_lora_protocol.py -v + python3 -B test/test_lr_meshtext_wire.py -v + python3 -B test/test_stm32wl_meshtext_protocol.py -v + python3 -B test/test_meshtext_modem_profiles.py -v + python3 -B test/test_environment_no_gps_link.py -v + python3 -B test/test_beacon_file_api.py -v + python3 -B test/test_beacon_sx1276_cad.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/tx_beacons.md b/docs/tx_beacons.md index b02429613..643410362 100644 --- a/docs/tx_beacons.md +++ b/docs/tx_beacons.md @@ -68,9 +68,27 @@ 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. +disabled to preserve existing features and filesystem space. The Meshtext +packet-format adapters cover SX1262 (including the Indicator LoRa variant), +SX1268, LLCC68, SX1276 (including external-PA variants), LR1110, LR1121, +LR2021, and STM32WLx. The STM32WLx adapter is available to explicitly enabled +builds, but does not enable beacons in the space-constrained STM32 artifacts. +An opt-in Wio-E5 Mini USB Companion link exceeded its unchanged application +limit by 19,820 bytes; the three existing STM32 Sensor targets exceeded theirs +by 37,000-42,736 bytes. Enabling those artifacts needs separate size work, +not just a radio capability flag or a smaller filesystem. +ESP-NOW-only boards have no LoRa transmitter and cannot send these packets. + +Choose a region and preset the physical chip, board RF path, and antenna +support. In particular, SX1268 requires a compatible band such as EU433, not +US or ANZ. LLCC68 supports MediumFast (SF9/BW250), but not LongFast +(SF11/BW250). Invalid modem combinations are rejected, not silently changed +to a different preset or transmitted as MeshCore. Local RF rules and the +board's power limits still apply. + +Protocol entry and restoration use each chip's checked sync-word, CRC, +explicit-header, and normal-IQ configuration. Beacon TX temporarily disables +LR2021 receive side detectors and restores their saved configuration afterward. Enabling this feature does not enable Companion Meshtext receive/chat support on other roles. diff --git a/src/helpers/BeaconManager.cpp b/src/helpers/BeaconManager.cpp index f57fd0bed..484d4d327 100644 --- a/src/helpers/BeaconManager.cpp +++ b/src/helpers/BeaconManager.cpp @@ -155,7 +155,7 @@ bool textValue(const char* text, char* output) { return true; } File makeFile(FILESYSTEM* fs) { -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) return File(*fs); #else (void)fs; @@ -163,11 +163,9 @@ File makeFile(FILESYSTEM* fs) { #endif } bool openRead(FILESYSTEM* fs, const char* path, File& file) { -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_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 @@ -176,10 +174,8 @@ bool openRead(FILESYSTEM* fs, const char* path, File& 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) +#if defined(NRF52_PLATFORM) || defined(STM32_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 diff --git a/src/helpers/radiolib/CustomLLCC68.h b/src/helpers/radiolib/CustomLLCC68.h index a87edf46f..3b638f2d5 100644 --- a/src/helpers/radiolib/CustomLLCC68.h +++ b/src/helpers/radiolib/CustomLLCC68.h @@ -16,6 +16,15 @@ class CustomLLCC68 : public LLCC68 { public: CustomLLCC68(Module *mod) : LLCC68(mod) { } + int16_t setProfileProtocol(uint8_t syncWord) { + // Retain RadioLib's ordinary setters: this wrapper does not own the + // CustomSX1262 fast RX packet cache, despite sharing its chip family. + int16_t status = LLCC68::setSyncWord(syncWord); + if (status == RADIOLIB_ERR_NONE) status = LLCC68::setCRC(1); + if (status == RADIOLIB_ERR_NONE) status = LLCC68::explicitHeader(); + if (status == RADIOLIB_ERR_NONE) status = LLCC68::invertIQ(false); + return status; + } int16_t setMorseFastRssi() { return setSX126xMorseRssiWindow(this); } diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index 6764a4b18..d1cbdbae8 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -16,6 +16,9 @@ public: CustomLLCC68Wrapper(CustomLLCC68& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsMorseTx() const override { return true; } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } +#if MESH_ENABLE_MESHTEXT_WIRE + bool supportsMeshtext() const override { return true; } +#endif void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); @@ -23,6 +26,14 @@ public: } protected: +#if MESH_ENABLE_MESHTEXT_WIRE + bool applyProtocol(mesh::RadioProfileProtocol protocol) override { + if (protocol != mesh::RadioProfileProtocol::MeshCore + && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; + return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; + } +#endif int16_t prepareMorseReceiver(float frequency_mhz) override { if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; _morse_rx_fsk_mode = true; diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index efa4bc97c..4fa190392 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -172,6 +172,19 @@ class CustomLR1110 : public LR1110 { return LR1110::setCodingRate(cr, longInterleave); } + // Protocol changes are outside the acknowledged fast-RX context. Use + // checked RadioLib setters so cached packet fields and hardware agree, + // including after a partial failure and a later MeshCore restoration. + int16_t setProfileProtocol(uint8_t syncWord) { + _profileSwitch.invalidate(); + _profilePacketDirty = false; + int16_t status = LR1110::setSyncWord(syncWord); + if (status == RADIOLIB_ERR_NONE) status = LR1110::setCRC(2); + if (status == RADIOLIB_ERR_NONE) status = LR1110::explicitHeader(); + if (status == RADIOLIB_ERR_NONE) status = LR1110::invertIQ(false); + return status; + } + // A single acknowledged SetModulationParams replaces three RadioLib // setters. Keep RadioLib's cached tuple and automatic LDRO calculation. int16_t setLoRaModulationParams(float bw, uint8_t sf, uint8_t cr) { diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 1879b7172..641a7d47d 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -18,10 +18,21 @@ public: : RadioLibWrapper(radio, board), _deep_init(NULL) { } bool supportsCarrierWave() const override { return true; } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } +#if MESH_ENABLE_MESHTEXT_WIRE + bool supportsMeshtext() const override { return true; } +#endif void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } protected: +#if MESH_ENABLE_MESHTEXT_WIRE + bool applyProtocol(mesh::RadioProfileProtocol protocol) override { + if (protocol != mesh::RadioProfileProtocol::MeshCore + && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; + return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; + } +#endif int16_t startMorseReceiver() override { return static_cast(_radio)->LR1110::startReceive( RADIOLIB_LR11X0_RX_TIMEOUT_INF, 0, 0, 0); diff --git a/src/helpers/radiolib/CustomLR1121.h b/src/helpers/radiolib/CustomLR1121.h index 5e7a30ab6..b17b11061 100644 --- a/src/helpers/radiolib/CustomLR1121.h +++ b/src/helpers/radiolib/CustomLR1121.h @@ -16,6 +16,14 @@ class CustomLR1121 : public LR1121 { public: CustomLR1121(Module *mod) : LR1121(mod) { } + int16_t setProfileProtocol(uint8_t syncWord) { + int16_t status = LR1121::setSyncWord(syncWord); + if (status == RADIOLIB_ERR_NONE) status = LR1121::setCRC(2); + if (status == RADIOLIB_ERR_NONE) status = LR1121::explicitHeader(); + if (status == RADIOLIB_ERR_NONE) status = LR1121::invertIQ(false); + return status; + } + void setRfSwitchTable(const uint32_t (&pins)[Module::RFSWITCH_MAX_PINS], const Module::RfSwitchMode_t table[]) { _morse_rf_switch.capture(this->mod, pins, table); diff --git a/src/helpers/radiolib/CustomLR1121Wrapper.h b/src/helpers/radiolib/CustomLR1121Wrapper.h index 9a6dc561d..af6526e15 100644 --- a/src/helpers/radiolib/CustomLR1121Wrapper.h +++ b/src/helpers/radiolib/CustomLR1121Wrapper.h @@ -12,6 +12,9 @@ public: CustomLR1121Wrapper(CustomLR1121& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsMorseTx() const override { return true; } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } +#if MESH_ENABLE_MESHTEXT_WIRE + bool supportsMeshtext() const override { return true; } +#endif void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } void powerOff() { @@ -21,6 +24,14 @@ public: } protected: +#if MESH_ENABLE_MESHTEXT_WIRE + bool applyProtocol(mesh::RadioProfileProtocol protocol) override { + if (protocol != mesh::RadioProfileProtocol::MeshCore + && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; + return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; + } +#endif int16_t startMorseReceiver() override { return static_cast(_radio)->LR1121::startReceive( RADIOLIB_LR11X0_RX_TIMEOUT_INF, 0, 0, 0); diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index d639acd8b..d0c9a2d3f 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -52,6 +52,14 @@ class CustomLR2021 : public LR2021 { public: CustomLR2021(Module *mod) : LR2021(mod) { irqDioNum = LR2021_IRQ_DIO; } + int16_t setProfileProtocol(uint8_t syncWord) { + int16_t status = LR2021::setSyncWord(syncWord); + if (status == RADIOLIB_ERR_NONE) status = LR2021::setCRC(2); + if (status == RADIOLIB_ERR_NONE) status = LR2021::explicitHeader(); + if (status == RADIOLIB_ERR_NONE) status = LR2021::invertIQ(false); + return status; + } + int16_t beginMorseReceive(float frequency_mhz) { const uint8_t gain = mesh::lr2021::isHighBand(frequency_mhz) ? gainModeHf : gainModeLf; int16_t status = LR2021::beginGFSK(frequency_mhz, diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index e32e38d14..0aba6a403 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -20,6 +20,9 @@ public: CustomLR2021Wrapper(CustomLR2021& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsMorseTx() const override { return true; } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } +#if MESH_ENABLE_MESHTEXT_WIRE + bool supportsMeshtext() const override { return true; } +#endif void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); @@ -27,6 +30,14 @@ public: } protected: +#if MESH_ENABLE_MESHTEXT_WIRE + bool applyProtocol(mesh::RadioProfileProtocol protocol) override { + if (protocol != mesh::RadioProfileProtocol::MeshCore + && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; + return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; + } +#endif int16_t startMorseReceiver() override { return static_cast(_radio)->LR2021::startReceive( RADIOLIB_LR2021_RX_TIMEOUT_INF, 0, 0, 0); @@ -97,8 +108,9 @@ protected: } int16_t applyCachedTxPower(int8_t dbm) override { - const float frequency = _params_valid - ? _cur_freq : ((CustomLR2021 *)_radio)->getFreqMHz(); + // A TX-only excursion deliberately preserves the normal wrapper cache. + // Clamp for the physically selected band, not that normal RX frequency. + const float frequency = ((CustomLR2021 *)_radio)->getFreqMHz(); return _radio->setOutputPower(mesh::clampLoRaTxPower(dbm, frequency)); } @@ -158,6 +170,14 @@ protected: } bool applySideDetectorConfig(uint8_t primary_sf, float bw) { +#if MESH_ENABLE_TX_BEACONS + // Saved RX side detectors are not part of an independent TX-only tuple. + // Disable them without changing their configuration, then restore the + // normal set when the owner's receive-restoration transaction runs. + if (isBeaconRadioOwned() && !_beacon_restoring) { + return ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0) == RADIOLIB_ERR_NONE; + } +#endif if (_profiles.enabled()) { return ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomSTM32WLx.h b/src/helpers/radiolib/CustomSTM32WLx.h index 4efd425b9..2a6eca10d 100644 --- a/src/helpers/radiolib/CustomSTM32WLx.h +++ b/src/helpers/radiolib/CustomSTM32WLx.h @@ -12,6 +12,16 @@ class CustomSTM32WLx : public STM32WLx { public: CustomSTM32WLx(STM32WLx_Module *mod) : STM32WLx(mod) { } + // STM32WLx inherits the SX126x LoRa packet setters. Unlike CustomSX1262, + // this driver has no acknowledged fast-hop cache: every protocol change + // repairs the complete CRC/header/IQ tuple through the ordinary API. + int16_t setProfileProtocol(uint8_t syncWord) { + int16_t status = STM32WLx::setSyncWord(syncWord); + if (status == RADIOLIB_ERR_NONE) status = STM32WLx::setCRC(1); + if (status == RADIOLIB_ERR_NONE) status = STM32WLx::explicitHeader(); + if (status == RADIOLIB_ERR_NONE) status = STM32WLx::invertIQ(false); + return status; + } int16_t setMorseFastRssi() { return setSX126xMorseRssiWindow(this); } diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index 61351aaaf..b82dc2137 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -18,12 +18,24 @@ public: ~CustomSTM32WLxWrapper() { unregisterInterruptAction(); } CustomSTM32WLxWrapper(CustomSTM32WLx& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsMorseTx() const override { return true; } +#if MESH_ENABLE_MESHTEXT_WIRE + bool supportsMeshtext() const override { return true; } +#endif #if MESH_ENABLE_MORSE_RX bool supportsMorseRx() const override { return supportsRadioDeepInit(); } #endif void setDeepInitCallback(DeepInitCallback callback) { _deep_init = callback; } protected: +#if MESH_ENABLE_MESHTEXT_WIRE + bool applyProtocol(mesh::RadioProfileProtocol protocol) override { + uint8_t sync; + if (protocol == mesh::RadioProfileProtocol::MeshCore) sync = 0x12; + else if (protocol == mesh::RadioProfileProtocol::Meshtext) sync = 0x2b; + else return false; + return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; + } +#endif #if MESH_ENABLE_MORSE_RX float morseReceiverBandwidthKHz() const override { return 4.8f; } int16_t prepareMorseReceiver(float frequency_mhz) override { @@ -71,6 +83,9 @@ protected: public: bool setCodingRate(uint8_t cr) override { +#if MESH_ENABLE_TX_BEACONS + if (isBeaconRadioOwned()) return false; +#endif if (isCarrierWaveActive()) return false; return ((CustomSTM32WLx *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 1098764cb..8c0c8daba 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -41,8 +41,7 @@ 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_WIRE && (MESH_ENABLE_MESHTEXT_CHAT \ - || (MESH_ENABLE_TX_BEACONS && !defined(SENSECAP_INDICATOR_LORA))) +#if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif @@ -73,8 +72,10 @@ protected: } #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 (protocol != mesh::RadioProfileProtocol::MeshCore + && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + // Respect an explicitly disabled wire implementation, including builds + // that retain other beacon or Companion features. if (!supportsMeshtext()) return protocol == mesh::RadioProfileProtocol::MeshCore; auto* radio = static_cast(_radio); const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; diff --git a/src/helpers/radiolib/CustomSX1268.h b/src/helpers/radiolib/CustomSX1268.h index eb0176b61..d54d18688 100644 --- a/src/helpers/radiolib/CustomSX1268.h +++ b/src/helpers/radiolib/CustomSX1268.h @@ -16,6 +16,15 @@ class CustomSX1268 : public SX1268 { public: CustomSX1268(Module *mod) : SX1268(mod) { } + int16_t setProfileProtocol(uint8_t syncWord) { + // This driver has no owned fast packet cache. Apply the complete + // RadioLib packet tuple, including its normal-IQ register workaround. + int16_t status = SX1268::setSyncWord(syncWord); + if (status == RADIOLIB_ERR_NONE) status = SX1268::setCRC(1); + if (status == RADIOLIB_ERR_NONE) status = SX1268::explicitHeader(); + if (status == RADIOLIB_ERR_NONE) status = SX1268::invertIQ(false); + return status; + } int16_t setMorseFastRssi() { return setSX126xMorseRssiWindow(this); } diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 6d0b6325d..ba14566f4 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -20,6 +20,9 @@ public: CustomSX1268Wrapper(CustomSX1268& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsMorseTx() const override { return true; } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } +#if MESH_ENABLE_MESHTEXT_WIRE + bool supportsMeshtext() const override { return true; } +#endif void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); @@ -27,6 +30,14 @@ public: } protected: +#if MESH_ENABLE_MESHTEXT_WIRE + bool applyProtocol(mesh::RadioProfileProtocol protocol) override { + if (protocol != mesh::RadioProfileProtocol::MeshCore + && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; + return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; + } +#endif float morseReceiverBandwidthKHz() const override { return 4.8f; } int16_t prepareMorseReceiver(float frequency_mhz) override { if (!_board->prepareRadioFrequency(frequency_mhz)) return RADIOLIB_ERR_INVALID_FREQUENCY; diff --git a/src/helpers/radiolib/CustomSX1276.h b/src/helpers/radiolib/CustomSX1276.h index 20428b6b1..502f2c074 100644 --- a/src/helpers/radiolib/CustomSX1276.h +++ b/src/helpers/radiolib/CustomSX1276.h @@ -15,6 +15,17 @@ class CustomSX1276 : public SX1276 { public: CustomSX1276(Module *mod) : SX1276(mod) { } + int16_t setProfileProtocol(uint8_t syncWord) { + // Both wire protocols use CRC-on, explicit-header, normal-IQ LoRa. + // Use RadioLib's setters so its CRC/header caches and IQ workaround + // registers agree with the hardware before the next RX or TX. + int16_t status = SX1276::setSyncWord(syncWord); + if (status == RADIOLIB_ERR_NONE) status = SX1276::setCRC(true); + if (status == RADIOLIB_ERR_NONE) status = SX1276::explicitHeader(); + if (status == RADIOLIB_ERR_NONE) status = SX1276::invertIQ(false); + return status; + } + int16_t beginMorseReceive(float frequency_mhz) { int16_t status = SX1276::beginFSK(frequency_mhz, mesh::MorseRx::ReceiverBitRateKbps, 0.6f, 2.6f, 2, 16, false); @@ -110,25 +121,27 @@ class CustomSX1276 : public SX1276 { int16_t state = startChannelScan(); RADIOLIB_ASSERT(state); - // SX127x getChannelScanResult() reports a free channel before CAD has - // completed, so wait on its IRQ pins with the same bounded policy used - // by the generic wrapper. - const unsigned long started = millis(); - unsigned long last_watchdog_service = started; - while(!this->mod->hal->digitalRead(this->mod->getIrq())) { - this->mod->hal->yield(); + // SX127x getChannelScanResult() reports FREE even before CAD completes. + // DIO0 proves CAD_DONE; then read CAD_DETECTED from the actual IRQ flags, + // including when completion/detection already happened before this poll. + const uint32_t started = millis(); + uint32_t last_watchdog_service = started; + for (;;) { + if(this->mod->hal->digitalRead(this->mod->getIrq())) { + return getChannelScanResult(); + } if(this->mod->hal->digitalRead(this->mod->getGpio())) { return(RADIOLIB_PREAMBLE_DETECTED); } - const unsigned long now = millis(); - if(now - last_watchdog_service >= 1000UL) { + const uint32_t now = millis(); + if(uint32_t(now - started) >= timeout_ms) { + return(RADIOLIB_ERR_RX_TIMEOUT); + } + if(uint32_t(now - last_watchdog_service) >= 1000UL) { board.serviceWatchdog(); last_watchdog_service = now; } - if(now - started >= timeout_ms) { - return(RADIOLIB_ERR_RX_TIMEOUT); - } + this->mod->hal->yield(); } - return(RADIOLIB_CHANNEL_FREE); } }; diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index 40a421a97..c1e5ab539 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -13,6 +13,9 @@ public: CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsCarrierWave() const override { return supportsRadioDeepInit(); } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } +#if MESH_ENABLE_MESHTEXT_WIRE + bool supportsMeshtext() const override { return true; } +#endif void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); @@ -20,6 +23,14 @@ public: } protected: +#if MESH_ENABLE_MESHTEXT_WIRE + bool applyProtocol(mesh::RadioProfileProtocol protocol) override { + if (protocol != mesh::RadioProfileProtocol::MeshCore + && protocol != mesh::RadioProfileProtocol::Meshtext) return false; + const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; + return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; + } +#endif int16_t startMorseReceiver() override { return static_cast(_radio)->receiveDirect(); } @@ -109,6 +120,13 @@ protected: int16_t performChannelScan() override { return ((CustomSX1276 *)_radio)->tryScanChannel(cadScanTimeoutMillis(), *_board); } +#if MESH_ENABLE_TX_BEACONS + int16_t performChannelScanWithTimeout(unsigned long timeout_ms) override { + // Beacon CAD has its own modulation-derived deadline. Preserve SX127x's + // DIO0 completion rule instead of the generic UNKNOWN-result poll. + return static_cast(_radio)->tryScanChannel(timeout_ms, *_board); + } +#endif bool radioDeepInit() override { if (!prepareRadioHardReset()) return false; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index a05916c5d..180057f06 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -2,6 +2,7 @@ #define RADIOLIB_STATIC_ONLY 1 #include "RadioLibWrappers.h" #include "RadioInterruptEvents.h" +#include "RadioProfileChip.h" #include #if defined(ESP32_PLATFORM) #include @@ -440,19 +441,19 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, bool RadioLibWrapper::validateProfile(const mesh::RadioProfileParams& p) const { if (!mesh::RadioProfiles::valid(p)) return false; if (p.protocol == mesh::RadioProfileProtocol::Meshtext && !supportsMeshtext()) return false; -#if defined(USE_SX1276) +#if MESH_RADIO_PROFILE_CHIP == 1276 return p.freq >= 137 && p.freq <= 1020 && p.sf >= 7 && p.bw <= 500; -#elif defined(USE_SX1272) +#elif MESH_RADIO_PROFILE_CHIP == 1272 return p.freq >= 860 && p.freq <= 1020 && p.sf >= 7 && p.bw >= 125 && p.bw <= 500; -#elif defined(USE_LLCC68) +#elif MESH_RADIO_PROFILE_CHIP == 68 return p.freq >= 150 && p.freq <= 960 && ((p.bw == 125 && p.sf <= 9) || (p.bw == 250 && p.sf <= 10) || (p.bw == 500 && p.sf <= 11)); -#elif defined(USE_LR2021) +#elif MESH_RADIO_PROFILE_CHIP == 2021 return (p.freq <= 1090 || p.freq >= 1900) && p.bw >= 31.25 && p.bw <= 1000; -#elif defined(USE_LR1110) +#elif MESH_RADIO_PROFILE_CHIP == 1110 return p.freq <= 960 && p.bw >= 62.5 && p.bw <= 500; -#elif defined(USE_SX1268) +#elif MESH_RADIO_PROFILE_CHIP == 1268 return p.freq >= 410 && p.freq <= 810 && p.bw <= 500; #else return p.freq <= 960 && p.bw <= 500; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 16c9a532b..27c8f9e37 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -233,7 +233,13 @@ protected: const float bw = _params_valid ? _cur_bw : static_cast(LORA_BW); return cadScanTimeoutMillis(sf, bw); } +#if MESH_ENABLE_TX_BEACONS + // A TX-only excursion supplies its own timeout without using the cached + // normal RX tuple. Chips with different CAD completion rules override it. + virtual int16_t performChannelScanWithTimeout(unsigned long timeout_ms); +#else int16_t performChannelScanWithTimeout(unsigned long timeout_ms); +#endif virtual int startReceiveMode(); virtual void stopReceiveDutyCycle(); virtual bool isPacketReady(); diff --git a/src/helpers/radiolib/RadioProfileChip.h b/src/helpers/radiolib/RadioProfileChip.h new file mode 100644 index 000000000..b570acd76 --- /dev/null +++ b/src/helpers/radiolib/RadioProfileChip.h @@ -0,0 +1,35 @@ +#pragma once + +// Variant headers can advertise more than one optional chip (Generic E22 +// defines both USE_SX1262 and USE_SX1268). Validate the concrete transceiver +// selected by the build, not whichever optional USE_* branch appears first. +#define MESH_PROFILE_CHIP_CustomSX1262 1262 +#define MESH_PROFILE_CHIP_CustomSTM32WLx 1262 +#define MESH_PROFILE_CHIP_CustomSX1268 1268 +#define MESH_PROFILE_CHIP_CustomSX1276 1276 +#define MESH_PROFILE_CHIP_CustomLLCC68 68 +#define MESH_PROFILE_CHIP_CustomLR1110 1110 +#define MESH_PROFILE_CHIP_CustomLR1121 1121 +#define MESH_PROFILE_CHIP_CustomLR2021 2021 +#define MESH_PROFILE_CHIP_(name) MESH_PROFILE_CHIP_##name +#define MESH_PROFILE_CHIP(name) MESH_PROFILE_CHIP_(name) + +#if defined(RADIO_CLASS) + #define MESH_RADIO_PROFILE_CHIP MESH_PROFILE_CHIP(RADIO_CLASS) +#elif defined(USE_SX1276) + #define MESH_RADIO_PROFILE_CHIP 1276 +#elif defined(USE_SX1272) + #define MESH_RADIO_PROFILE_CHIP 1272 +#elif defined(USE_LLCC68) + #define MESH_RADIO_PROFILE_CHIP 68 +#elif defined(USE_LR2021) + #define MESH_RADIO_PROFILE_CHIP 2021 +#elif defined(USE_LR1110) + #define MESH_RADIO_PROFILE_CHIP 1110 +#elif defined(USE_LR1121) + #define MESH_RADIO_PROFILE_CHIP 1121 +#elif defined(USE_SX1268) + #define MESH_RADIO_PROFILE_CHIP 1268 +#else + #define MESH_RADIO_PROFILE_CHIP 1262 +#endif diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 5791d675b..6d0088c60 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -1333,7 +1333,10 @@ void EnvironmentSensorManager::initBasicGPS() { gps_active = false; //Set GPS visibility off until setting is changed #endif } +#endif // ENV_INCLUDE_GPS +// These hooks also exist in no-GPS images. WisBlock slot power can still +// supply storage, Ethernet, or another peripheral without a GPS provider. bool EnvironmentSensorManager::supportsSharedPeripheralMode() const { #if defined(RAK_4631) && !defined(RAK_WISMESH_TAG) return true; @@ -1395,6 +1398,7 @@ bool EnvironmentSensorManager::isGpsPowered() const { #endif } +#if ENV_INCLUDE_GPS // gps code for rak might be moved to MicroNMEALoactionProvider // or make a new location provider ... #ifdef RAK_WISBLOCK_GPS diff --git a/test/test_beacon_file_api.py b/test/test_beacon_file_api.py new file mode 100644 index 000000000..12540d4f9 --- /dev/null +++ b/test/test_beacon_file_api.py @@ -0,0 +1,104 @@ +"""Check production beacon file helpers against the Adafruit constructor API.""" +from pathlib import Path +import os +import subprocess +import sys +import tempfile +import unittest + +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src/helpers/BeaconManager.cpp" + +HARNESS = r''' +#include +#include +#include +enum { FILE_O_READ = 0, FILE_O_WRITE = 1 }; +struct Filesystem { + bool present = false, metadata_ok = true, remove_ok = true, open_ok = true; + unsigned removals = 0, opens = 0; + uint8_t last_mode = 255; + bool remove(const char* path) { + assert(!std::strcmp(path, "/test")); ++removals; + if (!remove_ok) return false; + present = false; return true; + } +}; +#define FILESYSTEM Filesystem +struct File { + Filesystem* fs; + File() = delete; // Both nRF52 and STM32 Adafruit File require an FS. + explicit File(Filesystem& owner) : fs(&owner) {} + File(const File&) = delete; + File(File&&) = default; + File& operator=(const File&) = delete; + explicit operator bool() const { return fs && fs->open_ok; } + bool open(const char* path, uint8_t mode) { + assert(!std::strcmp(path, "/test")); ++fs->opens; fs->last_mode = mode; + return fs->open_ok; + } +}; +bool filePresence(Filesystem* fs, const char* path, bool& present) { + assert(!std::strcmp(path, "/test")); + present = fs->present; return fs->metadata_ok; +} +@METHODS@ +int main() { + Filesystem fs; + auto file = makeFile(&fs); + assert(file.fs == &fs); + assert(openRead(&fs, "/test", file) && fs.opens == 1 && fs.last_mode == FILE_O_READ); + assert(openWrite(&fs, "/test", file) && fs.opens == 2 && !fs.removals && fs.last_mode == FILE_O_WRITE); + fs.present = true; + assert(openWrite(&fs, "/test", file) && fs.opens == 3 && fs.removals == 1 && !fs.present); + fs.metadata_ok = false; + assert(!openWrite(&fs, "/test", file) && fs.opens == 3 && fs.removals == 1); + fs.metadata_ok = true; fs.present = true; fs.remove_ok = false; + assert(!openWrite(&fs, "/test", file) && fs.opens == 3 && fs.removals == 2); + fs.present = false; fs.open_ok = false; + assert(!openRead(&fs, "/test", file) && fs.opens == 4 && fs.last_mode == FILE_O_READ); + assert(!openWrite(&fs, "/test", file) && fs.opens == 5 && fs.last_mode == FILE_O_WRITE); +} +''' + + +class BeaconFileApiTest(unittest.TestCase): + def helper_source(self): + source = SOURCE.read_text(encoding="ascii") + return "\n".join(method(source, signature) for signature in + ("File makeFile(", "bool openRead(", "bool openWrite(")) + + def compile_fixture(self, methods, platform, negative=False): + with tempfile.TemporaryDirectory(prefix="beacon-file-api-") as folder: + cpp, exe = Path(folder) / "test.cpp", Path(folder) / "test" + cpp.write_text(HARNESS.replace("@METHODS@", methods), encoding="ascii") + flags = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [] + result = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-D" + platform, *flags, str(cpp), "-o", str(exe)], capture_output=True, text=True) + if negative: + self.assertNotEqual(result.returncode, 0, "default-File regression unexpectedly compiled") + self.assertIn("deleted", result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + ran = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_stm32_and_nrf52_use_bound_file_objects_and_open_modes(self): + source = self.helper_source() + for platform in ("STM32_PLATFORM", "NRF52_PLATFORM"): + with self.subTest(platform=platform): + self.compile_fixture(source, platform) + + def test_negative_control_stm32_default_file_constructor_is_rejected(self): + source = self.helper_source() + condition = "#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)" + self.assertEqual(source.count(condition), 3) + # Reproduce only the old makeFile branch. Read/write remain correct, + # proving this check specifically catches the missing constructor. + self.compile_fixture(source.replace(condition, "#if defined(NRF52_PLATFORM)", 1), "STM32_PLATFORM", negative=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_beacon_legacy_lora_protocol.py b/test/test_beacon_legacy_lora_protocol.py new file mode 100644 index 000000000..abf598adb --- /dev/null +++ b/test/test_beacon_legacy_lora_protocol.py @@ -0,0 +1,285 @@ +"""Exercise SX1276, SX1268 and LLCC68 beacon wire-protocol selection. + +Production driver/wrapper protocol methods, deep-recovery restoration, power +amplifier subclass, and profile validator run with counted RadioLib setter +boundaries. This proves control flow and cache repair, not RF interoperability. +No PlatformIO or hardware is used. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from cpp_source import body + + +ROOT = Path(__file__).resolve().parents[1] +RADIO = ROOT / "src/helpers/radiolib" + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_INVALID_OUTPUT_POWER -1 +#define RADIOLIB_ERR_WRONG_MODEM -2 +#define STATE_IDLE 0 +namespace mesh { struct MainBoard {}; } +static unsigned gain_writes = 0; +static uint8_t last_gain = 0; +void dacWrite(uint8_t, uint8_t code) { ++gain_writes; last_gain = code; } +struct PhysicalLayer { + uint8_t sync = 0x12; + bool crc = false, explicit_header = false, iq = true; + bool cached_crc = false, cached_implicit = true, cached_iq = true, lora = true; + unsigned step = 0, fail_at = 0, power_writes = 0; + int8_t drive = 0; + int16_t status() { return ++step == fail_at ? -77 : 0; } + int16_t setSyncWord(uint8_t value) { + if (!lora) return RADIOLIB_ERR_WRONG_MODEM; + const int16_t rc = status(); if (!rc) sync = value; return rc; + } + int16_t setCrc(bool value) { + cached_crc = value; + const int16_t rc = status(); if (!rc) crc = value; return rc; + } + int16_t explicitHeader() { + cached_implicit = false; + const int16_t rc = status(); if (!rc) explicit_header = true; return rc; + } + int16_t invertIQ(bool value) { + cached_iq = value; + const int16_t rc = status(); if (!rc) iq = value; return rc; + } + int16_t setPreambleLength(uint16_t) { return 0; } + int16_t setOutputPower(int8_t dbm, bool) { ++power_writes; drive = dbm; return 0; } + void resetPacketDefaults() { + sync = 0x12; crc = false; explicit_header = false; iq = true; + cached_crc = false; cached_implicit = true; cached_iq = true; step = 0; + } +}; +struct SX1276 : PhysicalLayer { int16_t setCRC(bool enabled) { return setCrc(enabled); } }; +struct SX1268 : PhysicalLayer { int16_t setCRC(uint8_t length) { return setCrc(length != 0); } }; +struct LLCC68 : PhysicalLayer { int16_t setCRC(uint8_t length) { return setCrc(length != 0); } }; +@DRIVERS@ +struct RadioLibWrapper { + PhysicalLayer* _radio; + mesh::MainBoard* _board; + bool _params_valid = true, _rx_ps_armed = true, _dbm_valid = false, _rx_boosted_gain_valid = false; + int state = 1; + float _cur_freq = 906.875f, _cur_bw = 250; + uint8_t _cur_sf = 11, _cur_cr = 5; + int8_t _cur_dbm = 17; + bool _cur_rx_boosted_gain = false; + unsigned floor_refreshes = 0; + mesh::RadioProfileProtocol _physical_protocol = mesh::RadioProfileProtocol::MeshCore; + RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board) {} + virtual bool supportsMeshtext() const { return false; } + virtual bool applyProtocol(mesh::RadioProfileProtocol p) { return p == mesh::RadioProfileProtocol::MeshCore; } + virtual int16_t applyCachedTxPower(int8_t) { return 0; } + bool setTxPower(int8_t value) { return applyCachedTxPower(value) == 0; } + bool radioDeepInit() { _radio->resetPacketDefaults(); return true; } + bool registerInterruptAction() { return true; } + bool applyParams(float, float, uint8_t, uint8_t) { return true; } + uint16_t currentPreambleLength() { return 16; } + bool applyRxBoostedGainMode(bool) { return true; } + void recalibrateNoiseFloor() { ++floor_refreshes; } + bool restoreAfterDeepInit(); + bool validateProfile(const mesh::RadioProfileParams&) const; +}; +@WRAPPERS@ +@RESTORE@ +@VALIDATE@ +@DAC@ +template +static void protocols() { + using Protocol = mesh::RadioProfileProtocol; + mesh::MainBoard board; + Chip chip; + Wrapper wrapper(chip, board); + assert(wrapper.supportsMeshtext()); + for (Protocol protocol : {Protocol::Meshtext, Protocol::MeshCore, Protocol::Meshtext}) { + chip.resetPacketDefaults(); + assert(wrapper.applyProtocol(protocol)); + assert(chip.sync == (protocol == Protocol::Meshtext ? 0x2b : 0x12)); + assert(chip.step == 4 && chip.crc && chip.explicit_header && !chip.iq); + assert(chip.cached_crc && !chip.cached_implicit && !chip.cached_iq); + } + chip.step = 0; + assert(!wrapper.applyProtocol(static_cast(255)) && chip.step == 0); + chip.lora = false; + assert(!wrapper.applyProtocol(Protocol::Meshtext) && chip.step == 0); +} +template +static void failures_and_recovery() { + using Protocol = mesh::RadioProfileProtocol; + for (unsigned fail = 1; fail <= 4; ++fail) { + mesh::MainBoard board; + Chip chip; + Wrapper wrapper(chip, board); + chip.fail_at = fail; + assert(!wrapper.applyProtocol(Protocol::Meshtext) && chip.step == fail); + // A setter may update a RadioLib cache before its hardware write fails. + // The old protocol restoration must rewrite every invariant, not skip it. + chip.fail_at = 0; chip.step = 0; + assert(wrapper.applyProtocol(Protocol::MeshCore)); + assert(chip.sync == 0x12 && chip.crc && chip.explicit_header && !chip.iq); + assert(chip.cached_crc && !chip.cached_implicit && !chip.cached_iq); + wrapper._physical_protocol = Protocol::Meshtext; + assert(wrapper.restoreAfterDeepInit()); + assert(chip.sync == 0x2b && chip.crc && chip.explicit_header && !chip.iq); + assert(!wrapper._rx_ps_armed && wrapper.state == STATE_IDLE); + chip.fail_at = fail; + assert(!wrapper.restoreAfterDeepInit()); + chip.fail_at = 0; + wrapper._physical_protocol = Protocol::MeshCore; + assert(wrapper.restoreAfterDeepInit()); + assert(chip.sync == 0x12 && chip.crc && chip.explicit_header && !chip.iq); + } +} +static void dac_inheritance() { + mesh::MainBoard board; + CustomSX1276 chip; + const DacPaLevel levels[] = {{10, 30}, {17, 50}, {30, 130}}; + DacPaSX1276Wrapper wrapper(chip, board, 4, levels, 3); + assert(wrapper.supportsMeshtext()); + assert(wrapper.beginPowerControl(17)); + assert(chip.drive == 2 && gain_writes == 1 && last_gain == 50); + const unsigned power_writes = chip.power_writes; + assert(wrapper.applyProtocol(mesh::RadioProfileProtocol::Meshtext)); + assert(chip.sync == 0x2b && chip.power_writes == power_writes && gain_writes == 1); + assert(wrapper.applyProtocol(mesh::RadioProfileProtocol::MeshCore)); + assert(chip.sync == 0x12 && chip.power_writes == power_writes && gain_writes == 1); +} +static void validation() { + mesh::MainBoard board; + CustomSX1276 chip; + CustomSX1276Wrapper wrapper(chip, board); + mesh::RadioProfileParams p; + p.freq = 906.875f; p.bw = 250; p.sf = 11; p.cr = 5; + p.preamble = 16; p.protocol = mesh::RadioProfileProtocol::Meshtext; +#if defined(USE_SX1276) + assert(wrapper.validateProfile(p)); + p.sf = 6; assert(!wrapper.validateProfile(p)); + p.sf = 9; assert(wrapper.validateProfile(p)); +#elif defined(USE_SX1268) + assert(!wrapper.validateProfile(p)); // US default is outside this chip's RF range. + p.freq = 433.875f; assert(wrapper.validateProfile(p)); + p.freq = 906.875f; assert(!wrapper.validateProfile(p)); +#elif defined(USE_LLCC68) + assert(!wrapper.validateProfile(p)); // LongFast SF11/BW250 exceeds LLCC68's limit. + p.sf = 9; assert(wrapper.validateProfile(p)); // MediumFast is supported. + p.bw = 500; p.sf = 11; assert(wrapper.validateProfile(p)); + p.sf = 12; assert(!wrapper.validateProfile(p)); +#endif +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "protocols")) { + protocols(); + protocols(); + protocols(); + } else if (!strcmp(argv[1], "recovery")) { + failures_and_recovery(); + failures_and_recovery(); + failures_and_recovery(); + } else if (!strcmp(argv[1], "dac")) dac_inheritance(); + else if (!strcmp(argv[1], "validation")) validation(); + else assert(false); + puts("PASS"); +} +''' + + +class BeaconLegacyLoRaProtocolTest(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") + drivers, wrappers, api_classes = [], [], [] + for name, parent in (("SX1276", "SX1276"), ("SX1268", "SX1268"), ("LLCC68", "LLCC68")): + driver_source = (RADIO / ("Custom" + name + ".h")).read_text(encoding="utf-8") + wrapper_source = (RADIO / ("Custom" + name + "Wrapper.h")).read_text(encoding="utf-8") + drivers.append("struct Custom" + name + ": " + parent + " {\n" + + body(driver_source, "int16_t setProfileProtocol(") + "\n};") + api_classes.append("struct Checked" + name + ": " + parent + " {\n" + + body(driver_source, "int16_t setProfileProtocol(") + "\n};") + wrappers.append("struct Custom" + name + "Wrapper: RadioLibWrapper {\n" + + "Custom" + name + "Wrapper(Custom" + name + + "& radio,mesh::MainBoard& board):RadioLibWrapper(radio,board) {}\n" + + body(wrapper_source, "bool supportsMeshtext(") + "\n" + + body(wrapper_source, "bool applyProtocol(") + "\n};") + shared = (RADIO / "RadioLibWrappers.cpp").read_text(encoding="utf-8") + dac = (RADIO / "DacPaSX1276Wrapper.h").read_text(encoding="utf-8") + source = (HARNESS.replace("@DRIVERS@", "\n".join(drivers)) + .replace("@WRAPPERS@", "\n".join(wrappers)) + .replace("@RESTORE@", body(shared, "bool RadioLibWrapper::restoreAfterDeepInit(")) + .replace("@VALIDATE@", body(shared, "bool RadioLibWrapper::validateProfile(")) + .replace("@DAC@", body(dac, "struct DacPaLevel") + ";\n" + + "#define DAC_PA_DEFAULT_DRIVE_DBM 2\n#define DAC_PA_TABLE_MAX INT8_MAX\n" + + body(dac, "class DacPaSX1276Wrapper") + ";\n")) + cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-legacy-lora-wire-") + cls.addClassCleanup(cls.temp.cleanup) + work = Path(cls.temp.name) + cpp = work / "protocol.cpp" + cpp.write_text(source, encoding="ascii") + cls.compiler = compiler + cls.api_source = "#include \n" + "\n".join(api_classes) + cls.binaries = {} + for chip in ("SX1276", "SX1268", "LLCC68"): + binary = work / (chip + (".exe" if sys.platform == "win32" else "")) + flags = ["-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-DMESH_ENABLE_TX_BEACONS=0", "-DMESH_ENABLE_MESHTEXT_WIRE=1", "-DUSE_" + chip] + if sys.platform.startswith("linux"): + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + result = subprocess.run([compiler, *flags, "-I" + str(ROOT / "src"), str(cpp), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + cls.binaries[chip] = binary + + def run_case(self, name, chip="SX1276"): + result = subprocess.run([str(self.binaries[chip]), name], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS", result.stdout) + + def test_all_three_drivers_apply_full_meshtext_and_meshcore_packet_invariants(self): + self.run_case("protocols") + + def test_each_failed_setter_repairs_caches_and_deep_recovery_restores_protocol(self): + self.run_case("recovery") + + def test_dac_pa_inherits_protocol_without_changing_pa_drive_or_gain(self): + self.run_case("dac") + + def test_chip_frequency_sf_and_bandwidth_limits_still_apply(self): + for chip in self.binaries: + with self.subTest(chip=chip): + self.run_case("validation", chip) + + def test_driver_protocol_methods_compile_against_cached_real_radiolib_api(self): + override = os.environ.get("MESHCORE_RADIOLIB_DIR") + candidates = [Path(override)] if override else sorted((ROOT / ".pio/libdeps").glob("*/RadioLib")) + radiolib = next((path for path in candidates if (path / "src/RadioLib.h").is_file()), None) + if radiolib is None: + self.skipTest("an existing RadioLib checkout is required (MESHCORE_RADIOLIB_DIR)") + result = subprocess.run( + [self.compiler, "-std=c++17", "-fsyntax-only", "-x", "c++", "-", + "-I" + str(radiolib / "src")], input=self.api_source, + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_beacon_radio.py b/test/test_beacon_radio.py index e4f6d5ecb..5ee14fcd0 100644 --- a/test/test_beacon_radio.py +++ b/test/test_beacon_radio.py @@ -377,8 +377,8 @@ class BeaconRadioTest(unittest.TestCase): (["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. + # The codec is stable across translation units, including the + # Indicator's SPI LoRa radio with expander-controlled GPIOs. (["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), diff --git a/test/test_beacon_sx1276_cad.py b/test/test_beacon_sx1276_cad.py new file mode 100644 index 000000000..2eb3b10e4 --- /dev/null +++ b/test/test_beacon_sx1276_cad.py @@ -0,0 +1,250 @@ +"""Run SX1276 CAD completion and beacon virtual dispatch against counted pins. + +Production driver, wrapper override, and beacon CAD caller are used as written. +The modem reproduces pinned RadioLib's pre-CAD_DONE FREE result, so bypassing +the SX1276 override is observably unsafe. No PlatformIO or hardware is used. +""" + +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from cpp_source import body + + +ROOT = Path(__file__).resolve().parents[1] +RADIO = ROOT / "src/helpers/radiolib" + + +HARNESS = r''' +#include +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_UNKNOWN -16 +#define RADIOLIB_CHANNEL_FREE -15 +#define RADIOLIB_PREAMBLE_DETECTED -14 +#define RADIOLIB_ERR_RX_TIMEOUT -6 +#define STATE_IDLE 0 +#define STATE_TX_WAIT 3 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define RADIOLIB_ASSERT(result) do { if ((result) != 0) return (result); } while (0) +static uint32_t now_ms = 0; +static unsigned yields = 0; +uint32_t millis() { return now_ms; } +void yield() { ++now_ms; ++yields; } +namespace mesh { +struct MainBoard { + unsigned watchdogs = 0; + void serviceWatchdog() { ++watchdogs; } +}; +} +struct Hal { + uint32_t started = 0, done_after = 0, detected_after = UINT32_MAX; + bool done() const { return done_after != UINT32_MAX && uint32_t(now_ms - started) >= done_after; } + bool digitalRead(unsigned pin) { + return pin == 0 ? done() + : detected_after != UINT32_MAX && uint32_t(now_ms - started) >= detected_after; + } + void yield() { ::yield(); } +}; +struct Module { + Hal hardware; + Hal* hal = &hardware; + unsigned getIrq() const { return 0; } + unsigned getGpio() const { return 1; } +}; +struct CustomSX1276 { + Module module; + Module* mod = &module; + int start_result = 0, result = RADIOLIB_CHANNEL_FREE, stop_result = 0, clear_result = 0; + unsigned starts = 0, reads = 0, stops = 0, clears = 0; + int16_t startChannelScan() { ++starts; mod->hardware.started = now_ms; return start_result; } + int16_t getChannelScanResult() { + ++reads; + // Pinned SX127x checks CAD_DETECTED, not whether CAD_DONE occurred yet. + return mod->hardware.done() ? result : RADIOLIB_CHANNEL_FREE; + } + int standby() { ++stops; return stop_result; } + int clearIrqFlags(uint32_t) { ++clears; return clear_result; } + @DRIVER@ +}; +struct RadioLibWrapper { + CustomSX1276* _radio; + mesh::MainBoard* _board; + bool _beacon_owned = true, _beacon_restoring = false, busy = false; + uint8_t state = STATE_IDLE; + uint32_t _beacon_cad_timeout = 100; + unsigned claimed = 0; + RadioLibWrapper(CustomSX1276& chip, mesh::MainBoard& board) : _radio(&chip), _board(&board) {} + bool isChipBusy() const { return busy; } + bool claimRadioInterrupt() { ++claimed; return false; } + @SCAN_DECLARATION@ + bool isBeaconChannelBusy(); +}; +struct CustomSX1276Wrapper : RadioLibWrapper { + CustomSX1276Wrapper(CustomSX1276& chip, mesh::MainBoard& board) : RadioLibWrapper(chip, board) {} + @OVERRIDE@ +}; +@COMMON_SCAN@ +@BEACON@ +struct Fixture { + CustomSX1276 chip; + mesh::MainBoard board; + CustomSX1276Wrapper wrapper{chip, board}; + Fixture() { now_ms = 100; yields = 0; } +}; +static void immediate() { + { Fixture f; assert(f.chip.tryScanChannel(100, f.board) == RADIOLIB_CHANNEL_FREE); + assert(!yields && f.chip.reads == 1); } + { Fixture f; f.chip.result = RADIOLIB_PREAMBLE_DETECTED; + assert(f.chip.tryScanChannel(100, f.board) == RADIOLIB_PREAMBLE_DETECTED); + assert(!yields && f.chip.reads == 1); } +} +static void delayed() { + for (int result : {RADIOLIB_CHANNEL_FREE, RADIOLIB_PREAMBLE_DETECTED}) { + Fixture f; f.chip.result = result; f.chip.mod->hardware.done_after = 3; + assert(f.chip.tryScanChannel(100, f.board) == result); + assert(yields == 3 && f.chip.reads == 1 && now_ms == 103); + } + { Fixture f; f.chip.mod->hardware.done_after = UINT32_MAX; + f.chip.mod->hardware.detected_after = 2; + assert(f.chip.tryScanChannel(100, f.board) == RADIOLIB_PREAMBLE_DETECTED); + assert(yields == 2 && f.chip.reads == 0); } + // Completion at the deadline is authoritative, even with no DIO1 level. + { Fixture f; f.chip.mod->hardware.done_after = 100; + f.chip.result = RADIOLIB_PREAMBLE_DETECTED; + assert(f.chip.tryScanChannel(100, f.board) == RADIOLIB_PREAMBLE_DETECTED); + assert(yields == 100 && f.chip.reads == 1); } +} +static void deadlines() { + { Fixture f; f.chip.mod->hardware.done_after = UINT32_MAX; + assert(f.chip.tryScanChannel(2500, f.board) == RADIOLIB_ERR_RX_TIMEOUT); + assert(yields == 2500 && f.board.watchdogs == 2 && f.chip.reads == 0); } + { Fixture f; now_ms = UINT32_MAX - 10; f.chip.mod->hardware.done_after = UINT32_MAX; + const uint32_t start = now_ms; + assert(f.chip.tryScanChannel(100, f.board) == RADIOLIB_ERR_RX_TIMEOUT); + assert(yields == 100 && uint32_t(now_ms - start) == 100); } + { Fixture f; f.chip.mod->hardware.done_after = UINT32_MAX; + assert(f.chip.tryScanChannel(0, f.board) == RADIOLIB_ERR_RX_TIMEOUT); + assert(!yields && f.chip.reads == 0); } +} +static void failures() { + { Fixture f; f.chip.start_result = -77; + assert(f.chip.tryScanChannel(100, f.board) == -77); + assert(f.chip.starts == 1 && !f.chip.reads && !yields && !f.board.watchdogs); } + { Fixture f; f.chip.mod->hardware.done_after = 4; f.chip.result = -78; + assert(f.chip.tryScanChannel(100, f.board) == -78); + assert(f.chip.reads == 1 && yields == 4); } +} +static void beacon_dispatch() { + { Fixture f; f.chip.mod->hardware.done_after = 3; f.chip.result = RADIOLIB_PREAMBLE_DETECTED; + RadioLibWrapper& base = f.wrapper; + assert(base.isBeaconChannelBusy()); + assert(yields == 3 && f.chip.reads == 1 && f.chip.stops == 1 && f.chip.clears == 1); + assert(f.wrapper.claimed == 1 && f.wrapper.state == STATE_IDLE); } + { Fixture f; f.chip.mod->hardware.done_after = 5; + assert(!f.wrapper.isBeaconChannelBusy()); + assert(yields == 5 && f.chip.reads == 1); } + { Fixture f; f.chip.mod->hardware.done_after = UINT32_MAX; f.wrapper._beacon_cad_timeout = 13; + assert(f.wrapper.isBeaconChannelBusy()); assert(yields == 13 && !f.chip.reads); } + { Fixture f; f.chip.start_result = -77; assert(f.wrapper.isBeaconChannelBusy()); + assert(!yields && f.chip.stops == 1 && f.chip.clears == 1); } + { Fixture f; f.chip.stop_result = -1; assert(f.wrapper.isBeaconChannelBusy()); } + { Fixture f; f.chip.clear_result = -1; assert(f.wrapper.isBeaconChannelBusy()); } +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "immediate")) immediate(); + else if (!strcmp(argv[1], "delayed")) delayed(); + else if (!strcmp(argv[1], "deadlines")) deadlines(); + else if (!strcmp(argv[1], "failures")) failures(); + else if (!strcmp(argv[1], "beacon")) beacon_dispatch(); + else assert(false); + puts("PASS"); +} +''' + + +class BeaconSX1276CADTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise unittest.SkipTest("a host C++ compiler is required") + driver = (RADIO / "CustomSX1276.h").read_text(encoding="utf-8") + wrapper = (RADIO / "CustomSX1276Wrapper.h").read_text(encoding="utf-8") + common = (RADIO / "RadioLibWrappers.cpp").read_text(encoding="utf-8") + header = (RADIO / "RadioLibWrappers.h").read_text(encoding="utf-8") + declaration = re.search(r"virtual int16_t performChannelScanWithTimeout\(unsigned long timeout_ms\);", header) + if declaration is None: + raise AssertionError("beacon CAD timeout helper must use virtual driver dispatch") + cls.source = (HARNESS.replace("@DRIVER@", body(driver, "int tryScanChannel(")) + .replace("@OVERRIDE@", body(wrapper, "int16_t performChannelScanWithTimeout(")) + .replace("@SCAN_DECLARATION@", declaration.group(0)) + .replace("@COMMON_SCAN@", body(common, "int16_t RadioLibWrapper::performChannelScanWithTimeout(")) + .replace("@BEACON@", body(common, "bool RadioLibWrapper::isBeaconChannelBusy("))) + cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-sx1276-cad-") + cls.addClassCleanup(cls.temp.cleanup) + cls.work = Path(cls.temp.name) + cls.binary = cls.compile_source(cls.source, "checked") + + @classmethod + def compile_source(cls, source, name): + cpp = cls.work / (name + ".cpp") + cpp.write_text("#include \n" + source, encoding="ascii") + binary = cls.work / (name + (".exe" if sys.platform == "win32" else "")) + flags = ["-std=c++17", "-Wall", "-Wextra", "-Werror"] + if sys.platform.startswith("linux"): + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + result = subprocess.run([cls.compiler, *flags, str(cpp), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + return binary + + def run_case(self, name): + result = subprocess.run([str(self.binary), name], capture_output=True, text=True, + cwd=self.work, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS", result.stdout) + + def test_immediate_cad_done_checks_detected_flag_instead_of_assuming_free(self): + self.run_case("immediate") + + def test_delayed_free_detected_early_preamble_and_done_at_deadline(self): + self.run_case("delayed") + + def test_timeout_millisecond_wrap_and_watchdog_service_are_bounded(self): + self.run_case("deadlines") + + def test_start_and_result_errors_propagate_without_false_free(self): + self.run_case("failures") + + def test_actual_beacon_cad_uses_sx1276_virtual_override_and_cleans_up(self): + self.run_case("beacon") + + def test_old_free_on_done_and_nonvirtual_dispatch_fail_negative_controls(self): + controls = ( + (self.source.replace("return getChannelScanResult();", "return(RADIOLIB_CHANNEL_FREE);", 1), "immediate"), + (self.source.replace("virtual int16_t performChannelScanWithTimeout", "int16_t performChannelScanWithTimeout", 1) + .replace("unsigned long timeout_ms) override", "unsigned long timeout_ms)", 1), "beacon"), + ) + for index, (source, case) in enumerate(controls): + with self.subTest(case=case): + self.assertNotEqual(source, self.source) + binary = self.compile_source(source, "broken-" + str(index)) + result = subprocess.run([str(binary), case], capture_output=True, text=True, + cwd=self.work, timeout=30) + self.assertNotEqual(result.returncode, 0, "unsafe CAD implementation unexpectedly passed") + self.assertIn("Assertion", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_environment_no_gps_link.py b/test/test_environment_no_gps_link.py new file mode 100644 index 000000000..8a0e10b0b --- /dev/null +++ b/test/test_environment_no_gps_link.py @@ -0,0 +1,143 @@ +"""Preserve real preprocessor guards before linking no-GPS sensor hooks. + +The former method-extraction fixtures could find definitions hidden inside a +disabled outer ENV_INCLUDE_GPS guard. Preprocess the complete production unit +first so an absent vtable definition cannot be manufactured by the fixture. +""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +from test_gps_upstream_adaptations import ARDUINO, CAYENNE, MESH +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" + +SIGNATURES = ( + "bool EnvironmentSensorManager::begin()", + "bool EnvironmentSensorManager::querySensors(", + "uint8_t EnvironmentSensorManager::getVoltageSensorChannels(", + "uint8_t EnvironmentSensorManager::queryVoltageSensors(", + "int EnvironmentSensorManager::getNumSettings()", + "const char* EnvironmentSensorManager::getSettingName(", + "const char* EnvironmentSensorManager::getSettingValue(", + "bool EnvironmentSensorManager::setSettingValue(", + "void EnvironmentSensorManager::setPowerSavingEnabled(", + "bool EnvironmentSensorManager::supportsSharedPeripheralMode()", + "void EnvironmentSensorManager::configureSharedPeripheralMode(", + "bool EnvironmentSensorManager::isGpsPowered()", + "bool EnvironmentSensorManager::gpsUsesSerialUart(", + "bool EnvironmentSensorManager::gpsSerialTransportMayConflict(", + "bool EnvironmentSensorManager::gpsSerialTransportCanYield(", + "bool EnvironmentSensorManager::setGpsSerialTransportBlocked(", +) + +GENERIC = r''' +#include +#include +#include +#include +#define TELEM_WIRE (&Wire) +#define MESH_DEBUG_PRINTLN(...) ((void)0) +void LocationProvider::sendSentence(const char*) {} +static bool ensureI2cBusReleased(TwoWire*) { return true; } +struct SensorDef { + uint32_t address; + const char* name; + uint8_t (*init)(TwoWire*, uint8_t); + void (*query)(uint8_t, uint8_t, CayenneLPP&); + bool (*query_voltage)(uint8_t, float&); +}; +static const SensorDef SENSOR_TABLE[1] = {}; +static const size_t SENSOR_TABLE_SIZE = 0; +bool EnvironmentSensorManager::i2c_probe(TwoWire&, uint8_t) { return false; } +@METHODS@ +int main() { + EnvironmentSensorManager sensors; + assert(sensors.begin()); + SensorManager& hooks = sensors; + assert(!hooks.supportsSharedPeripheralMode()); + hooks.configureSharedPeripheralMode(true); + hooks.configureSharedPeripheralMode(false); + assert(!hooks.isGpsPowered() && !hooks.isGPSDetected()); + assert(!hooks.gpsUsesSerialUart(1) && !hooks.setGpsSerialTransportBlocked(1, true)); +} +''' + + +class EnvironmentNoGpsLinkTest(unittest.TestCase): + def preprocess(self, source, defines): + # Includes are hardware boundaries, not conditional-compilation logic. + # Preserve every #if/#else/#endif and local #define in the real unit. + source = re.sub(r"^[ \t]*#[ \t]*include[^\n]*(?:\n|$)", "", source, flags=re.MULTILINE) + result = subprocess.run([os.environ.get("CXX", "g++"), "-E", "-P", "-x", "c++", "-", + "-DENV_INCLUDE_GPS=0", *defines], input=source, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout + + def test_no_gps_generic_and_wisblock_vtables_link_with_real_guards(self): + variants = ( + ("-DESP32_PLATFORM",), + ("-DNRF52_PLATFORM", "-DRAK_BOARD", "-DRAK_4631", "-DOTA_RAK_AUTO_STORE"), + ("-DNRF52_PLATFORM", "-DRAK_BOARD", "-DRAK_3401", "-DOTA_RAK_AUTO_STORE"), + ("-DNRF52_PLATFORM", "-DRAK_BOARD", "-DRAK_4631", "-DOTA_RAK_AUTO_STORE", "-DETHERNET_ENABLED=0"), + ) + original = SOURCE.read_text(encoding="ascii") + for defines in variants: + with self.subTest(defines=defines), tempfile.TemporaryDirectory(prefix="environment-no-gps-") as folder: + processed = self.preprocess(original, defines) + signatures = SIGNATURES + rak = "-DRAK_BOARD" in defines + if rak: + signatures += ("void EnvironmentSensorManager::applyWisBlockRailPower()", + "uint8_t EnvironmentSensorManager::detectWisBlockStorageHolds()") + for signature in signatures: + self.assertIn(signature, processed) + methods = "\n".join(extract_braced(processed, signature) for signature in signatures) + fixture = (ROOT / "test/fixtures/rak_peripheral_power_no_gps.cpp").read_text(encoding="ascii") if rak else GENERIC + fixture = fixture.replace("@METHODS@", methods) + work = Path(folder) + arduino = ARDUINO.replace("#include ", "#include \n#include \n#include \n#include \n#define INPUT 0") + arduino = arduino.replace( + "inline void pinMode(int,int){}\ninline void digitalWrite(int,int){}\ninline int digitalRead(int){return HIGH;}", + "struct HardwareEvent { std::string name; int a; int b; uint32_t time; };\n" + "inline std::vector hardware_events;\ninline int pin_values[64]={};\n" + "inline void pinMode(int pin,int mode){hardware_events.push_back({\"pinMode\",pin,mode,now_ms});}\n" + "inline void digitalWrite(int pin,int value){assert(pin>=0&&pin<64);pin_values[pin]=value;" + "hardware_events.push_back({\"write\",pin,value,now_ms});}\n" + "inline int digitalRead(int pin){assert(pin>=0&&pin<64);return pin_values[pin];}") + for name, content in {"Arduino.h": arduino, "Mesh.h": MESH, "CayenneLPP.h": CAYENNE, + "Wire.h": "#pragma once\nclass TwoWire {};\ninline TwoWire Wire;\n"}.items(): + (work / name).write_text(content, encoding="ascii") + cpp, exe = work / "test.cpp", work / "test" + cpp.write_text(fixture, encoding="ascii") + compiled = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-unused-variable", "-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", "-DENV_INCLUDE_GPS=0", *defines, + "-I" + str(work), "-I" + str(ROOT / "src"), str(cpp), + str(ROOT / "src/helpers/SensorManager.cpp"), "-o", str(exe)], + capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + ran = subprocess.run([str(exe), *(["no_gps"] if rak else [])], capture_output=True, text=True) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_negative_control_original_outer_gps_guard_hides_required_hooks(self): + source = SOURCE.read_text(encoding="ascii") + close = "#endif // ENV_INCLUDE_GPS\n\n// These hooks also exist in no-GPS images." + reopen = "#if ENV_INCLUDE_GPS\n// gps code for rak might be moved to MicroNMEALoactionProvider" + self.assertEqual(source.count(close), 1) + self.assertEqual(source.count(reopen), 1) + original = source.replace(close, "// These hooks also exist in no-GPS images.") + original = original.replace(reopen, "// gps code for rak might be moved to MicroNMEALoactionProvider") + processed = self.preprocess(original, ("-DESP32_PLATFORM",)) + for signature in SIGNATURES[9:12]: + self.assertNotIn(signature, processed) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_lr_meshtext_wire.py b/test/test_lr_meshtext_wire.py new file mode 100644 index 000000000..529d191c1 --- /dev/null +++ b/test/test_lr_meshtext_wire.py @@ -0,0 +1,208 @@ +"""Exercise production LR11xx/LR2021 Meshtext packet and restoration methods.""" +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] + + +COMMON = r''' +#include +#include +#include +#include +#include +#include +#define MESH_ENABLE_TX_BEACONS 1 +#define MESH_ENABLE_MESHTEXT_WIRE 1 +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE 0x12 +struct Wire { + unsigned calls = 0, fail = 0; + uint8_t sync = 0x12; + uint16_t preamble = 16; + bool crc = false, explicit_header = false, inverted = true; + int16_t next() { return ++calls == fail ? -705 : 0; } + int16_t setSyncWord(uint8_t value) { sync = value; return next(); } + int16_t setCRC(uint8_t value) { assert(value == 2); crc = value > 0; return next(); } + int16_t explicitHeader() { explicit_header = true; return next(); } + int16_t invertIQ(bool value) { inverted = value; return next(); } +}; +''' + + +class LRMeshtextWireTest(unittest.TestCase): + def run_cpp(self, code): + with tempfile.TemporaryDirectory(prefix="meshcore-lr-meshtext-") as folder: + source, binary = Path(folder) / "test.cpp", Path(folder) / "test" + source.write_text(code, encoding="ascii") + result = subprocess.run( + ["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "src"), + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + str(source), "-o", str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.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) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout + + def test_actual_three_lr_families_protocol_errors_and_restore(self): + for family in ("LR1110", "LR1121", "LR2021"): + with self.subTest(family=family): + driver = (ROOT / f"src/helpers/radiolib/Custom{family}.h").read_text() + wrapper = (ROOT / f"src/helpers/radiolib/Custom{family}Wrapper.h").read_text() + protocol = method(driver, "int16_t setProfileProtocol(") + apply = method(wrapper, "bool applyProtocol(").replace(" override", "") + support = method(wrapper, "bool supportsMeshtext()").replace(" override", "") + code = COMMON + r''' +struct @FAMILY@ : Wire {}; +struct Custom@FAMILY@ : @FAMILY@ { + LR1110ProfileSwitchState _profileSwitch{true}; + bool _profilePacketDirty = true; + @PROTOCOL@ +}; +struct Wrapper { + Custom@FAMILY@ chip; + Custom@FAMILY@* _radio = &chip; + @SUPPORT@ + @APPLY@ +}; +int main() { + using Protocol = mesh::RadioProfileProtocol; + Wrapper invalid; + assert(!invalid.applyProtocol(static_cast(255)) && invalid.chip.calls == 0); + unsigned scenarios = 1; + for (Protocol protocol : {Protocol::MeshCore, Protocol::Meshtext}) { + Wrapper wrapper; + assert(wrapper.supportsMeshtext()); + wrapper.chip._profileSwitch.rxValid = wrapper.chip._profileSwitch.active = true; + wrapper.chip._profileSwitch.modulationValid = wrapper.chip._profileSwitch.standbyReady = true; + wrapper.chip._profileSwitch.open = true; + assert(wrapper.applyProtocol(protocol)); + assert(wrapper.chip.sync == (protocol == Protocol::Meshtext ? 0x2b : 0x12)); + assert(wrapper.chip.crc && wrapper.chip.explicit_header && !wrapper.chip.inverted); + assert(wrapper.chip.preamble == 16 && wrapper.chip.calls == 4); + @FAST_SUCCESS@ + wrapper.chip.preamble = 32; + assert(wrapper.applyProtocol(Protocol::MeshCore)); + assert(wrapper.chip.sync == 0x12 && wrapper.chip.crc && wrapper.chip.explicit_header); + assert(!wrapper.chip.inverted && wrapper.chip.preamble == 32); + ++scenarios; + } + for (unsigned failure = 1; failure <= 4; ++failure) { + Wrapper wrapper; + wrapper.chip._profileSwitch.rxValid = wrapper.chip._profileSwitch.active = true; + wrapper.chip._profileSwitch.modulationValid = wrapper.chip._profileSwitch.standbyReady = true; + wrapper.chip._profileSwitch.open = true; + wrapper.chip.fail = failure; + assert(!wrapper.applyProtocol(Protocol::Meshtext)); + assert(wrapper.chip.calls == failure); // no later setters after an error + @FAST_FAILURE@ + wrapper.chip.fail = 0; + assert(wrapper.applyProtocol(Protocol::MeshCore)); + assert(wrapper.chip.sync == 0x12 && wrapper.chip.crc && wrapper.chip.explicit_header); + assert(!wrapper.chip.inverted); + ++scenarios; + } + std::printf("PASS: @FAMILY@ %u actual protocol cases\n", scenarios); +} +''' + fast = "assert(!wrapper.chip._profileSwitch.rxValid && !wrapper.chip._profileSwitch.modulationValid);\n" + fast += "assert(!wrapper.chip._profileSwitch.canResumeFast() && !wrapper.chip._profilePacketDirty);" + code = (code.replace("@FAMILY@", family).replace("@PROTOCOL@", protocol) + .replace("@APPLY@", apply).replace("@SUPPORT@", support) + .replace("@FAST_SUCCESS@", fast if family == "LR1110" else "") + .replace("@FAST_FAILURE@", fast if family == "LR1110" else "")) + self.assertIn("7 actual protocol cases", self.run_cpp(code)) + + def test_lr2021_transient_side_detectors_and_physical_power_band(self): + source = (ROOT / "src/helpers/radiolib/CustomLR2021Wrapper.h").read_text() + build = method(source, "static void buildSideDetectorConfig(") + side = method(source, "bool applySideDetectorConfig(") + power = method(source, "int16_t applyCachedTxPower(").replace(" override", "") + code = COMMON + r''' +#define USE_LR2021 1 +#include +#include +struct LR2021LoRaSideDetector_t { + uint8_t sf = 0; + bool ldro = false, invertIQ = true; + uint8_t syncWord = 0; +}; +struct CustomLR2021 { + unsigned calls = 0; + size_t count = 0; + bool fail = false; + float frequency = 906; + int8_t power = 0; + LR2021LoRaSideDetector_t values[3]; + int16_t setSideDetector(const LR2021LoRaSideDetector_t* config, size_t size) { + ++calls; count = size; + if (size) for (size_t i = 0; i < size; ++i) values[i] = config[i]; + return fail ? -705 : 0; + } + float getFreqMHz() const { return frequency; } + int16_t setOutputPower(int8_t value) { power = value; return 0; } +}; +struct Wrapper { + CustomLR2021 chip; + CustomLR2021* _radio = &chip; + mesh::RadioProfiles _profiles; + bool beacon_owned = false, _beacon_restoring = false; + uint8_t _sideDetSFs[3] = {8, 9, 10}; + size_t _numSideDet = 3; + bool isBeaconRadioOwned() const { return beacon_owned; } + @BUILD@ + @SIDE@ + @POWER@ +}; +int main() { + Wrapper wrapper; + // Valid normal RX set: SF7, 125 kHz with SF8/9/10 side detectors. + assert(wrapper.applySideDetectorConfig(7, 125) && wrapper.chip.count == 3); + // Same saved set is illegal at LongFast/SF11; disable, not reject or mutate. + wrapper.beacon_owned = true; + assert(wrapper.applySideDetectorConfig(11, 250) && wrapper.chip.count == 0); + assert(wrapper._numSideDet == 3 && wrapper._sideDetSFs[0] == 8); + // Cleanup repairs normal RX before relinquishing exclusive ownership. + wrapper._beacon_restoring = true; + assert(wrapper.applySideDetectorConfig(7, 125) && wrapper.chip.count == 3); + for (size_t i = 0; i < 3; ++i) { + assert(wrapper.chip.values[i].sf == 8 + i); + assert(!wrapper.chip.values[i].invertIQ && wrapper.chip.values[i].syncWord == 0x12); + } + wrapper._beacon_restoring = false; + wrapper.chip.fail = true; + assert(!wrapper.applySideDetectorConfig(11, 250)); + wrapper.chip.fail = false; + wrapper._profiles.secondary.mode = mesh::RadioProfileMode::RxTx; + wrapper._beacon_restoring = true; + assert(wrapper.applySideDetectorConfig(7, 125) && wrapper.chip.count == 0); + // An off-channel TX keeps the wrapper's normal RX cache. Clamp using the + // driver's actual band, matching the PA chosen by its setOutputPower(). + wrapper.chip.frequency = 2440; + assert(wrapper.applyCachedTxPower(22) == 0 && wrapper.chip.power == 12); + wrapper.chip.frequency = 906; + assert(wrapper.applyCachedTxPower(22) == 0 && wrapper.chip.power == 22); + std::puts("PASS: LR2021 independent side detectors and physical power band"); +} +''' + code = code.replace("@BUILD@", build).replace("@SIDE@", side).replace("@POWER@", power) + self.assertIn("PASS:", self.run_cpp(code)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshtext_config.py b/test/test_meshtext_config.py index 838b0c473..afa2acb9a 100644 --- a/test/test_meshtext_config.py +++ b/test/test_meshtext_config.py @@ -164,7 +164,7 @@ int main() { '''.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"], True), (["NRF52_PLATFORM", "USE_SX1262", "SENSECAP_INDICATOR_LORA", "MESH_ENABLE_MESHTEXT_CHAT=1"], True), (["NRF52_PLATFORM", "USE_SX1262", "MESH_ENABLE_MESHTEXT_WIRE=0"], False), diff --git a/test/test_meshtext_modem_profiles.py b/test/test_meshtext_modem_profiles.py new file mode 100644 index 000000000..832a48131 --- /dev/null +++ b/test/test_meshtext_modem_profiles.py @@ -0,0 +1,112 @@ +"""Exercise real preset generation against each modem's production validator. + +This is a host test, not evidence of RF operation or antenna band support. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] + + +class MeshtextModemProfilesTest(unittest.TestCase): + def test_real_presets_respect_chip_limits_and_capability(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("a host C++ compiler is required") + required = ["AESCommon.cpp", "AES128.cpp", "AES256.cpp", "BlockCipher.cpp", "Crypto.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 / item).is_file() for item in required)), None) + if not crypto: + self.skipTest("an existing rweather/Crypto dependency is required (MESHCORE_CRYPTO_DIR)") + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + validator = method(source, "bool RadioLibWrapper::validateProfile(") + harness = r''' +#include +#include +#include +#include +struct RadioLibWrapper { + bool supported = true; + bool supportsMeshtext() const { return supported; } + bool validateProfile(const mesh::RadioProfileParams&) const; +}; +@VALIDATOR@ +int main() { + using namespace mesh; + static_assert(MESH_RADIO_PROFILE_CHIP == EXPECTED_PROFILE_CHIP, "selected concrete modem"); + RadioLibWrapper radio; + unsigned accepted = 0, rejected = 0; + for (unsigned region = unsigned(mt::Region::US); region <= unsigned(mt::Region::ANZ); ++region) { + for (unsigned preset = 0; preset <= unsigned(mt::Preset::LongSlow); ++preset) { + RadioProfileParams p; + if (!mt::modemConfig(mt::Region(region), mt::Preset(preset), p)) continue; + assert(p.protocol == RadioProfileProtocol::Meshtext); + bool expected = true; +#if EXPECTED_PROFILE_CHIP == 1268 + expected = region == unsigned(mt::Region::EU433); +#elif EXPECTED_PROFILE_CHIP == 68 + expected = (p.bw == 125 && p.sf <= 9) || (p.bw == 250 && p.sf <= 10) + || (p.bw == 500 && p.sf <= 11); +#endif + assert(radio.validateProfile(p) == expected); + if (expected) ++accepted; else ++rejected; + radio.supported = false; + assert(!radio.validateProfile(p)); + p.protocol = RadioProfileProtocol::MeshCore; + assert(radio.validateProfile(p) == expected); + radio.supported = true; + p.protocol = RadioProfileProtocol(99); + assert(!radio.validateProfile(p)); + } + } + assert(accepted > 0); +#if EXPECTED_PROFILE_CHIP == 1268 || EXPECTED_PROFILE_CHIP == 68 + assert(rejected > 0); +#else + assert(rejected == 0); +#endif + printf("PASS: %u compatible and %u incompatible presets\n", accepted, rejected); +} +'''.replace("@VALIDATOR@", validator) + with tempfile.TemporaryDirectory(prefix="meshcore-mt-modems-") as folder: + cpp, binary = Path(folder) / "test.cpp", Path(folder) / "test" + cpp.write_text(harness) + chips = [(chip, []) for chip in + ("SX1262", "SX1268", "LLCC68", "SX1276", "LR1110", "LR1121", "LR2021", "STM32WLX")] + # Real E22 variants expose both optional chip macros. The chosen + # radio class must still accept the SX1262's US/ANZ presets. + chips += [("SX1262", ["-DRADIO_CLASS=CustomSX1262", "-DUSE_SX1268"]), + ("SX1268", ["-DRADIO_CLASS=CustomSX1268", "-DUSE_SX1262"]), + ("LR1110", ["-DRADIO_CLASS=CustomLR1110", "-DUSE_SX1268"])] + for chip, extra in chips: + with self.subTest(chip=chip, extra=extra): + expected = {"SX1262": 1262, "SX1268": 1268, "LLCC68": 68, + "SX1276": 1276, "LR1110": 1110, "LR1121": 1121, + "LR2021": 2021, "STM32WLX": 1262}[chip] + flags = ["-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-DMESH_ENABLE_MESHTEXT_WIRE=1", "-DUSE_" + chip, + "-DEXPECTED_PROFILE_CHIP=" + str(expected), *extra] + if sys.platform.startswith("linux"): + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + command = [compiler, *flags, "-I", str(ROOT / "src"), "-I", str(crypto), str(cpp), + str(ROOT / "src/helpers/MeshtextChat.cpp"), + *[str(crypto / item) for item in required], "-o", str(binary)] + result = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS:", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshtext_transport.py b/test/test_meshtext_transport.py index e921af4e3..e75417244 100644 --- a/test/test_meshtext_transport.py +++ b/test/test_meshtext_transport.py @@ -342,6 +342,8 @@ int main() { for (unsigned fail=1;fail<=4;++fail) { RadioLibWrapper w;w.chip.fail_at=fail;assert(!w.applyProtocol(Protocol::Meshtext)); } + RadioLibWrapper invalid; + assert(!invalid.applyProtocol(static_cast(99)) && invalid.chip.step==0); RadioLibWrapper w;assert(w.restoreAfterDeepInit()); assert(w.chip.sync==0x2b && w.chip.crc && w.chip.explicit_header && !w.chip.iq); assert(!w._rx_ps_armed && w.state==STATE_IDLE); diff --git a/test/test_stm32wl_meshtext_protocol.py b/test/test_stm32wl_meshtext_protocol.py new file mode 100644 index 000000000..b9ef245e5 --- /dev/null +++ b/test/test_stm32wl_meshtext_protocol.py @@ -0,0 +1,129 @@ +"""Check STM32WLx foreign-protocol setup and restoration without hardware.""" +from pathlib import Path +import os +import subprocess +import sys +import tempfile +import unittest + +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + + +class STM32WLMeshtextProtocolTest(unittest.TestCase): + def test_full_protocol_setup_errors_restore_and_owner_guards(self): + driver = (ROOT / "src/helpers/radiolib/CustomSTM32WLx.h").read_text(encoding="ascii") + wrapper = (ROOT / "src/helpers/radiolib/CustomSTM32WLxWrapper.h").read_text(encoding="ascii") + setup = method(driver, "int16_t setProfileProtocol(") + apply = method(wrapper, "bool applyProtocol(").replace(" override", "") + coding = method(wrapper, "bool setCodingRate(").replace(" override", "") + supports = method(wrapper, "bool supportsMeshtext()").replace(" override", "") + source = r''' +#include +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +#define MESH_ENABLE_TX_BEACONS 1 +namespace mesh { enum class RadioProfileProtocol : uint8_t { MeshCore, Meshtext }; } +struct STM32WLx { + std::string trace; + char fail = 0; + uint8_t sync = 0xff, crc = 0, header = 1, iq = 1; + unsigned coding_calls = 0; + int16_t call(char op) { trace += op; return fail == op ? -101 : 0; } + int16_t setSyncWord(uint8_t value) { + const int16_t status = call('Y'); if (!status) sync = value; return status; + } + int16_t setCRC(uint8_t value) { + assert(value == 1); + const int16_t status = call('C'); if (!status) crc = value; return status; + } + int16_t explicitHeader() { + const int16_t status = call('E'); if (!status) header = 0; return status; + } + int16_t invertIQ(bool enabled) { + assert(!enabled); + const int16_t status = call('I'); if (!status) iq = 0; return status; + } + int16_t setCodingRate(uint8_t cr) { assert(cr == 5); ++coding_calls; return call('R'); } +}; +struct CustomSTM32WLx : STM32WLx { @SETUP@ }; +struct Wrapper { + CustomSTM32WLx* _radio; + bool owned = false, carrier = false; + bool isBeaconRadioOwned() const { return owned; } + bool isCarrierWaveActive() const { return carrier; } + @SUPPORTS@ + @APPLY@ + @CODING@ +}; +int main() { + using Protocol = mesh::RadioProfileProtocol; + for (uint8_t sync : {uint8_t(0x12), uint8_t(0x2b)}) { + CustomSTM32WLx radio; + assert(radio.setProfileProtocol(sync) == 0 && radio.trace == "YCEI"); + assert(radio.sync == sync && radio.crc == 1 && radio.header == 0 && radio.iq == 0); + radio.trace.clear(); + assert(radio.setProfileProtocol(0x12) == 0 && radio.trace == "YCEI"); + assert(radio.sync == 0x12 && radio.crc == 1 && radio.header == 0 && radio.iq == 0); + } + for (char failed : std::string("YCEI")) { + CustomSTM32WLx radio; radio.fail = failed; + assert(radio.setProfileProtocol(0x2b) == -101); + assert(radio.trace == std::string("YCEI").substr(0, std::string("YCEI").find(failed) + 1)); + // Ordinary restoration must repair every field after a partial failure. + radio.fail = 0; radio.trace.clear(); + assert(radio.setProfileProtocol(0x12) == 0 && radio.trace == "YCEI"); + assert(radio.sync == 0x12 && radio.crc == 1 && radio.header == 0 && radio.iq == 0); + } + CustomSTM32WLx radio; Wrapper wrapper{&radio}; + assert(wrapper.supportsMeshtext()); + assert(wrapper.applyProtocol(Protocol::Meshtext) && radio.sync == 0x2b); + radio.trace.clear(); + assert(wrapper.applyProtocol(Protocol::MeshCore) && radio.sync == 0x12 && radio.trace == "YCEI"); + radio.trace.clear(); + assert(!wrapper.applyProtocol(static_cast(255)) && radio.trace.empty()); + for (char failed : std::string("YCEI")) { + radio.fail = failed; + assert(!wrapper.applyProtocol(Protocol::Meshtext)); + radio.fail = 0; assert(wrapper.applyProtocol(Protocol::MeshCore)); + } + wrapper.owned = true; + assert(!wrapper.setCodingRate(5) && !radio.coding_calls); + wrapper.owned = false; wrapper.carrier = true; + assert(!wrapper.setCodingRate(5) && !radio.coding_calls); + wrapper.carrier = false; + assert(wrapper.setCodingRate(5) && radio.coding_calls == 1); + radio.fail = 'R'; assert(!wrapper.setCodingRate(5) && radio.coding_calls == 2); +} +''' + source = source.replace("@SETUP@", setup).replace("@APPLY@", apply).replace("@CODING@", coding).replace("@SUPPORTS@", supports) + with tempfile.TemporaryDirectory(prefix="meshcore-stm32wl-protocol-") as work: + cpp, exe = Path(work) / "test.cpp", Path(work) / "test" + cpp.write_text(source, encoding="ascii") + flags = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [] + built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", *flags, + str(cpp), "-o", str(exe)], capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_stm32wl_inherits_the_pinned_sx126x_packet_api(self): + pinned = ROOT / ".pio/libdeps/wio-e5-mini_companion_radio_usb/RadioLib/src/modules/SX126x" + if not pinned.exists(): + self.skipTest("pinned STM32WLx dependency is not cached") + stm32 = (pinned / "STM32WLx.h").read_text() + sx126x = (pinned / "SX126x.h").read_text() + self.assertIn("class STM32WLx : public SX1262", stm32) + for signature in ("setSyncWord(", "setCRC(", "explicitHeader(", "invertIQ("): + self.assertNotIn(signature, stm32) + self.assertIn(signature, sx126x) + driver = (ROOT / "src/helpers/radiolib/CustomSTM32WLx.h").read_text(encoding="ascii") + setup = method(driver, "int16_t setProfileProtocol(") + self.assertNotIn("_profileSwitch", setup) + + +if __name__ == "__main__": + unittest.main()