From a6e8dec68969131d03cba686ecb2a1663c07cdfa Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Sat, 26 Sep 2026 20:39:36 +0200 Subject: [PATCH 001/136] Fix Heltec Mesh Solar companion build: undef NONE macro from meshsolar logger --- variants/heltec_mesh_solar/MeshSolarBoard.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/heltec_mesh_solar/MeshSolarBoard.h b/variants/heltec_mesh_solar/MeshSolarBoard.h index 81633625597..6fa2742d2cc 100644 --- a/variants/heltec_mesh_solar/MeshSolarBoard.h +++ b/variants/heltec_mesh_solar/MeshSolarBoard.h @@ -6,6 +6,8 @@ #ifdef HELTEC_MESH_SOLAR #include "meshSolarApp.h" +// meshsolar's logger.h defines NONE as a macro, which clashes with InterfaceType::NONE +#undef NONE #endif // LoRa radio module pins for Heltec T114 From 7cbf73ce7781379b6bebdb1aeb94930cd5a61080 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 17:33:53 +0200 Subject: [PATCH 002/136] refactor(prefs): move server NodePrefs into its own header and pin it with host tests What changes - NodePrefs of the server roles (repeater, room server, sensor) moves verbatim from helpers/CommonCLI.h into the new helpers/CommonNodePrefs.h, together with the ADVERT_LOC_* and LOOP_DETECT_* constants. CommonCLI.h includes the new header, so every existing #include keeps working unchanged (roles, bridges, UI, downstream forks). - New characterization tests for both NodePrefs classes (server roles and companion radio). They pin down today's behaviour: * the exact /prefs.json text written for a fully populated NodePrefs, * loading that text restores every persisted field, * every radio CLI get/set (CommonRadioPrefs::handleCommand): reply text, the field it writes, the dirty flag, and the TX power / RX gain push to the radio driver, * FEM gain access through the "custom" key/value store used by boards. The expected values were captured from the unchanged code. - Native test env: also builds CommonRadioPrefs.cpp and TxtDataHelpers.cpp. New test mocks: target.h (a recording stand-in for the global radio_driver), ltoa() and constrain() in Arduino.h, and PrefsStreams.h (stream helpers that were local to test_companion_node_prefs). Why - CommonCLI.h pulls in Mesh.h, IdentityStore.h, ClientACL.h and RegionMap.h, so the server NodePrefs could not be compiled in the native env and had no tests at all. The companion NodePrefs test only contained a disabled case. - The next change merges the radio and GPS settings that are duplicated between the two NodePrefs classes. These tests are the proof that it keeps the on-flash format and the CLI behaviour byte for byte. No functional change: the moved block is identical to the original (diff of the extracted ranges is empty), and all native suites pass. --- platformio.ini | 2 + src/helpers/CommonCLI.h | 228 +---------------- src/helpers/CommonNodePrefs.h | 232 ++++++++++++++++++ test/mocks/Arduino.h | 23 ++ test/mocks/PrefsStreams.h | 60 +++++ test/mocks/RadioPrefsChecks.h | 190 ++++++++++++++ test/mocks/target.h | 20 ++ .../test_common_node_prefs.cpp | 113 +++++++++ .../test_companion_node_prefs.cpp | 129 ++++++---- 9 files changed, 727 insertions(+), 270 deletions(-) create mode 100644 src/helpers/CommonNodePrefs.h create mode 100644 test/mocks/PrefsStreams.h create mode 100644 test/mocks/RadioPrefsChecks.h create mode 100644 test/mocks/target.h create mode 100644 test/test_common_node_prefs/test_common_node_prefs.cpp diff --git a/platformio.ini b/platformio.ini index f296f838213..164a33bd048 100644 --- a/platformio.ini +++ b/platformio.ini @@ -174,6 +174,8 @@ build_src_filter = +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/TxtDataHelpers.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2759b0c4f0e..f8d9bc04e73 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -8,238 +8,12 @@ #include #include #include +#include #if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) #define WITH_BRIDGE #endif -#define ADVERT_LOC_NONE 0 -#define ADVERT_LOC_SHARE 1 -#define ADVERT_LOC_PREFS 2 - -#define LOOP_DETECT_OFF 0 -#define LOOP_DETECT_MINIMAL 1 -#define LOOP_DETECT_MODERATE 2 -#define LOOP_DETECT_STRICT 3 - -class NodePrefs : public ConfigSerializer { -public: - // in-memory backing data - float airtime_factor = 0; - char node_name[32]; - double node_lat = 0, node_lon = 0; - char password[16]; - float freq = 0; - int8_t tx_power_dbm = 0; - uint8_t disable_fwd = 0; - uint8_t advert_interval = 0; // minutes / 2 - uint8_t flood_advert_interval = 0; // hours - float rx_delay_base = 0; - float tx_delay_factor = 0; - char guest_password[16]; - float direct_tx_delay_factor = 0; - uint32_t guard; - uint8_t sf = 0; - uint8_t cr = 0; - uint8_t allow_read_only = 0; - uint8_t multi_acks = 0; - float bw = 0; - uint8_t flood_max = 0; - uint8_t flood_max_unscoped = 0; - uint8_t flood_max_advert = 0; - uint8_t interference_threshold = 0; - uint8_t agc_reset_interval = 0; // secs / 4 - // Bridge settings - uint8_t bridge_enabled = 0; // boolean - uint16_t bridge_delay = 0; // milliseconds (default 500 ms) - uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logTx) - uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200) - uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only) - char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) - // Power setting - uint8_t powersaving_enabled = 0; // boolean - // Gps settings - uint8_t gps_enabled = 0; - uint32_t gps_interval = 0; // in seconds - uint8_t advert_loc_policy = 0; - uint32_t discovery_mod_timestamp = 0; - float adc_multiplier = 0; - char owner_info[120]; - uint8_t rx_boosted_gain = 0; // power settings - uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting - uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain setting - uint8_t path_hash_mode = 0; // which path mode to use when sending - uint8_t loop_detect = 0; - uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) - uint8_t extra_sf[4]; - -private: - class RadioPrefs : public CommonRadioPrefs { - NodePrefs* _parent; - protected: - void structure() override { - def("freq", _parent->freq); - def("bw", _parent->bw); - def("sf", _parent->sf); - def("cr", _parent->cr); - def("cad", _parent->cad_enabled); - def("int_thr", _parent->interference_threshold); - def("rxgain", _parent->rx_boosted_gain); - def("fem_rxgain", _parent->radio_fem_rxgain); - def("fem_txgain", _parent->radio_fem_txgain); - def("tx", _parent->tx_power_dbm); - def("af", _parent->airtime_factor); - def("rxdelay", _parent->rx_delay_base); - def("f_txdelay", _parent->tx_delay_factor); - def("d_txdelay", _parent->direct_tx_delay_factor); - def("agc_int", _parent->agc_reset_interval); - def("hash_mode", _parent->path_hash_mode); - def("multi_ack", _parent->multi_acks); - } - public: - RadioPrefs(NodePrefs* parent) : _parent(parent) { } - // CommonRadioPrefs interface - float getFreq() const override { return _parent->freq; } - void setFreq(float f) override { _parent->freq = f; markDirty(); } - float getBandwidth() const override { return _parent->bw; } - void setBandwidth(float bw) override { _parent->bw = bw; markDirty(); } - uint8_t getSpreadFactor() const override { return _parent->sf; } - void setSpreadFactor(uint8_t sf) override { _parent->sf = sf; markDirty(); } - uint8_t getCodingRate() const override { return _parent->cr; } - void setCodingRate(uint8_t cr) override { _parent->cr = cr; markDirty(); } - float getAirtimeFactor() const override { return _parent->airtime_factor; } - void setAirtimeFactor(float af) override { _parent->airtime_factor = af; markDirty(); } - bool isCadEnabled() const override { return _parent->cad_enabled; } - void setCadEnabled(bool en) override { _parent->cad_enabled = en; markDirty(); } - uint8_t getIntThresh() const override { return _parent->interference_threshold; } - void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } - uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } - void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } - uint8_t getTxPower() const override { return _parent->tx_power_dbm; } - void setTxPower(uint8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } - float getRxDelay() const override { return _parent->rx_delay_base; } - void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } - uint16_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } - void setAgcResetInt(uint16_t secs) override { _parent->agc_reset_interval = secs / 4; markDirty(); } - uint8_t getHashMode() const override { return _parent->path_hash_mode; } - void setHashMode(uint8_t m) override { _parent->path_hash_mode = m; markDirty(); } - uint8_t getMultiAcks() const override { return _parent->multi_acks; } - void setMultiAcks(uint8_t m) override { _parent->multi_acks = m; markDirty(); } - float getFloodTxDelay() const override { return _parent->tx_delay_factor; } - void setFloodTxDelay(float d) override { _parent->tx_delay_factor = d; markDirty(); } - float getDirectTxDelay() const override { return _parent->direct_tx_delay_factor; } - void setDirectTxDelay(float d) override { _parent->direct_tx_delay_factor = d; markDirty(); } - uint8_t getFEMRxGain() const override { return _parent->radio_fem_rxgain; } - void setFEMRxGain(uint8_t g) override { _parent->radio_fem_rxgain = g; markDirty(); } - uint8_t getFEMTxGain() const override { return _parent->radio_fem_txgain; } - void setFEMTxGain(uint8_t g) override { _parent->radio_fem_txgain = g; markDirty(); } - }; - RadioPrefs radio; - - class BridgePrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("en", _parent->bridge_enabled); // boolean - def("delay", _parent->bridge_delay); // milliseconds (default 500 ms) - def("src", _parent->bridge_pkt_src); // 0 = logTx, 1 = logRx (default logTx) - def("baud", _parent->bridge_baud); // 9600, 19200, 38400, 57600, 115200 (default 115200) - def("ch", _parent->bridge_channel); // 1-14 (ESP-NOW only) - def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret)); // for XOR encryption of bridge packets (ESP-NOW only) - } - public: - BridgePrefs(NodePrefs* parent) : _parent(parent) { } - }; - BridgePrefs bridge; - - class GPSPrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("en", _parent->gps_enabled); // boolean - def("int", _parent->gps_interval); // interval in seconds - def("adv_loc", _parent->advert_loc_policy); - } - public: - GPSPrefs(NodePrefs* parent) : _parent(parent) { } - }; - GPSPrefs gps; - - class PowerPrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("adc_mult", _parent->adc_multiplier); - def("pwr_sav_en", _parent->powersaving_enabled); - } - public: - PowerPrefs(NodePrefs* parent) : _parent(parent) { } - }; - PowerPrefs power; - - class RepeatPrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("disable", _parent->disable_fwd); - def("f_max", _parent->flood_max); - def("f_max_uns", _parent->flood_max_unscoped); - def("f_max_adv", _parent->flood_max_advert); - def("loop", _parent->loop_detect); - } - public: - RepeatPrefs(NodePrefs* parent) : _parent(parent) { } - }; - RepeatPrefs repeat; - - class RoomPrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("rd_only", _parent->allow_read_only); - } - public: - RoomPrefs(NodePrefs* parent) : _parent(parent) { } - }; - RoomPrefs room; - - DynamicConfigSerializer custom; - -protected: - void structure() override { - def("name", node_name, sizeof(node_name)); - def("pass", password, sizeof(password)); - def("guest", guest_password, sizeof(guest_password)); - def("owner", owner_info, sizeof(owner_info)); - def("adv_int", advert_interval); - def("f_adv_int", flood_advert_interval); - def("lat", node_lat); - def("lon", node_lon); - def("radio", radio); - def("bridge", bridge); - def("gps", gps); - def("repeat", repeat); - def("room", room); - def("power", power); - def("custom", custom); - } - -public: - NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this), custom(&radio) { - node_name[0] = 0; - password[0] = 0; - guest_password[0] = 0; - bridge_secret[0] = 0; - owner_info[0] = 0; - } - - CommonRadioPrefs* getRadioPrefs() { return &radio; } - KeyValueStore* getCustom() { return &custom; } - - bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } - void clearDirty() override { ConfigSerializer::clearDirty(); radio.clearDirty(); custom.clearDirty(); } -}; - class CommonCLICallbacks { public: virtual void savePrefs() = 0; diff --git a/src/helpers/CommonNodePrefs.h b/src/helpers/CommonNodePrefs.h new file mode 100644 index 00000000000..9b16c9d64d7 --- /dev/null +++ b/src/helpers/CommonNodePrefs.h @@ -0,0 +1,232 @@ +#pragma once + +#include +#include +#include + +#define ADVERT_LOC_NONE 0 +#define ADVERT_LOC_SHARE 1 +#define ADVERT_LOC_PREFS 2 + +#define LOOP_DETECT_OFF 0 +#define LOOP_DETECT_MINIMAL 1 +#define LOOP_DETECT_MODERATE 2 +#define LOOP_DETECT_STRICT 3 + +class NodePrefs : public ConfigSerializer { +public: + // in-memory backing data + float airtime_factor = 0; + char node_name[32]; + double node_lat = 0, node_lon = 0; + char password[16]; + float freq = 0; + int8_t tx_power_dbm = 0; + uint8_t disable_fwd = 0; + uint8_t advert_interval = 0; // minutes / 2 + uint8_t flood_advert_interval = 0; // hours + float rx_delay_base = 0; + float tx_delay_factor = 0; + char guest_password[16]; + float direct_tx_delay_factor = 0; + uint32_t guard; + uint8_t sf = 0; + uint8_t cr = 0; + uint8_t allow_read_only = 0; + uint8_t multi_acks = 0; + float bw = 0; + uint8_t flood_max = 0; + uint8_t flood_max_unscoped = 0; + uint8_t flood_max_advert = 0; + uint8_t interference_threshold = 0; + uint8_t agc_reset_interval = 0; // secs / 4 + // Bridge settings + uint8_t bridge_enabled = 0; // boolean + uint16_t bridge_delay = 0; // milliseconds (default 500 ms) + uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logTx) + uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200) + uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only) + char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) + // Power setting + uint8_t powersaving_enabled = 0; // boolean + // Gps settings + uint8_t gps_enabled = 0; + uint32_t gps_interval = 0; // in seconds + uint8_t advert_loc_policy = 0; + uint32_t discovery_mod_timestamp = 0; + float adc_multiplier = 0; + char owner_info[120]; + uint8_t rx_boosted_gain = 0; // power settings + uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting + uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain setting + uint8_t path_hash_mode = 0; // which path mode to use when sending + uint8_t loop_detect = 0; + uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) + uint8_t extra_sf[4]; + +private: + class RadioPrefs : public CommonRadioPrefs { + NodePrefs* _parent; + protected: + void structure() override { + def("freq", _parent->freq); + def("bw", _parent->bw); + def("sf", _parent->sf); + def("cr", _parent->cr); + def("cad", _parent->cad_enabled); + def("int_thr", _parent->interference_threshold); + def("rxgain", _parent->rx_boosted_gain); + def("fem_rxgain", _parent->radio_fem_rxgain); + def("fem_txgain", _parent->radio_fem_txgain); + def("tx", _parent->tx_power_dbm); + def("af", _parent->airtime_factor); + def("rxdelay", _parent->rx_delay_base); + def("f_txdelay", _parent->tx_delay_factor); + def("d_txdelay", _parent->direct_tx_delay_factor); + def("agc_int", _parent->agc_reset_interval); + def("hash_mode", _parent->path_hash_mode); + def("multi_ack", _parent->multi_acks); + } + public: + RadioPrefs(NodePrefs* parent) : _parent(parent) { } + // CommonRadioPrefs interface + float getFreq() const override { return _parent->freq; } + void setFreq(float f) override { _parent->freq = f; markDirty(); } + float getBandwidth() const override { return _parent->bw; } + void setBandwidth(float bw) override { _parent->bw = bw; markDirty(); } + uint8_t getSpreadFactor() const override { return _parent->sf; } + void setSpreadFactor(uint8_t sf) override { _parent->sf = sf; markDirty(); } + uint8_t getCodingRate() const override { return _parent->cr; } + void setCodingRate(uint8_t cr) override { _parent->cr = cr; markDirty(); } + float getAirtimeFactor() const override { return _parent->airtime_factor; } + void setAirtimeFactor(float af) override { _parent->airtime_factor = af; markDirty(); } + bool isCadEnabled() const override { return _parent->cad_enabled; } + void setCadEnabled(bool en) override { _parent->cad_enabled = en; markDirty(); } + uint8_t getIntThresh() const override { return _parent->interference_threshold; } + void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } + uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } + void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } + uint8_t getTxPower() const override { return _parent->tx_power_dbm; } + void setTxPower(uint8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } + float getRxDelay() const override { return _parent->rx_delay_base; } + void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } + uint16_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } + void setAgcResetInt(uint16_t secs) override { _parent->agc_reset_interval = secs / 4; markDirty(); } + uint8_t getHashMode() const override { return _parent->path_hash_mode; } + void setHashMode(uint8_t m) override { _parent->path_hash_mode = m; markDirty(); } + uint8_t getMultiAcks() const override { return _parent->multi_acks; } + void setMultiAcks(uint8_t m) override { _parent->multi_acks = m; markDirty(); } + float getFloodTxDelay() const override { return _parent->tx_delay_factor; } + void setFloodTxDelay(float d) override { _parent->tx_delay_factor = d; markDirty(); } + float getDirectTxDelay() const override { return _parent->direct_tx_delay_factor; } + void setDirectTxDelay(float d) override { _parent->direct_tx_delay_factor = d; markDirty(); } + uint8_t getFEMRxGain() const override { return _parent->radio_fem_rxgain; } + void setFEMRxGain(uint8_t g) override { _parent->radio_fem_rxgain = g; markDirty(); } + uint8_t getFEMTxGain() const override { return _parent->radio_fem_txgain; } + void setFEMTxGain(uint8_t g) override { _parent->radio_fem_txgain = g; markDirty(); } + }; + RadioPrefs radio; + + class BridgePrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("en", _parent->bridge_enabled); // boolean + def("delay", _parent->bridge_delay); // milliseconds (default 500 ms) + def("src", _parent->bridge_pkt_src); // 0 = logTx, 1 = logRx (default logTx) + def("baud", _parent->bridge_baud); // 9600, 19200, 38400, 57600, 115200 (default 115200) + def("ch", _parent->bridge_channel); // 1-14 (ESP-NOW only) + def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret)); // for XOR encryption of bridge packets (ESP-NOW only) + } + public: + BridgePrefs(NodePrefs* parent) : _parent(parent) { } + }; + BridgePrefs bridge; + + class GPSPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("en", _parent->gps_enabled); // boolean + def("int", _parent->gps_interval); // interval in seconds + def("adv_loc", _parent->advert_loc_policy); + } + public: + GPSPrefs(NodePrefs* parent) : _parent(parent) { } + }; + GPSPrefs gps; + + class PowerPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("adc_mult", _parent->adc_multiplier); + def("pwr_sav_en", _parent->powersaving_enabled); + } + public: + PowerPrefs(NodePrefs* parent) : _parent(parent) { } + }; + PowerPrefs power; + + class RepeatPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("disable", _parent->disable_fwd); + def("f_max", _parent->flood_max); + def("f_max_uns", _parent->flood_max_unscoped); + def("f_max_adv", _parent->flood_max_advert); + def("loop", _parent->loop_detect); + } + public: + RepeatPrefs(NodePrefs* parent) : _parent(parent) { } + }; + RepeatPrefs repeat; + + class RoomPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("rd_only", _parent->allow_read_only); + } + public: + RoomPrefs(NodePrefs* parent) : _parent(parent) { } + }; + RoomPrefs room; + + DynamicConfigSerializer custom; + +protected: + void structure() override { + def("name", node_name, sizeof(node_name)); + def("pass", password, sizeof(password)); + def("guest", guest_password, sizeof(guest_password)); + def("owner", owner_info, sizeof(owner_info)); + def("adv_int", advert_interval); + def("f_adv_int", flood_advert_interval); + def("lat", node_lat); + def("lon", node_lon); + def("radio", radio); + def("bridge", bridge); + def("gps", gps); + def("repeat", repeat); + def("room", room); + def("power", power); + def("custom", custom); + } + +public: + NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this), custom(&radio) { + node_name[0] = 0; + password[0] = 0; + guest_password[0] = 0; + bridge_secret[0] = 0; + owner_info[0] = 0; + } + + CommonRadioPrefs* getRadioPrefs() { return &radio; } + KeyValueStore* getCustom() { return &custom; } + + bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } + void clearDirty() override { ConfigSerializer::clearDirty(); radio.clearDirty(); custom.clearDirty(); } +}; diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe4145..773ac6dab0e 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include "Stream.h" @@ -15,3 +17,24 @@ inline uint32_t millis() { inline void delay(uint32_t ms) { g_mock_millis += ms; } + +#ifndef constrain +#define constrain(amt, low, high) ((amt) < (low) ? (low) : ((amt) > (high) ? (high) : (amt))) +#endif + +// Arduino core extension (not in the C standard library). +inline char* ltoa(long value, char* buf, int base) { + char tmp[sizeof(long) * 8 + 1]; + char* t = tmp; + unsigned long v = (value < 0 && base == 10) ? -(unsigned long)value : (unsigned long)value; + do { + int digit = v % base; + *t++ = (char)(digit < 10 ? '0' + digit : 'a' + digit - 10); + v /= base; + } while (v); + char* p = buf; + if (value < 0 && base == 10) *p++ = '-'; + while (t > tmp) *p++ = *--t; + *p = 0; + return buf; +} diff --git a/test/mocks/PrefsStreams.h b/test/mocks/PrefsStreams.h new file mode 100644 index 00000000000..d0bcd50924e --- /dev/null +++ b/test/mocks/PrefsStreams.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +#include "Stream.h" + +// Feeds a fixed text to ConfigSerializer::loadSerial(). +class ReplayStream : public Stream { + const char* _text; + int _pos = 0; + int _len; + +public: + explicit ReplayStream(const char* text) : _text(text), _len(strlen(text)) { } + + int available() override { return _len - _pos; } + int read() override { return _pos < _len ? _text[_pos++] : -1; } + int peek() override { return _pos < _len ? _text[_pos] : -1; } +}; + +// Captures what ConfigSerializer::saveSerial() writes. Numbers are printed the +// way Arduino's Print does it (floats with a fixed number of decimals). +class CaptureStream : public Stream { + std::string _text; + + size_t emit(long long value) { + char text[24]; + int length = snprintf(text, sizeof(text), "%lld", value); + return write(reinterpret_cast(text), length); + } + +public: + size_t write(uint8_t value) override { + _text.push_back(static_cast(value)); + return 1; + } + + size_t write(const uint8_t* buffer, size_t size) override { + _text.append(reinterpret_cast(buffer), size); + return size; + } + + size_t print(unsigned char value, int = DEC) override { return emit(value); } + size_t print(int value, int = DEC) override { return emit(value); } + size_t print(unsigned int value, int = DEC) override { return emit(value); } + size_t print(long value, int = DEC) override { return emit(value); } + size_t print(unsigned long value, int = DEC) override { return emit(value); } + size_t print(long long value, int = DEC) override { return emit(value); } + size_t print(unsigned long long value, int = DEC) override { return emit(value); } + size_t print(double value, int digits = 2) override { + char text[48]; + int length = snprintf(text, sizeof(text), "%.*f", digits, value); + return write(reinterpret_cast(text), length); + } + using Print::print; + + const std::string& text() const { return _text; } +}; diff --git a/test/mocks/RadioPrefsChecks.h b/test/mocks/RadioPrefsChecks.h new file mode 100644 index 00000000000..fa990ca6a74 --- /dev/null +++ b/test/mocks/RadioPrefsChecks.h @@ -0,0 +1,190 @@ +#pragma once + +// Shared expectations for the radio and GPS settings of a NodePrefs class. +// The server roles (repeater, room server, sensor) and the companion radio each +// have their own NodePrefs; both must expose the radio/GPS settings the same +// way, so the same checks run against both. + +#include +#include +#include + +#include "target.h" + +// Distinct, non-default values for every radio and GPS field. +template +void fillRadioAndGps(P& p) { + p.freq = 869.618f; + p.bw = 62.5f; + p.sf = 8; + p.cr = 6; + p.cad_enabled = 1; + p.interference_threshold = 14; + p.rx_boosted_gain = 1; + p.radio_fem_rxgain = 1; + p.radio_fem_txgain = 2; + p.tx_power_dbm = 21; + p.airtime_factor = 1.5f; + p.rx_delay_base = 3.25f; + p.tx_delay_factor = 0.75f; + p.direct_tx_delay_factor = 0.25f; + p.agc_reset_interval = 5; + p.path_hash_mode = 2; + p.multi_acks = 1; + + p.gps_enabled = 1; + p.gps_interval = 900; + p.advert_loc_policy = 1; +} + +template +void expectRadioAndGpsEqual(const P& expected, const P& actual) { + EXPECT_FLOAT_EQ(expected.freq, actual.freq); + EXPECT_FLOAT_EQ(expected.bw, actual.bw); + EXPECT_EQ(expected.sf, actual.sf); + EXPECT_EQ(expected.cr, actual.cr); + EXPECT_EQ(expected.cad_enabled, actual.cad_enabled); + EXPECT_EQ(expected.interference_threshold, actual.interference_threshold); + EXPECT_EQ(expected.rx_boosted_gain, actual.rx_boosted_gain); + EXPECT_EQ(expected.radio_fem_rxgain, actual.radio_fem_rxgain); + EXPECT_EQ(expected.radio_fem_txgain, actual.radio_fem_txgain); + EXPECT_EQ(expected.tx_power_dbm, actual.tx_power_dbm); + EXPECT_FLOAT_EQ(expected.airtime_factor, actual.airtime_factor); + EXPECT_FLOAT_EQ(expected.rx_delay_base, actual.rx_delay_base); + EXPECT_FLOAT_EQ(expected.tx_delay_factor, actual.tx_delay_factor); + EXPECT_FLOAT_EQ(expected.direct_tx_delay_factor, actual.direct_tx_delay_factor); + EXPECT_EQ(expected.agc_reset_interval, actual.agc_reset_interval); + EXPECT_EQ(expected.path_hash_mode, actual.path_hash_mode); + EXPECT_EQ(expected.multi_acks, actual.multi_acks); + + EXPECT_EQ(expected.gps_enabled, actual.gps_enabled); + EXPECT_EQ(expected.gps_interval, actual.gps_interval); + EXPECT_EQ(expected.advert_loc_policy, actual.advert_loc_policy); +} + +// Runs one CLI command through the prefs' radio handler and returns the reply. +template +std::string radioCli(P& p, const char* command) { + char reply[160]; + reply[0] = 0; + EXPECT_TRUE(p.getRadioPrefs()->handleCommand(command, 0, reply)) << command; + return reply; +} + +// The radio CLI must read and write the node's own fields, mark the prefs +// dirty, and push TX power / RX gain to the radio driver. +template +void expectRadioCliDrivesNodeFields(P& p) { + radio_driver.reset(); + fillRadioAndGps(p); + p.clearDirty(); + + EXPECT_EQ("> 869.6179809,62.5,8,6", radioCli(p, "get radio")); + EXPECT_EQ("> 869.6179809", radioCli(p, "get freq")); + EXPECT_EQ("> 1.5", radioCli(p, "get af")); + EXPECT_EQ("> 40.0%", radioCli(p, "get dutycycle")); + EXPECT_EQ("> 14", radioCli(p, "get int.thresh")); + EXPECT_EQ("> on", radioCli(p, "get cad")); + EXPECT_EQ("> on", radioCli(p, "get radio.rxgain")); + EXPECT_EQ("> 21", radioCli(p, "get tx")); + EXPECT_EQ("> 3.25", radioCli(p, "get rxdelay")); + EXPECT_EQ("> 20", radioCli(p, "get agc.reset.interval")); + EXPECT_EQ("> 2", radioCli(p, "get path.hash.mode")); + EXPECT_EQ("> 1", radioCli(p, "get multi.acks")); + EXPECT_EQ("> 0.75", radioCli(p, "get txdelay")); + EXPECT_EQ("> 0.25", radioCli(p, "get direct.txdelay")); + EXPECT_FALSE(p.isDirty()); + + // A command that changes a setting must mark the prefs dirty (otherwise the + // change is never saved); a rejected command must leave them clean. + auto accepted = [&p](const char* command) { + p.clearDirty(); + std::string reply = radioCli(p, command); + EXPECT_TRUE(p.isDirty()) << command; + return reply; + }; + auto rejected = [&p](const char* command) { + p.clearDirty(); + std::string reply = radioCli(p, command); + EXPECT_FALSE(p.isDirty()) << command; + return reply; + }; + + EXPECT_EQ("OK - reboot to apply", accepted("set radio 433.5,125,9,5")); + EXPECT_FLOAT_EQ(433.5f, p.freq); + EXPECT_FLOAT_EQ(125.0f, p.bw); + EXPECT_EQ(9, p.sf); + EXPECT_EQ(5, p.cr); + + EXPECT_EQ("Error, invalid radio params", rejected("set radio 433.5,6,9,5")); + EXPECT_FLOAT_EQ(125.0f, p.bw); + + EXPECT_EQ("OK", accepted("set af 2")); + EXPECT_FLOAT_EQ(2.0f, p.airtime_factor); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af 10")); + EXPECT_FLOAT_EQ(2.0f, p.airtime_factor); + EXPECT_EQ("OK - 25.0%", accepted("set dutycycle 25")); + EXPECT_FLOAT_EQ(3.0f, p.airtime_factor); + EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle 0")); + + EXPECT_EQ("OK", accepted("set int.thresh 9")); + EXPECT_EQ(9, p.interference_threshold); + EXPECT_EQ("OK", accepted("set cad off")); + EXPECT_EQ(0, p.cad_enabled); + + EXPECT_EQ("OK", accepted("set radio.rxgain off")); + EXPECT_EQ(0, p.rx_boosted_gain); + EXPECT_EQ(1, radio_driver.rx_boost_calls); + EXPECT_FALSE(radio_driver.rx_boosted); + // The setting is stored even when the radio cannot apply it. + radio_driver.rx_boost_supported = false; + EXPECT_EQ("Error: unsupported", accepted("set radio.rxgain on")); + EXPECT_EQ(1, p.rx_boosted_gain); + + EXPECT_EQ("OK", accepted("set tx 17")); + EXPECT_EQ(17, p.tx_power_dbm); + EXPECT_EQ(1, radio_driver.tx_power_calls); + EXPECT_EQ(17, radio_driver.tx_power); + + EXPECT_EQ("OK", accepted("set rxdelay 4.5")); + EXPECT_FLOAT_EQ(4.5f, p.rx_delay_base); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay 21")); + + EXPECT_EQ("OK - interval rounded to 36", accepted("set agc.reset.interval 38")); + EXPECT_EQ(9, p.agc_reset_interval); + + EXPECT_EQ("OK", accepted("set path.hash.mode 1")); + EXPECT_EQ(1, p.path_hash_mode); + EXPECT_EQ("Error, must be 0,1, or 2", rejected("set path.hash.mode 3")); + + EXPECT_EQ("OK", accepted("set multi.acks 0")); + EXPECT_EQ(0, p.multi_acks); + + EXPECT_EQ("OK", accepted("set txdelay 1.5")); + EXPECT_FLOAT_EQ(1.5f, p.tx_delay_factor); + EXPECT_EQ("Error, must be 0-2", rejected("set txdelay 3")); + EXPECT_EQ("OK", accepted("set direct.txdelay 0.5")); + EXPECT_FLOAT_EQ(0.5f, p.direct_tx_delay_factor); + EXPECT_EQ("Error, must be 0-2", rejected("set direct.txdelay 3")); + + char reply[16]; + EXPECT_FALSE(p.getRadioPrefs()->handleCommand("get name", 0, reply)); +} + +// Board code reaches the FEM gains only through the "custom" key/value store, +// which falls back to the radio settings. +template +void expectCustomStoreReachesFemGains(P& p) { + fillRadioAndGps(p); + p.clearDirty(); + + char value[8]; + ASSERT_TRUE(p.getCustom()->getByKey("fem_rxgain", value, sizeof(value))); + EXPECT_STREQ("1", value); + ASSERT_TRUE(p.getCustom()->getByKey("fem_txgain", value, sizeof(value))); + EXPECT_STREQ("2", value); + + ASSERT_TRUE(p.getCustom()->setByKey("fem_rxgain", "0")); + EXPECT_EQ(0, p.radio_fem_rxgain); + EXPECT_TRUE(p.isDirty()); +} diff --git a/test/mocks/target.h b/test/mocks/target.h new file mode 100644 index 00000000000..22ed9638dee --- /dev/null +++ b/test/mocks/target.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +// Stand-in for a variant's target.h, so code that reaches the global +// radio_driver (e.g. CommonRadioPrefs.cpp) builds and runs in the native env. +struct MockRadioDriver { + int8_t tx_power = 0; + int tx_power_calls = 0; + bool rx_boosted = false; + int rx_boost_calls = 0; + bool rx_boost_supported = true; + + void setTxPower(int8_t dbm) { tx_power = dbm; tx_power_calls++; } + bool setRxBoostedGainMode(bool en) { rx_boosted = en; rx_boost_calls++; return rx_boost_supported; } + + void reset() { *this = MockRadioDriver(); } +}; + +inline MockRadioDriver radio_driver; diff --git a/test/test_common_node_prefs/test_common_node_prefs.cpp b/test/test_common_node_prefs/test_common_node_prefs.cpp new file mode 100644 index 00000000000..9e7d57f2ecf --- /dev/null +++ b/test/test_common_node_prefs/test_common_node_prefs.cpp @@ -0,0 +1,113 @@ +// Characterization tests for the NodePrefs of the server roles (repeater, +// room server, sensor). They pin down what /prefs.json contains and what the +// radio CLI does today, so a refactor of the prefs classes can prove it keeps +// the on-flash format and the CLI behaviour unchanged. + +#include + +#include +#include + +#include + +#include "PrefsStreams.h" +#include "RadioPrefsChecks.h" + +namespace { + +void fillAll(NodePrefs& p) { + fillRadioAndGps(p); + strcpy(p.node_name, "Test Repeater"); + strcpy(p.password, "admin-pw"); + strcpy(p.guest_password, "guest-pw"); + strcpy(p.owner_info, "owner \"quoted\" info"); + p.advert_interval = 60; + p.flood_advert_interval = 12; + p.node_lat = 53.551086; + p.node_lon = 9.993682; + p.bridge_enabled = 1; + p.bridge_delay = 500; + p.bridge_pkt_src = 1; + p.bridge_baud = 115200; + p.bridge_channel = 6; + strcpy(p.bridge_secret, "bridge-secret"); + p.disable_fwd = 1; + p.flood_max = 32; + p.flood_max_unscoped = 16; + p.flood_max_advert = 8; + p.loop_detect = 2; + p.allow_read_only = 1; + p.adc_multiplier = 1.25f; + p.powersaving_enabled = 1; +} + +// /prefs.json as written by v1.18.0 for the values of fillAll(). +const char* kPrefsJson = + "{name:\"Test Repeater\",pass:\"admin-pw\",guest:\"guest-pw\"," + "owner:\"owner \\\"quoted\\\" info\",adv_int:60,f_adv_int:12,lat:53.551086," + "lon:9.993682,radio:{freq:869.6180,bw:62.5000,sf:8,cr:6,cad:1," + "int_thr:14,rxgain:1,fem_rxgain:1,fem_txgain:2,tx:21,af:1.5000," + "rxdelay:3.2500,f_txdelay:0.7500,d_txdelay:0.2500,agc_int:5,hash_mode:2," + "multi_ack:1},bridge:{en:1,delay:500,src:1,baud:115200,ch:6,secret:\"bridge-secret\"}," + "gps:{en:1,int:900,adv_loc:1},repeat:{disable:1,f_max:32,f_max_uns:16," + "f_max_adv:8,loop:2},room:{rd_only:1},power:{adc_mult:1.2500,pwr_sav_en:1}," + "custom:{}}"; + +} // namespace + +TEST(CommonNodePrefs, WritesTheKnownPrefsJson) { + NodePrefs prefs; + fillAll(prefs); + + CaptureStream out; + ASSERT_TRUE(prefs.saveSerial(out)); + EXPECT_EQ(kPrefsJson, out.text()); +} + +TEST(CommonNodePrefs, ReadsEveryPersistedFieldBack) { + NodePrefs expected; + fillAll(expected); + + NodePrefs loaded; + ReplayStream in(kPrefsJson); + ASSERT_TRUE(loaded.loadSerial(in)); + + expectRadioAndGpsEqual(expected, loaded); + EXPECT_STREQ(expected.node_name, loaded.node_name); + EXPECT_STREQ(expected.password, loaded.password); + EXPECT_STREQ(expected.guest_password, loaded.guest_password); + EXPECT_STREQ(expected.owner_info, loaded.owner_info); + EXPECT_EQ(expected.advert_interval, loaded.advert_interval); + EXPECT_EQ(expected.flood_advert_interval, loaded.flood_advert_interval); + EXPECT_DOUBLE_EQ(expected.node_lat, loaded.node_lat); + EXPECT_DOUBLE_EQ(expected.node_lon, loaded.node_lon); + EXPECT_EQ(expected.bridge_enabled, loaded.bridge_enabled); + EXPECT_EQ(expected.bridge_delay, loaded.bridge_delay); + EXPECT_EQ(expected.bridge_pkt_src, loaded.bridge_pkt_src); + EXPECT_EQ(expected.bridge_baud, loaded.bridge_baud); + EXPECT_EQ(expected.bridge_channel, loaded.bridge_channel); + EXPECT_STREQ(expected.bridge_secret, loaded.bridge_secret); + EXPECT_EQ(expected.disable_fwd, loaded.disable_fwd); + EXPECT_EQ(expected.flood_max, loaded.flood_max); + EXPECT_EQ(expected.flood_max_unscoped, loaded.flood_max_unscoped); + EXPECT_EQ(expected.flood_max_advert, loaded.flood_max_advert); + EXPECT_EQ(expected.loop_detect, loaded.loop_detect); + EXPECT_EQ(expected.allow_read_only, loaded.allow_read_only); + EXPECT_FLOAT_EQ(expected.adc_multiplier, loaded.adc_multiplier); + EXPECT_EQ(expected.powersaving_enabled, loaded.powersaving_enabled); +} + +TEST(CommonNodePrefs, RadioCliReadsAndWritesTheNodeFields) { + NodePrefs prefs; + expectRadioCliDrivesNodeFields(prefs); +} + +TEST(CommonNodePrefs, CustomStoreReachesTheFemGains) { + NodePrefs prefs; + expectCustomStoreReachesFemGains(prefs); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index 433c9710601..24d7a4a069f 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -6,49 +6,92 @@ #include "../../examples/companion_radio/NodePrefs.h" -class ReplayStream : public Stream { - const char* _text; - int _pos = 0; - int _len; - -public: - explicit ReplayStream(const char* text) : _text(text), _len(strlen(text)) { } - - int available() override { return _len - _pos; } - int read() override { return _pos < _len ? _text[_pos++] : -1; } - int peek() override { return _pos < _len ? _text[_pos] : -1; } -}; - -class CaptureStream : public Stream { - std::string _text; - - size_t emit(long long value) { - char text[24]; - int length = snprintf(text, sizeof(text), "%lld", value); - return write(reinterpret_cast(text), length); - } - -public: - size_t write(uint8_t value) override { - _text.push_back(static_cast(value)); - return 1; - } - - size_t write(const uint8_t* buffer, size_t size) override { - _text.append(reinterpret_cast(buffer), size); - return size; - } - - size_t print(unsigned char value, int = DEC) override { return emit(value); } - size_t print(int value, int = DEC) override { return emit(value); } - size_t print(unsigned int value, int = DEC) override { return emit(value); } - size_t print(long value, int = DEC) override { return emit(value); } - size_t print(unsigned long value, int = DEC) override { return emit(value); } - size_t print(long long value, int = DEC) override { return emit(value); } - size_t print(unsigned long long value, int = DEC) override { return emit(value); } - - const std::string& text() const { return _text; } -}; +#include "PrefsStreams.h" +#include "RadioPrefsChecks.h" + +// Characterization tests for the companion radio's NodePrefs. They pin down +// what /prefs.json contains and what the radio CLI does today, so a +// refactor of the prefs classes can prove it keeps both unchanged. + +namespace { + +void fillAll(NodePrefs& p) { + fillRadioAndGps(p); + strcpy(p.node_name, "Test Companion"); + p.node_lat = 53.551086; + p.node_lon = 9.993682; + p.setRepeatEn(true); + p.autoadd_max_hops = 3; + strcpy(p.default_scope_name, "de-hh"); + for (int i = 0; i < 16; i++) p.default_scope_key[i] = (uint8_t)(0xA0 + i); + p.ble_pin = 123456; + p.buzzer_quiet = 1; + p.vibe_quiet = 1; + p.autoadd_config = 0x05; + p.manual_add_contacts = 1; + p.telemetry_mode_base = 2; + p.telemetry_mode_loc = 1; + p.telemetry_mode_env = 2; + p.tz_offset = 1.5f; +} + +// Prefs file as written by v1.18.0 for the values of fillAll(). +const char* kPrefsJson = + "{name:\"Test Companion\",lat:53.551086,lon:9.993682,radio:{freq:869.6180," + "bw:62.5000,sf:8,cr:6,cad:1,int_thr:14,rxgain:1,fem_rxgain:1,fem_txgain:2," + "tx:21,af:1.5000,rxdelay:3.2500,f_txdelay:0.7500,d_txdelay:0.2500," + "agc_int:5,hash_mode:2,multi_ack:1},gps:{en:1,int:900,adv_loc:1}," + "repeat:{disable:0},comp:{auto_max:3,defs_nm:\"de-hh\",defs_key:\"A0A1A2A3A4A5A6A7A8A9AAABACADAEAF\"," + "pin:123456,buzz_q:1,vibe_q:1,auto_add:5,man_add:1,tel_base:2," + "tel_loc:1,tel_env:2,tz_offset:1.5000},custom:{}}"; + +} // namespace + +TEST(CompanionNodePrefs, WritesTheKnownPrefsJson) { + NodePrefs prefs; + fillAll(prefs); + + CaptureStream out; + ASSERT_TRUE(prefs.saveSerial(out)); + EXPECT_EQ(kPrefsJson, out.text()); +} + +TEST(CompanionNodePrefs, ReadsEveryPersistedFieldBack) { + NodePrefs expected; + fillAll(expected); + + NodePrefs loaded; + ReplayStream in(kPrefsJson); + ASSERT_TRUE(loaded.loadSerial(in)); + + expectRadioAndGpsEqual(expected, loaded); + EXPECT_STREQ(expected.node_name, loaded.node_name); + EXPECT_DOUBLE_EQ(expected.node_lat, loaded.node_lat); + EXPECT_DOUBLE_EQ(expected.node_lon, loaded.node_lon); + EXPECT_EQ(expected.isRepeatEn(), loaded.isRepeatEn()); + EXPECT_EQ(expected.autoadd_max_hops, loaded.autoadd_max_hops); + EXPECT_STREQ(expected.default_scope_name, loaded.default_scope_name); + EXPECT_EQ(0, memcmp(expected.default_scope_key, loaded.default_scope_key, sizeof(loaded.default_scope_key))); + EXPECT_EQ(expected.ble_pin, loaded.ble_pin); + EXPECT_EQ(expected.buzzer_quiet, loaded.buzzer_quiet); + EXPECT_EQ(expected.vibe_quiet, loaded.vibe_quiet); + EXPECT_EQ(expected.autoadd_config, loaded.autoadd_config); + EXPECT_EQ(expected.manual_add_contacts, loaded.manual_add_contacts); + EXPECT_EQ(expected.telemetry_mode_base, loaded.telemetry_mode_base); + EXPECT_EQ(expected.telemetry_mode_loc, loaded.telemetry_mode_loc); + EXPECT_EQ(expected.telemetry_mode_env, loaded.telemetry_mode_env); + EXPECT_FLOAT_EQ(expected.tz_offset, loaded.tz_offset); +} + +TEST(CompanionNodePrefs, RadioCliReadsAndWritesTheNodeFields) { + NodePrefs prefs; + expectRadioCliDrivesNodeFields(prefs); +} + +TEST(CompanionNodePrefs, CustomStoreReachesTheFemGains) { + NodePrefs prefs; + expectCustomStoreReachesFemGains(prefs); +} #if 0 // Re-enable test once we can SET fem_ values in companion From a59561ffae5a2e9a785eff68cfa5ad945df4a4fb Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 17:38:17 +0200 Subject: [PATCH 003/136] refactor(prefs): share the radio and GPS settings between server and companion NodePrefs What changes - New RadioPrefsFields (helpers/CommonRadioPrefs.h) and GPSPrefsFields (helpers/CommonGPSPrefs.h) hold the 17 radio and 3 GPS settings once, with their types, defaults and unit comments. Their fields are ordered by size so the two structs carry no internal padding. - Both NodePrefs classes (server roles: helpers/CommonNodePrefs.h, companion radio: examples/companion_radio/NodePrefs.h) inherit these two structs instead of declaring the same 20 fields each. Inheritance (rather than a member) keeps every existing access such as prefs.freq or _prefs.sf compiling unchanged in roles, UI, bridges, boards and downstream forks. - CommonRadioPrefs becomes the one concrete binding of RadioPrefsFields: it owns the "radio" JSON structure, the radio CLI commands and the key/value access boards use. The two private RadioPrefs classes that implemented its 34 pure virtual getters/setters by forwarding to the parent are removed. - New CommonGPSPrefs replaces the two identical private GPSPrefs classes. Why - The two NodePrefs classes carried verbatim copies of the radio and GPS settings and of their JSON/CLI adapters (the companion copy was marked "COPIED from CommonCLI (for now)"). Copies drift: in this release cycle the companion copy needed three separate fixes for defects the server copy did not have (6dad3d5a: setSpreadFactor() did not assign; e3d0f9fc and 5d82ed35: getters returned constants, setters were no-ops). - Adding one radio setting meant editing both copies plus the 34-method interface. Now it is one field and one def() line, shared by every role. Compatibility - /prefs.json is unchanged byte for byte: same keys, same order, same number formatting (proven by the characterization tests added in the previous commit, which pass unchanged before and after this change). - CLI replies and side effects are unchanged (same tests). - Legacy binary prefs loaders read field by field, so the changed in-memory layout of NodePrefs does not affect them; no code copies NodePrefs as a raw block. - Source compatible for all in-tree users. CommonRadioPrefs (introduced in this release cycle) is now a concrete class constructed from a RadioPrefsFields; code that subclassed it to implement the pure virtuals would have to drop that subclass. --- examples/companion_radio/NodePrefs.h | 108 +++------------------ src/helpers/CommonGPSPrefs.h | 33 +++++++ src/helpers/CommonNodePrefs.h | 111 +++------------------ src/helpers/CommonRadioPrefs.h | 138 ++++++++++++++++++++------- 4 files changed, 160 insertions(+), 230 deletions(-) create mode 100644 src/helpers/CommonGPSPrefs.h diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 3cceed56668..634ef67feb5 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -2,6 +2,7 @@ #include // For uint8_t, uint32_t #include #include +#include #include #define TELEM_MODE_DENY 0 @@ -11,40 +12,27 @@ #define ADVERT_LOC_NONE 0 #define ADVERT_LOC_SHARE 1 -class NodePrefs : public ConfigSerializer { // persisted to file +/** + * \brief Persisted settings of the companion radio, stored as /prefs.json. + * + * The radio and GPS settings are inherited from RadioPrefsFields and GPSPrefsFields, + * which the server roles' NodePrefs (helpers/CommonNodePrefs.h) shares, so both read + * and write them the same way. Every other setting is companion specific. + */ +class NodePrefs : public ConfigSerializer, public RadioPrefsFields, public GPSPrefsFields { // persisted to file public: - float airtime_factor = 0; char node_name[32]; double node_lat = 0, node_lon = 0; - float freq = 0; - uint8_t sf = 0; - uint8_t cr = 0; - uint8_t multi_acks = 0; uint8_t manual_add_contacts = 0; - float bw = 0; - int8_t tx_power_dbm = 0; uint8_t telemetry_mode_base = 0; uint8_t telemetry_mode_loc = 0; uint8_t telemetry_mode_env = 0; - float rx_delay_base = 0; - float tx_delay_factor = 0; - float direct_tx_delay_factor = 0; uint32_t ble_pin = 0; - uint8_t advert_loc_policy = 0; uint8_t buzzer_quiet = 0; uint8_t vibe_quiet = 0; - uint8_t gps_enabled = 0; // GPS enabled flag (0=disabled, 1=enabled) - uint32_t gps_interval = 0; // GPS read interval in seconds uint8_t autoadd_config = 0; // bitmask for auto-add contacts config - uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode (0=power saving, 1=boosted) - uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) - uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain (low by default) uint8_t _client_repeat = 0; // DEPRECATED -> use repeat.disable_fwd - uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t autoadd_max_hops = 0; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) - uint8_t cad_enabled = 0; - uint8_t interference_threshold = 0; - uint8_t agc_reset_interval = 0; // secs / 4 char default_scope_name[31]; uint8_t default_scope_key[16]; float tz_offset = 0; @@ -60,81 +48,9 @@ class NodePrefs : public ConfigSerializer { // persisted to file #endif private: - class RadioPrefs : public CommonRadioPrefs { - NodePrefs* _parent; - protected: - void structure() override { - def("freq", _parent->freq); - def("bw", _parent->bw); - def("sf", _parent->sf); - def("cr", _parent->cr); - def("cad", _parent->cad_enabled); - def("int_thr", _parent->interference_threshold); - def("rxgain", _parent->rx_boosted_gain); - def("fem_rxgain", _parent->radio_fem_rxgain); // fem_rxgain WAS mapped to wrong JSON property previously - def("fem_txgain", _parent->radio_fem_txgain); - def("tx", _parent->tx_power_dbm); - def("af", _parent->airtime_factor); - def("rxdelay", _parent->rx_delay_base); - def("f_txdelay", _parent->tx_delay_factor); - def("d_txdelay", _parent->direct_tx_delay_factor); - def("agc_int", _parent->agc_reset_interval); - def("hash_mode", _parent->path_hash_mode); - def("multi_ack", _parent->multi_acks); - } - public: - RadioPrefs(NodePrefs* parent) : _parent(parent) { } - - // CommonRadioPrefs interface - float getFreq() const override { return _parent->freq; } - void setFreq(float f) override { _parent->freq = f; markDirty(); } - float getBandwidth() const override { return _parent->bw; } - void setBandwidth(float bw) override { _parent->bw = bw; markDirty(); } - uint8_t getSpreadFactor() const override { return _parent->sf; } - void setSpreadFactor(uint8_t sf) override { _parent->sf = sf; markDirty(); } - uint8_t getCodingRate() const override { return _parent->cr; } - void setCodingRate(uint8_t cr) override { _parent->cr = cr; markDirty(); } - float getAirtimeFactor() const override { return _parent->airtime_factor; } - void setAirtimeFactor(float af) override { _parent->airtime_factor = af; markDirty(); } - bool isCadEnabled() const override { return _parent->cad_enabled; } - void setCadEnabled(bool en) override { _parent->cad_enabled = en; markDirty(); } - uint8_t getIntThresh() const override { return _parent->interference_threshold; } - void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } - uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } - void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } - uint8_t getTxPower() const override { return _parent->tx_power_dbm; } - void setTxPower(uint8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } - float getRxDelay() const override { return _parent->rx_delay_base; } - void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } - uint16_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } - void setAgcResetInt(uint16_t secs) override { _parent->agc_reset_interval = secs / 4; markDirty(); } - uint8_t getHashMode() const override { return _parent->path_hash_mode; } - void setHashMode(uint8_t m) override { _parent->path_hash_mode = m; markDirty(); } - uint8_t getMultiAcks() const override { return _parent->multi_acks; } - void setMultiAcks(uint8_t m) override { _parent->multi_acks = m; markDirty(); } - float getFloodTxDelay() const override { return _parent->tx_delay_factor; } - void setFloodTxDelay(float d) override { _parent->tx_delay_factor = d; markDirty(); } - float getDirectTxDelay() const override { return _parent->direct_tx_delay_factor; } - void setDirectTxDelay(float d) override { _parent->direct_tx_delay_factor = d; markDirty(); } - uint8_t getFEMRxGain() const override { return _parent->radio_fem_rxgain; } - void setFEMRxGain(uint8_t g) override { _parent->radio_fem_rxgain = g; markDirty(); } - uint8_t getFEMTxGain() const override { return _parent->radio_fem_txgain; } - void setFEMTxGain(uint8_t g) override { _parent->radio_fem_txgain = g; markDirty(); } - }; - RadioPrefs radio; + CommonRadioPrefs radio; - class GPSPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now) - NodePrefs* _parent; - protected: - void structure() override { - def("en", _parent->gps_enabled); // boolean - def("int", _parent->gps_interval); // interval in seconds - def("adv_loc", _parent->advert_loc_policy); - } - public: - GPSPrefs(NodePrefs* parent) : _parent(parent) { } - }; - GPSPrefs gps; + CommonGPSPrefs gps; class RepeatPrefs : public ConfigSerializer { // COPIED from CommonCLI (for now) public: @@ -206,7 +122,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file #endif } public: - NodePrefs() : radio(this), gps(this), companion(this), custom(&radio) + NodePrefs() : radio(*this), gps(*this), companion(this), custom(&radio) #ifdef ENABLE_WIFI_INTERFACE , wifi(this) #endif diff --git a/src/helpers/CommonGPSPrefs.h b/src/helpers/CommonGPSPrefs.h new file mode 100644 index 00000000000..e36b8c727cd --- /dev/null +++ b/src/helpers/CommonGPSPrefs.h @@ -0,0 +1,33 @@ +#pragma once +#include "ConfigSerializer.h" + +/** + * \brief The GPS settings every node role persists. + * + * Defined once here and inherited by both NodePrefs classes (see RadioPrefsFields + * in CommonRadioPrefs.h for why inheritance is used). + */ +struct GPSPrefsFields { + // ordered by size, so the struct has no padding inside + uint32_t gps_interval = 0; // read interval in seconds + uint8_t gps_enabled = 0; // boolean + uint8_t advert_loc_policy = 0; // ADVERT_LOC_* : which location adverts carry +}; + +/** + * \brief The "gps" object of /prefs.json, bound to one GPSPrefsFields. + */ +class CommonGPSPrefs : public ConfigSerializer { + GPSPrefsFields* _f; + +protected: + // JSON keys and their order are the persisted format: do not rename or reorder. + void structure() override { + def("en", _f->gps_enabled); + def("int", _f->gps_interval); + def("adv_loc", _f->advert_loc_policy); + } + +public: + explicit CommonGPSPrefs(GPSPrefsFields& fields) : _f(&fields) { } +}; diff --git a/src/helpers/CommonNodePrefs.h b/src/helpers/CommonNodePrefs.h index 9b16c9d64d7..48cd0720a28 100644 --- a/src/helpers/CommonNodePrefs.h +++ b/src/helpers/CommonNodePrefs.h @@ -2,6 +2,7 @@ #include #include +#include #include #define ADVERT_LOC_NONE 0 @@ -13,33 +14,29 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 -class NodePrefs : public ConfigSerializer { +/** + * \brief Persisted settings of the server roles (repeater, room server, sensor), + * stored as /prefs.json and edited through CommonCLI. + * + * The radio and GPS settings are inherited from RadioPrefsFields and GPSPrefsFields, + * which the companion radio's NodePrefs shares, so both read and write them the + * same way. Every other setting is specific to the server roles and lives below. + */ +class NodePrefs : public ConfigSerializer, public RadioPrefsFields, public GPSPrefsFields { public: - // in-memory backing data - float airtime_factor = 0; + // in-memory backing data (radio and GPS fields are inherited, see above) char node_name[32]; double node_lat = 0, node_lon = 0; char password[16]; - float freq = 0; - int8_t tx_power_dbm = 0; uint8_t disable_fwd = 0; uint8_t advert_interval = 0; // minutes / 2 uint8_t flood_advert_interval = 0; // hours - float rx_delay_base = 0; - float tx_delay_factor = 0; char guest_password[16]; - float direct_tx_delay_factor = 0; uint32_t guard; - uint8_t sf = 0; - uint8_t cr = 0; uint8_t allow_read_only = 0; - uint8_t multi_acks = 0; - float bw = 0; uint8_t flood_max = 0; uint8_t flood_max_unscoped = 0; uint8_t flood_max_advert = 0; - uint8_t interference_threshold = 0; - uint8_t agc_reset_interval = 0; // secs / 4 // Bridge settings uint8_t bridge_enabled = 0; // boolean uint16_t bridge_delay = 0; // milliseconds (default 500 ms) @@ -49,83 +46,14 @@ class NodePrefs : public ConfigSerializer { char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) // Power setting uint8_t powersaving_enabled = 0; // boolean - // Gps settings - uint8_t gps_enabled = 0; - uint32_t gps_interval = 0; // in seconds - uint8_t advert_loc_policy = 0; uint32_t discovery_mod_timestamp = 0; float adc_multiplier = 0; char owner_info[120]; - uint8_t rx_boosted_gain = 0; // power settings - uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting - uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain setting - uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t loop_detect = 0; - uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) uint8_t extra_sf[4]; private: - class RadioPrefs : public CommonRadioPrefs { - NodePrefs* _parent; - protected: - void structure() override { - def("freq", _parent->freq); - def("bw", _parent->bw); - def("sf", _parent->sf); - def("cr", _parent->cr); - def("cad", _parent->cad_enabled); - def("int_thr", _parent->interference_threshold); - def("rxgain", _parent->rx_boosted_gain); - def("fem_rxgain", _parent->radio_fem_rxgain); - def("fem_txgain", _parent->radio_fem_txgain); - def("tx", _parent->tx_power_dbm); - def("af", _parent->airtime_factor); - def("rxdelay", _parent->rx_delay_base); - def("f_txdelay", _parent->tx_delay_factor); - def("d_txdelay", _parent->direct_tx_delay_factor); - def("agc_int", _parent->agc_reset_interval); - def("hash_mode", _parent->path_hash_mode); - def("multi_ack", _parent->multi_acks); - } - public: - RadioPrefs(NodePrefs* parent) : _parent(parent) { } - // CommonRadioPrefs interface - float getFreq() const override { return _parent->freq; } - void setFreq(float f) override { _parent->freq = f; markDirty(); } - float getBandwidth() const override { return _parent->bw; } - void setBandwidth(float bw) override { _parent->bw = bw; markDirty(); } - uint8_t getSpreadFactor() const override { return _parent->sf; } - void setSpreadFactor(uint8_t sf) override { _parent->sf = sf; markDirty(); } - uint8_t getCodingRate() const override { return _parent->cr; } - void setCodingRate(uint8_t cr) override { _parent->cr = cr; markDirty(); } - float getAirtimeFactor() const override { return _parent->airtime_factor; } - void setAirtimeFactor(float af) override { _parent->airtime_factor = af; markDirty(); } - bool isCadEnabled() const override { return _parent->cad_enabled; } - void setCadEnabled(bool en) override { _parent->cad_enabled = en; markDirty(); } - uint8_t getIntThresh() const override { return _parent->interference_threshold; } - void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } - uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } - void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } - uint8_t getTxPower() const override { return _parent->tx_power_dbm; } - void setTxPower(uint8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } - float getRxDelay() const override { return _parent->rx_delay_base; } - void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } - uint16_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } - void setAgcResetInt(uint16_t secs) override { _parent->agc_reset_interval = secs / 4; markDirty(); } - uint8_t getHashMode() const override { return _parent->path_hash_mode; } - void setHashMode(uint8_t m) override { _parent->path_hash_mode = m; markDirty(); } - uint8_t getMultiAcks() const override { return _parent->multi_acks; } - void setMultiAcks(uint8_t m) override { _parent->multi_acks = m; markDirty(); } - float getFloodTxDelay() const override { return _parent->tx_delay_factor; } - void setFloodTxDelay(float d) override { _parent->tx_delay_factor = d; markDirty(); } - float getDirectTxDelay() const override { return _parent->direct_tx_delay_factor; } - void setDirectTxDelay(float d) override { _parent->direct_tx_delay_factor = d; markDirty(); } - uint8_t getFEMRxGain() const override { return _parent->radio_fem_rxgain; } - void setFEMRxGain(uint8_t g) override { _parent->radio_fem_rxgain = g; markDirty(); } - uint8_t getFEMTxGain() const override { return _parent->radio_fem_txgain; } - void setFEMTxGain(uint8_t g) override { _parent->radio_fem_txgain = g; markDirty(); } - }; - RadioPrefs radio; + CommonRadioPrefs radio; class BridgePrefs : public ConfigSerializer { NodePrefs* _parent; @@ -143,18 +71,7 @@ class NodePrefs : public ConfigSerializer { }; BridgePrefs bridge; - class GPSPrefs : public ConfigSerializer { - NodePrefs* _parent; - protected: - void structure() override { - def("en", _parent->gps_enabled); // boolean - def("int", _parent->gps_interval); // interval in seconds - def("adv_loc", _parent->advert_loc_policy); - } - public: - GPSPrefs(NodePrefs* parent) : _parent(parent) { } - }; - GPSPrefs gps; + CommonGPSPrefs gps; class PowerPrefs : public ConfigSerializer { NodePrefs* _parent; @@ -216,7 +133,7 @@ class NodePrefs : public ConfigSerializer { } public: - NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this), custom(&radio) { + NodePrefs() : ConfigSerializer(), bridge(this), gps(*this), radio(*this), power(this), repeat(this), room(this), custom(&radio) { node_name[0] = 0; password[0] = 0; guest_password[0] = 0; diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index 0e83ca9dfdb..a7f1ad3f6b2 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -2,65 +2,129 @@ #include "ConfigSerializer.h" #include "KeyValueStore.h" +/** + * \brief The radio settings every node role persists: LoRa modulation, TX power, + * timing factors and radio front-end options. + * + * Defined once here and inherited by both NodePrefs classes (the server roles' + * one in CommonNodePrefs.h and the companion radio's one), so a radio setting has + * exactly one name, type and default for all firmwares. Inheriting, rather than + * holding it as a member, keeps the existing `prefs.freq` style of access working + * for every caller. + */ +struct RadioPrefsFields { + // ordered by size, so the struct has no padding inside + float freq = 0; // MHz + float bw = 0; // kHz + float airtime_factor = 0; // duty cycle budget: 100% / (airtime_factor + 1) + float rx_delay_base = 0; + float tx_delay_factor = 0; // flood retransmit delay factor + float direct_tx_delay_factor = 0; // direct retransmit delay factor + uint8_t sf = 0; // spreading factor + uint8_t cr = 0; // coding rate (4/cr) + int8_t tx_power_dbm = 0; + uint8_t interference_threshold = 0; + uint8_t agc_reset_interval = 0; // seconds / 4 + uint8_t rx_boosted_gain = 0; // RX boosted gain mode (0 = power saving, 1 = boosted) + uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) + uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain + uint8_t path_hash_mode = 0; // which path hash size to use when sending + uint8_t multi_acks = 0; + uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) +}; + +/** + * \brief The "radio" object of /prefs.json, the radio commands of the CLI and the + * key/value access boards use, all bound to one RadioPrefsFields. + * + * NodePrefs owns the fields and an instance of this class bound to them. Setters + * mark this object dirty, which NodePrefs::isDirty() reports, so the owner knows + * the prefs need saving. + */ class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { + RadioPrefsFields* _f; bool _is_dirty = false; + protected: - CommonRadioPrefs() { } + // JSON keys and their order are the persisted format: do not rename or reorder. + void structure() override { + def("freq", _f->freq); + def("bw", _f->bw); + def("sf", _f->sf); + def("cr", _f->cr); + def("cad", _f->cad_enabled); + def("int_thr", _f->interference_threshold); + def("rxgain", _f->rx_boosted_gain); + def("fem_rxgain", _f->radio_fem_rxgain); + def("fem_txgain", _f->radio_fem_txgain); + def("tx", _f->tx_power_dbm); + def("af", _f->airtime_factor); + def("rxdelay", _f->rx_delay_base); + def("f_txdelay", _f->tx_delay_factor); + def("d_txdelay", _f->direct_tx_delay_factor); + def("agc_int", _f->agc_reset_interval); + def("hash_mode", _f->path_hash_mode); + def("multi_ack", _f->multi_acks); + } + public: + explicit CommonRadioPrefs(RadioPrefsFields& fields) : _f(&fields) { } + void markDirty() { _is_dirty = true; } - void clearDirty() { _is_dirty = false; } - bool isDirty() const { return _is_dirty; } + void clearDirty() override { _is_dirty = false; } + bool isDirty() const override { return _is_dirty; } - virtual float getFreq() const = 0; - virtual void setFreq(float f) = 0; + float getFreq() const { return _f->freq; } + void setFreq(float f) { _f->freq = f; markDirty(); } - virtual float getBandwidth() const = 0; - virtual void setBandwidth(float bw) = 0; + float getBandwidth() const { return _f->bw; } + void setBandwidth(float bw) { _f->bw = bw; markDirty(); } - virtual uint8_t getSpreadFactor() const = 0; - virtual void setSpreadFactor(uint8_t sf) = 0; + uint8_t getSpreadFactor() const { return _f->sf; } + void setSpreadFactor(uint8_t sf) { _f->sf = sf; markDirty(); } - virtual uint8_t getCodingRate() const = 0; - virtual void setCodingRate(uint8_t cr) = 0; + uint8_t getCodingRate() const { return _f->cr; } + void setCodingRate(uint8_t cr) { _f->cr = cr; markDirty(); } - virtual float getAirtimeFactor() const = 0; - virtual void setAirtimeFactor(float af) = 0; + float getAirtimeFactor() const { return _f->airtime_factor; } + void setAirtimeFactor(float af) { _f->airtime_factor = af; markDirty(); } - virtual bool isCadEnabled() const = 0; - virtual void setCadEnabled(bool en) = 0; + bool isCadEnabled() const { return _f->cad_enabled; } + void setCadEnabled(bool en) { _f->cad_enabled = en; markDirty(); } - virtual uint8_t getIntThresh() const = 0; - virtual void setIntThresh(uint8_t t) = 0; + uint8_t getIntThresh() const { return _f->interference_threshold; } + void setIntThresh(uint8_t t) { _f->interference_threshold = t; markDirty(); } - virtual uint8_t getRxGain() const = 0; - virtual void setRxGain(uint8_t g) = 0; + uint8_t getRxGain() const { return _f->rx_boosted_gain; } + void setRxGain(uint8_t g) { _f->rx_boosted_gain = g; markDirty(); } - virtual uint8_t getTxPower() const = 0; - virtual void setTxPower(uint8_t dbm) = 0; + uint8_t getTxPower() const { return _f->tx_power_dbm; } + void setTxPower(uint8_t dbm) { _f->tx_power_dbm = dbm; markDirty(); } - virtual float getRxDelay() const = 0; - virtual void setRxDelay(float d) = 0; + float getRxDelay() const { return _f->rx_delay_base; } + void setRxDelay(float d) { _f->rx_delay_base = d; markDirty(); } - virtual uint16_t getAgcResetInt() const = 0; - virtual void setAgcResetInt(uint16_t secs) = 0; + // stored in units of 4 seconds + uint16_t getAgcResetInt() const { return _f->agc_reset_interval * 4; } + void setAgcResetInt(uint16_t secs) { _f->agc_reset_interval = secs / 4; markDirty(); } - virtual uint8_t getHashMode() const = 0; - virtual void setHashMode(uint8_t m) = 0; + uint8_t getHashMode() const { return _f->path_hash_mode; } + void setHashMode(uint8_t m) { _f->path_hash_mode = m; markDirty(); } - virtual uint8_t getMultiAcks() const = 0; - virtual void setMultiAcks(uint8_t m) = 0; + uint8_t getMultiAcks() const { return _f->multi_acks; } + void setMultiAcks(uint8_t m) { _f->multi_acks = m; markDirty(); } - virtual float getFloodTxDelay() const = 0; - virtual void setFloodTxDelay(float d) = 0; + float getFloodTxDelay() const { return _f->tx_delay_factor; } + void setFloodTxDelay(float d) { _f->tx_delay_factor = d; markDirty(); } - virtual float getDirectTxDelay() const = 0; - virtual void setDirectTxDelay(float d) = 0; + float getDirectTxDelay() const { return _f->direct_tx_delay_factor; } + void setDirectTxDelay(float d) { _f->direct_tx_delay_factor = d; markDirty(); } - virtual uint8_t getFEMRxGain() const = 0; - virtual void setFEMRxGain(uint8_t g) = 0; + uint8_t getFEMRxGain() const { return _f->radio_fem_rxgain; } + void setFEMRxGain(uint8_t g) { _f->radio_fem_rxgain = g; markDirty(); } - virtual uint8_t getFEMTxGain() const = 0; - virtual void setFEMTxGain(uint8_t g) = 0; + uint8_t getFEMTxGain() const { return _f->radio_fem_txgain; } + void setFEMTxGain(uint8_t g) { _f->radio_fem_txgain = g; markDirty(); } bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); From a5ffadb523c2a1fb4525633b13d0dea9edf295ff Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 18:21:22 +0200 Subject: [PATCH 004/136] docs(readme): explain what this fork is for Adds an "About this fork" box at the top of the README of the integration branch: what the fork does, where the refactoring series is documented, how the branches are organised (one refactor/... branch per step based on upstream dev, each prepared as a separate pull request against upstream; integration combines the finished steps) and where the status is tracked. The upstream README below the box is unchanged. This commit exists only on the integration branch, never on a step branch. --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 6cc45d7f43d..d94d65549e2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,22 @@ +> ## About this fork +> +> This is a fork of [meshcore-dev/MeshCore](https://github.com/meshcore-dev/MeshCore) +> that refactors the firmware so new features are cheaper to add, without changing +> how it behaves on air, towards the companion apps, on the CLI or on flash. +> +> - **What and why:** [docs/refactoring/](docs/refactoring/README.md) explains the +> series, the rules every step follows (characterization tests first, no behaviour +> change, full build matrix, hardware upgrade and downgrade tests) and the +> [architecture review of v1.18.0](docs/refactoring/architecture-review-v1.18.md) +> the steps are based on. +> - **Branches:** each step lives on its own `refactor/...` branch based on upstream +> `dev` and is prepared as a separate pull request against upstream. This branch, +> `integration`, combines all finished steps on top of upstream and is the base +> for further work in this fork. +> - **Status:** see the table in [docs/refactoring/README.md](docs/refactoring/README.md#steps). +> +> Everything below this box is the unchanged upstream README. + ## About MeshCore MeshCore is a lightweight, portable C++ library that enables multi-hop packet routing for embedded projects using LoRa and other packet radios. It is designed for developers who want to create resilient, decentralized communication networks that work without the internet. From 678b4dbc782394fcee666cd7bd0b2140ceed3f3f Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 18:20:56 +0200 Subject: [PATCH 005/136] docs(refactoring): explain the refactoring series and its first step - docs/refactoring/README.md: what the series is for, the rules every step follows (characterization tests first, no behaviour change, full build matrix with size comparison, hardware upgrade and downgrade tests, one concern per pull request) and the status of each step. - docs/refactoring/architecture-review-v1.18.md: the measured findings the series is based on, each with file and line references or a git command to reproduce it, plus issues noticed while reading that are not part of the series. - docs/refactoring/0001-shared-radio-gps-prefs.md: design note for the shared radio and GPS settings: context, decision, rejected alternatives, compatibility and the verification evidence (host tests, mutation tests, build sizes for 18 environments, hardware run on two boards). --- .../0001-shared-radio-gps-prefs.md | 185 ++++++++++++++++++ docs/refactoring/README.md | 63 ++++++ docs/refactoring/architecture-review-v1.18.md | 106 ++++++++++ 3 files changed, 354 insertions(+) create mode 100644 docs/refactoring/0001-shared-radio-gps-prefs.md create mode 100644 docs/refactoring/README.md create mode 100644 docs/refactoring/architecture-review-v1.18.md diff --git a/docs/refactoring/0001-shared-radio-gps-prefs.md b/docs/refactoring/0001-shared-radio-gps-prefs.md new file mode 100644 index 00000000000..5e5a5aaa366 --- /dev/null +++ b/docs/refactoring/0001-shared-radio-gps-prefs.md @@ -0,0 +1,185 @@ +# 0001: Share the radio and GPS settings between both NodePrefs + +Status: ready for review. Behaviour change: none. + +## Context + +MeshCore has two classes called `NodePrefs`: + +- `src/helpers/CommonCLI.h` (now `src/helpers/CommonNodePrefs.h`): the settings of + the repeater, room server and sensor, edited through `CommonCLI`. +- `examples/companion_radio/NodePrefs.h`: the settings of the companion radio. + +Both persist themselves as `/prefs.json` through `ConfigSerializer`, and both +declared the same 17 radio and 3 GPS fields. Each also had a private `RadioPrefs` +class that implemented the 34 pure virtual getters and setters of +`CommonRadioPrefs` by forwarding to its parent, and a private `GPSPrefs` class. +The companion copies were marked "COPIED from CommonCLI (for now)". + +Copies drift. In this release cycle the companion copy needed three fixes for +defects the server copy did not have: + +- `6dad3d5a`: `setSpreadFactor()` evaluated `_parent->sf;` without assigning it. +- `e3d0f9fc`: `isCadEnabled()` returned `false` and `setCadEnabled()` did nothing. +- `5d82ed35`: `setIntThresh()`, `setAgcResetInt()`, `setFloodTxDelay()`, + `setDirectTxDelay()` did nothing, `getFloodTxDelay()` returned a constant. + +Adding one radio setting meant touching both classes, both forwarding classes and +the abstract interface. + +## Decision + +1. `RadioPrefsFields` (in `CommonRadioPrefs.h`) and `GPSPrefsFields` (in the new + `CommonGPSPrefs.h`) declare the radio and GPS fields once, with type, default + and unit. The fields are ordered by size so the structs contain no padding. +2. Both `NodePrefs` classes inherit these two structs instead of declaring the + fields themselves. +3. `CommonRadioPrefs` becomes the one concrete binding of a `RadioPrefsFields`: + it owns the `radio` JSON object, the radio CLI commands and the key/value + access boards use. It is constructed with the fields it binds to. The 34 pure + virtuals and both forwarding classes are gone. +4. `CommonGPSPrefs` is the binding for the `gps` JSON object and replaces the two + identical private `GPSPrefs` classes. + +The step is split into two commits so each can be reviewed on its own: + +- `refactor(prefs): move server NodePrefs into its own header and pin it with host tests` + moves the server `NodePrefs` verbatim out of `CommonCLI.h` (which pulls in + `Mesh.h`, `IdentityStore.h`, `ClientACL.h` and `RegionMap.h` and therefore + cannot be compiled on the host) and adds the characterization tests. +- `refactor(prefs): share the radio and GPS settings between server and companion NodePrefs` + is the change itself; the tests from the first commit pass unchanged. + +## Alternatives considered + +- **Hold the groups as members** (`prefs.radio.freq`). Rejected: it would change + every access in roles, UI, bridges, boards and downstream forks. Inheriting + keeps `prefs.freq` working everywhere. +- **A template binding over the parent class** (`RadioPrefsBinding`). + Rejected: it removes the duplicated code but keeps the duplicated fields, and + the binding would rely on matching member names rather than on a type. +- **Keep `CommonRadioPrefs` abstract** and add one concrete subclass. Rejected: + with one data layout left there is nothing to vary, so the 34 virtuals would + stay pure forwarding. + +## Compatibility + +- `/prefs.json`: unchanged byte for byte (same keys, same order, same number + formatting). The `def()` order, not the field order, defines the file. +- CLI: unchanged replies and side effects. +- Legacy binary prefs (`/com_prefs`, `/new_prefs`) are read field by field, so + the changed in-memory layout does not matter to them. No code copies a + `NodePrefs` as a raw block. +- Source compatibility: every `#include ` keeps working + (it includes `CommonNodePrefs.h`), and every field keeps its name and type. + `CommonRadioPrefs`, introduced in this release cycle, is now a concrete class; + code that subclassed it to implement the pure virtuals has to drop that + subclass and construct it from a `RadioPrefsFields`. + +## Verification + +### Host tests + +`test/test_common_node_prefs` (new) and `test/test_companion_node_prefs` (until +now only a disabled case) run the same checks against both classes, shared +through `test/mocks/RadioPrefsChecks.h`: + +- the exact `/prefs.json` text for a fully populated `NodePrefs`, +- loading that text restores every persisted field, +- every radio CLI command: reply text, the field it writes, that an accepted + command marks the prefs dirty and a rejected one does not, and the TX power / + RX gain push to the radio driver (`test/mocks/target.h` records the calls), +- FEM gain access through the `custom` key/value store. + +The expected values were captured from the code before the change. The tests +pass on both commits. + +The tests were checked against deliberately broken code (mutation tests), each +of which must fail: + +| Mutation | Result | +|----------|--------| +| swap the `sf` and `cr` JSON keys | 2 tests fail | +| `setFloodTxDelay()` writes the direct delay | 2 tests fail | +| `setAirtimeFactor()` does not mark the prefs dirty | 2 tests fail | +| swap the GPS `en` and `int` JSON keys | 4 tests fail | + +### Builds + +18 environments covering every role and platform were built from `dev` (`428a95c6`) +and from each commit of this step, with the same toolchain: + +- All 18 build on both commits. +- The first commit (move and tests) produces firmware of exactly the same flash + and RAM size as `dev`, as expected for a verbatim move. +- The second commit makes every firmware smaller and leaves RAM unchanged + (the forwarding classes and their 34 virtual methods are gone; the field + order keeps the structs free of padding): + +| Environment | Platform | Flash `dev` | Flash this step | Flash delta | RAM delta | +|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32-S3 | 1,174,449 | 1,173,485 | -964 | +0 | +| `Heltec_v3_room_server` | ESP32-S3 | 1,169,705 | 1,168,769 | -936 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32-S3 | 1,666,437 | 1,665,533 | -904 | +0 | +| `Heltec_v3_companion_radio_wifi` | ESP32-S3 | 1,102,677 | 1,101,745 | -932 | +0 | +| `Heltec_v3_sensor` | ESP32-S3 | 1,180,593 | 1,179,633 | -960 | +0 | +| `RAK_4631_repeater` | nRF52840 | 440,952 | 440,456 | -496 | +0 | +| `RAK_4631_room_server` | nRF52840 | 434,088 | 433,576 | -512 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52840 | 462,548 | 462,068 | -480 | +0 | +| `RAK_4631_sensor` | nRF52840 | 437,544 | 437,016 | -528 | +0 | +| `PicoW_repeater` | RP2040 | 491,972 | 491,316 | -656 | +0 | +| `wio-e5-mini_repeater` | STM32WL | 203,552 | 203,000 | -552 | +0 | +| `wio-e5-mini_sensor` | STM32WL | 202,904 | 202,336 | -568 | +0 | +| `RAK_3112_repeater_bridge_espnow` | ESP32-S3 | 1,175,717 | 1,174,761 | -956 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | 240,368 | 239,720 | -648 | +0 | +| `ProMicro_repeater` | nRF52840 | 345,424 | 344,912 | -512 | +0 | +| `ProMicro_room_server` | nRF52840 | 338,456 | 337,960 | -496 | +0 | +| `ProMicro_companion_radio_usb` | nRF52840 | 357,300 | 356,820 | -480 | +0 | +| `ProMicro_sensor` | nRF52840 | 341,900 | 341,372 | -528 | +0 | + +### Hardware + +Run on two LilyGo T-Echo boards (nRF52840, SX1262), one per `NodePrefs` class, +starting from real data written by v1.17.1: + +| Board | Firmware role | Settings class | +|-------|---------------|----------------| +| T-Echo A | room server (`LilyGo_T-Echo_room_server`) | server roles' `NodePrefs` | +| T-Echo B | companion radio over USB (`LilyGo_T-Echo_companion_radio_usb`) | companion `NodePrefs` | + +Sequence on each board: + +1. Flash `dev`. It migrates the v1.17.1 binary prefs to `/prefs.json`; all radio + values read back as before. +2. With `dev`, change 11 to 15 settings through the CLI (room server: serial + console; companion: `CMD_RUN_CLI_COMMAND` frames), reboot, read them back. +3. Flash this step. Read all settings again: identical to step 2. +4. With this step, change the settings to other values, reboot, read back: all + changes persisted. +5. Flash `dev` again. Read all settings: identical to step 4. + +Step 1 ran once per board. Steps 2 to 5 ran on the final revision of this step, +after an earlier run on a previous revision of it had passed the same way. + +The room server reads 30 values (`get ...` for every radio setting plus name, +role, location, advert and flood settings, owner info); the companion reads 16 +values plus the `APP_START` self info and the tuning parameters. Every +comparison was identical except for the version line. To prove which firmware +was running, the board images were built with `FIRMWARE_BUILD_DATE` set to the +commit (for example `ver` answered `v1.18.0 (Build: B-a59561ff)`); this define is +the only difference to the images in the build table. + +Not covered on hardware: the GPS commands (the T-Echo room server has no GPS +toggle; the host tests cover the `gps` JSON object), the repeater and sensor +roles (same class as the room server, built only), the BLE and WiFi companions, +and the ESP32, RP2040 and STM32 platforms (built only). + +## Follow-ups (not in this step) + +- One table per settings group (name, type, range, JSON key, CLI key, roles), so + a new setting is one row; the CLI could then answer "unsupported" for settings + a role does not use instead of accepting them silently. +- Let the core read its settings from the groups instead of a virtual getter per + setting in every role. +- Remove the `target.h` include from `CommonRadioPrefs.cpp` by giving it the radio + driver through an interface. diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md new file mode 100644 index 00000000000..04580f72966 --- /dev/null +++ b/docs/refactoring/README.md @@ -0,0 +1,63 @@ +# Refactoring series: make MeshCore easier to extend + +This folder documents a series of small, behaviour-preserving refactors of the +MeshCore firmware. Each step is its own pull request against `dev`, can be +reviewed on its own, and comes with the evidence that it changes nothing a user, +an app or the mesh can observe. + +## Why + +Adding a feature to MeshCore today touches far more files than the feature +itself, because the same logic exists in several copies (per role, per board, +per radio chip) and settings can only reach the code that needs them through +hand-written forwarding. A few measured examples from v1.18.0: + +- Making CAD a separate setting (`7c8e0924`) touched 15 files. +- The remote LNA toggle (`71136671`) touched 162 variant files; moving it into + the radio wrappers 23 days later (`9a95e25e`) touched 156 again. +- The radio and GPS settings existed twice, once in the server roles' NodePrefs + and once in the companion's NodePrefs, and the companion copy needed three + separate fixes in this release cycle that the other copy did not. + +The full analysis, with file and line references for every finding, is in +[architecture-review-v1.18.md](architecture-review-v1.18.md). + +## Rules every step follows + +1. **No behaviour change.** On-air packets, companion protocol frames, CLI + replies and the files on flash stay byte for byte the same. Where a step + would need a behaviour change, that is split out and proposed separately. +2. **Tests first.** Before code moves, characterization tests pin down the + current behaviour on the host (`pio test -e native`). The same tests must + pass unchanged before and after the step. The tests are checked against + deliberately broken code (mutation tests) to show they would catch a + regression. +3. **Builds and hardware.** Every step is built for a matrix covering every + role (repeater, room server, sensor, companion BLE/USB/WiFi, bridges) and + every platform (ESP32, nRF52, RP2040, STM32), with flash and RAM compared to + `dev`. Steps that touch persisted data are run on real boards, including an + upgrade from the previous firmware and a downgrade back to it. +4. **Source compatible.** Existing code, including downstream forks, keeps + compiling. Where a public header changes in an incompatible way, the step + says so explicitly. +5. **One concern per step**, documented in a design note in this folder: what + changes, why, which alternatives were rejected, and how it was verified. + +## Steps + +| # | Step | Design note | Status | +|---|------|-------------|--------| +| 1 | Share the radio and GPS settings between the server roles' and the companion's NodePrefs | [0001](0001-shared-radio-gps-prefs.md) | ready for review | + +Planned next steps (each needs its own design note before code), in order of +expected benefit: one settings module with one table per setting group; +a shared base for repeater, room server and sensor; one generic variant target +built from a board description; one RadioLib adapter per chip family; a +companion frame codec with a command table. See the review for the reasoning. + +## Verifying a step yourself + +```sh +pio test -e native # host tests, including the characterization tests +pio run -e RAK_4631_repeater # any firmware environment +``` diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md new file mode 100644 index 00000000000..9df98415502 --- /dev/null +++ b/docs/refactoring/architecture-review-v1.18.md @@ -0,0 +1,106 @@ +# Architecture review of v1.18.0 + +Read-only review of MeshCore v1.18.0 (`companion-v1.18.0` = `repeater-v1.18.0` = +`room-server-v1.18.0`, commit `81dbae59`) and of its history since v1.14.0. +The question was: **why is adding a feature expensive, and which refactors +would make it cheaper without changing behaviour?** + +Every number below was measured on the source tree or with `git` and can be +reproduced with the command given. File and line references point into v1.18.0; +later steps of this series move some of that code. Findings that came only from reading code +and were not reproduced on hardware say so. + +## How the cost shows up + +| Change | Commit | Files touched | Reproduce | +|--------|--------|---------------|-----------| +| CAD as its own setting (`set cad on/off`) | `7c8e0924` | 15 | `git show --stat 7c8e0924` | +| Remote LNA / RX gain toggle | `71136671` | 171 (162 in `variants/`) | `git show --stat 71136671` | +| Same feature moved into the radio wrappers, 23 days later | `9a95e25e` | 164 (156 in `variants/`) | `git show --stat 9a95e25e` | +| One line added to every `radio_init()` (preamble) | `f0ec5d2a` | 66 (64 in `variants/`) | `git show --stat f0ec5d2a` | +| Build flag for USB companion firmwares | `e7f2c717` | 70 | `git show --stat e7f2c717` | + +Churn between v1.14.0 and v1.18.0 (`git log --no-merges --oneline companion-v1.14.0..companion-v1.18.0 -- | wc -l`): +`examples/companion_radio/MyMesh.cpp` 80, `src/helpers/CommonCLI.cpp` 43, +`examples/simple_repeater/MyMesh.cpp` 35, `examples/simple_room_server/MyMesh.cpp` 29. + +## Findings + +Each finding names the place where the same knowledge exists more than once, +or where a value cannot reach the code that needs it. + +### F1. Settings are spelled out in many places + +- Two different classes are both called `NodePrefs`: `src/helpers/CommonCLI.h:25` + (repeater, room server, sensor) and `examples/companion_radio/NodePrefs.h:14` + (companion). Their radio adapter (`RadioPrefs`, 60 lines) was a copy that differed + only in one comment (`CommonCLI.h:77-137` vs `NodePrefs.h:63-124`). +- The core reads settings only through virtual getters that every role + re-implements as `return _prefs.x;` (`src/Dispatcher.h:166-173`, `src/Mesh.h:72`). + `Dispatcher::loop()` pushes CAD and the interference threshold to the radio every + 2 seconds (`src/Dispatcher.cpp:16`, `:67-71`). +- `CommonRadioPrefs.cpp:4` includes the variant's `target.h` and calls the global + `radio_driver` directly (`:129`, `:143`), so the radio CLI could not be built + on the host. +- The role callbacks `setTxPower()` and `setRxBoostedGain()` declared in + `CommonCLICallbacks` have no caller anywhere in `src/`, `examples/` or `variants/`. + +Addressed by step 1 (shared radio and GPS settings); the remaining points are +candidates for the next steps. + +### F2. Server roles carry the same code three times + +Repeater, room server and sensor each implement the same plumbing. +For example `sendFloodReply()` is identical in all three except for whitespace +in one comment (`diff -w` is empty), and the tested `mesh::chooseReplyRoute()` +(`src/helpers/RoutingPolicy.h:39`) is used in one place only +(`examples/simple_repeater/MyMesh.cpp:600`). +The copies have drifted (found by reading the code, not reproduced on hardware): + +- The sensor's `allowPacketForward()` checks only `flood_max` + (`examples/simple_sensor/SensorMesh.cpp:319-323`); the repeater also applies + `flood.max.unscoped`, `flood.max.advert`, the region rule and loop detection + (`examples/simple_repeater/MyMesh.cpp:434-460`). +- The sensor does not override `getExtraAckTransmitCount()`, whose base returns 0 + (`src/Mesh.cpp:25`), so `set multi.acks` is accepted but has no effect there. + +### F3. Board variants are near copies + +89 `variants/*/target.cpp` files; `radio_new_identity()` is identical (ignoring +repeated spaces) in 74 of them. The FEM gain handlers (`attachDynamicPrefs()` + `handleCommand()`) of +HeltecV4, HeltecV4R8, T096 and HeltecTrackerV2 are identical once the class name is +normalized. 618 PlatformIO environments; the pull request build check builds 14. + +### F4. Radio chip classes are near copies + +`isReceiving()` exists in seven copies of about 41 lines; after normalizing chip +names, the SX1262, SX1268, LLCC68 and STM32WLx copies are identical, and the +LR1110 and LR1121 copies are identical (`src/helpers/radiolib/Custom*.h`). + +### F5. The companion protocol is one long function + +`MyMesh::handleCmdFrame()` (`examples/companion_radio/MyMesh.cpp:1055-2063`) is +1009 lines with 62 `if (cmd_frame[0] == CMD_...)` branches. Length checks are +written per branch. + +### F6. Little of this can be tested on the host + +The native test environment builds four source files (`platformio.ini`, +`[env:native]`). None of the role code, the persistence code or the radio CLI was +covered before step 1. + +## Issues noticed while reading (not part of this series) + +Found by reading v1.18.0, not reproduced on hardware. Each should get its own +issue or fix. + +| # | Issue | Where | +|---|-------|-------| +| 1 | `CMD_SEND_CHANNEL_TXT_MSG` has no minimum length check; with a 3 to 5 byte frame, `len - i` is negative and reaches `memcpy` in `sendGroupMessage()` | `examples/companion_radio/MyMesh.cpp:1178`, `src/helpers/BaseChatMesh.cpp:527-528` | +| 2 | `CustomLLCC68Wrapper::doResetAGC()` calls `sx126xResetAGC()` with one argument; the function takes two. No environment builds this wrapper | `src/helpers/radiolib/CustomLLCC68Wrapper.h:38`, `SX126xReset.h:8` | +| 3 | LR1110, LR1121 and LR2021 wrappers do not override `packetScore()`, so the base assumes SF10 | `src/helpers/radiolib/RadioLibWrappers.h:76` | +| 4 | `ESPNOWRadio::setRxBoostedGainMode()` returns `bool` but has no `return` | `src/helpers/esp32/ESPNOWRadio.h:41` | +| 5 | Seven variant GPS loops keep `next_gps_update` in a signed `long` and compare `millis() > next_gps_update`; the shared sensor manager fixed exactly this stall after the ~24.8 day sign flip in `63731d3f`, the variant copies did not get the fix | e.g. `variants/heltec_tracker/target.cpp:74-85` | +| 6 | `extra_sf` and `discovery_mod_timestamp` are not part of `/prefs.json` | `src/helpers/CommonCLI.h:65`, `:74` | +| 7 | The server roles load `/prefs.json` without range checks (only the legacy loader clamps) | `src/helpers/CommonCLI.cpp:38` | +| 8 | Role fallback radio defaults (915 / 250 / SF10) differ from the root `platformio.ini` (869.618 / 62.5 / SF8) | `examples/simple_repeater/MyMesh.cpp:7-13`, `platformio.ini` | From 63ea0afc417a857e78da2d78b137ca1a3363b575 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 18:22:54 +0200 Subject: [PATCH 006/136] tools(refactoring): add the hardware check tool used for the refactoring steps nodetool.py addresses nRF52 boards by USB serial number and runs the upgrade/downgrade check of the design notes in docs/refactoring: text CLI for the server roles, CMD_RUN_CLI_COMMAND frames for the companion, self info, reboot and serial DFU through adafruit-nrfutil. scripts/ holds the get/set command lists used for step 1. Only on the integration branch; the step branches stay free of tooling. --- tools/refactoring/README.md | 42 ++++ .../__pycache__/nodetool.cpython-315.pyc | Bin 0 -> 13759 bytes tools/refactoring/nodetool.py | 219 ++++++++++++++++++ tools/refactoring/scripts/comp_gets.txt | 16 ++ tools/refactoring/scripts/comp_sets_1.txt | 12 + tools/refactoring/scripts/comp_sets_2.txt | 11 + tools/refactoring/scripts/server_gets.txt | 31 +++ tools/refactoring/scripts/server_sets_1.txt | 15 ++ tools/refactoring/scripts/server_sets_2.txt | 13 ++ 9 files changed, 359 insertions(+) create mode 100644 tools/refactoring/README.md create mode 100644 tools/refactoring/__pycache__/nodetool.cpython-315.pyc create mode 100644 tools/refactoring/nodetool.py create mode 100644 tools/refactoring/scripts/comp_gets.txt create mode 100644 tools/refactoring/scripts/comp_sets_1.txt create mode 100644 tools/refactoring/scripts/comp_sets_2.txt create mode 100644 tools/refactoring/scripts/server_gets.txt create mode 100644 tools/refactoring/scripts/server_sets_1.txt create mode 100644 tools/refactoring/scripts/server_sets_2.txt diff --git a/tools/refactoring/README.md b/tools/refactoring/README.md new file mode 100644 index 00000000000..1cecf950b79 --- /dev/null +++ b/tools/refactoring/README.md @@ -0,0 +1,42 @@ +# Hardware checks for the refactoring series + +`nodetool.py` drives the upgrade/downgrade check described in each step's design +note (`docs/refactoring/`): flash a firmware, change settings through the CLI, +reboot, read them back, flash the other firmware, compare. Boards are addressed by +their USB serial number, so the check cannot hit the wrong board when several are +attached. macOS only (it finds ports through `ioreg`); Python 3 standard library +only. + +```sh +python3 nodetool.py port # serial port of the board +python3 nodetool.py cli @scripts/server_gets.txt # repeater / room server / sensor console +python3 nodetool.py comp @scripts/comp_gets.txt # companion: CMD_RUN_CLI_COMMAND per line +python3 nodetool.py compinfo # companion: self info and tuning parameters +python3 nodetool.py dfu firmware.zip # 1200 baud touch, then adafruit-nrfutil serial DFU +``` + +`@file` stands for the lines of that file. `reboot` as a command reboots the board +(companion: `CMD_REBOOT`). + +## Telling the images apart + +All builds of one release report the same version and build date, so `ver` cannot +show which image is running. Build the images for the check with a marker: + +```sh +PLATFORMIO_BUILD_FLAGS='-D FIRMWARE_BUILD_DATE=\"step1-\"' pio run -e LilyGo_T-Echo_room_server +``` + +`ver` then answers `v1.18.0 (Build: step1-)`. Setting +`PLATFORMIO_BUILD_FLAGS` makes PlatformIO clean the whole build folder of the +project, so build the marked images in a separate worktree. + +## Restoring a board + +Since v1.17 every role reads `/prefs.json`. Flashing the old firmware back keeps +the values set during the check, so set them back to the original values first +(read them before the check with the `get` scripts). + +If `~/.platformio/penv` points at a Python that no longer exists, set +`NRFUTIL_PYTHON` to another interpreter, for example the one PlatformIO itself +runs with. diff --git a/tools/refactoring/__pycache__/nodetool.cpython-315.pyc b/tools/refactoring/__pycache__/nodetool.cpython-315.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a30bc1f2dcd531853208f0b60d16a62a7d8a6e6c GIT binary patch literal 13759 zcmc(GYj6`+x?s1|ddqJaKd^8+5J2Du2J#W-KyLD07pFb2GWa?oMi%TRS_Md#9#yxAq}Nc2GLrnrzjrn&lq}RNc%} zZQcF8)9O}RaYF8|J)raGbIx~O-}@Z*7ZzA42&s-wp6jorr~np}V9aJ7{*I=o7!*8| zKn+uUrfx={Nop2!B()2Ak`@aLNgaZLq?Lk^q&0$xq^w{j={CV~o_0HwfpV$4B9~Wk z9(pf~k3q0QuWC83jonUdNzQw8|B4ax$_RUPa_{qY53@@Hv|c9^oiDDXHXa{Z8?>!G z#{F41LW!r$V=AXE(n9HZlY6@cWH#GOd(5QX;jxrcLb=D`v6Av8Pr1iNaupss)Kq#3 zAg=NhLR{@Bf_Ss17~&dF3B=CxTRf!)&cO-7c~(>Bowd~7>oxXl=udn0uNneNi z%$fGyPIl*k11@$F_8rw&k8hWH6Q-p0AmWOo0X_wL!#FyfyI0)kBi#_REPBY;W%QGO~U zHbnT*DKQkr?LtiIJmIy@)&FHv<7C(`0@6%`qD_}1oaffJp2hTp$pUnLkvC)kt&SbYA%<_k)D^gy>y?&Ln}-ts`u*M3iDPPLOM(l25|GmYiQPabiiWF7wL#$$f7ib z@3cpc--m#i$iZz_a=EmYf^iCSWhJLF)9eSO9A&)Bli6=FoAQFV*rj3Ub=sXRR^a8* zTFS#H(417yyOhkbV_7{+~rV!V@J4fbedEnBg=lA z4vFLNEhH%TD08+(z=y(se-c1)K7cVfC4LPj_Q9+kehyKqj-{MLJPy!@X=Qz*<+#gE z%1gPk!Yj856r{>3`t=98DJn{P)t&(mi*P=)mpTYt0tHy|LRq^{(^L#j>l>l#>M2E# zod6h}Fqf#VpQ)wJ)~VkCr~6DHz^G|D1{ikAXa#sZAbu@W4cS!_b;`O=KTf;!b0xTC z12V<-65cNropg7|8{A3uSE@Ma?&rorg2>NyMIh=J4~0Q`L|`lRW1Q$RNDOjs$ry?V zAxP$y+R}8V zWo663O!byj?Vi=@=Cq?ZQ{}u}d80D*#F4xG_o|+Xw|`o@HQD~t7w$P4R~(HG^;C5` z{i&nzPXY^r{FSx6k$$xXqW6pt{f9<~e#_GB_153k*&!d$o-Y7aR>6Gcrvp`3dUoIajYPgU-`$XU_7?@}&C(lzdp7!XUML{E~v z5?~-|JIls+q*uw(HpWG`nMwYrRI|Y*sW&Jcj5hEw?4nP& zED|GzCOF9|OpQ$P(Ew;2lAfQ6NLHQ@c7UJ`2v(5`O6JKh;8{2{A{oL_e^8K&f*1rc zl?+0d<0f539+{P7LWuZT&qaa~%}WME6+zw)WRIh;A?#Dx9Z|P|-vL!y;3xbLqCdSz z{gbuus_TmDN4pcIRcmdmV@)mVSFD@Y?3=FkUg`aKvpaPmxMJt#>5R>OwQZp-(Y#`- z`DdM>py>ex(IY!mzImnk?9VHIvE^^J+@*hG{*@VaE?s>#?KnH%^@k!yrlKa{`SDD= zXw7WDYQ18O?|ZfAQ(MtPm<6?t{U8zn`BD%62cT9AG8(a>3Jdt04S-787KjOXLDL`i zB`@p()Kh|{rCIrwOSLT(Mgv0caY96=FheIbq>2;whV0*~wnZT~tUC<H9|UNA z(9~{VduV6BOH(iEMcp<4U?q*J)Z*e!YcPzZXvu0x)x3 z9p$3$;|aP7BwbXHnCK)Ik&JF%f9Lo6C9~Vt>+TpFd`7ZjuJ`z9cgHD7FN(830G!DC zrvc?TRG|dPA_joz`bCkKj2+Of{j_B0KwTPZpfRa24R#E)_qx9i41+<^kQmh4B;9CG zGGGHi<{CgHGL4vVIzAYOZ-Y5D!%z4TL@!cn7TeW|D;06ab$)U7+U#3<-#PN;k=2UE zUzYwS=f8KZRvb-Rj>V3zF{YP$e%Le5ztp>CFS}=Ver$IpM$-1WSXYLz%-6l#eciNJ zc&+fQig%oEI#-JuSDD7Y>PEiIY>9Qv*S*+1KNa^Z%&alCdrbMqOnH3d=8oG&l zlvz*LqY6jw&C=y%m~E7CQc*)rSsI0J)l|=MimIZ9vh6_i+<;b*);&6v_KaSms*pgx zYUqzxc6R$N6rkA<)%8RHyW#s5YDlGOBNYHXgDT8ss^*N2h^ zSIc+JZ_Q)UrIm{fuQen~lT31C>4~Lax^&-yX`Y^Mp7;O3W?yK2pwkyrtyNavHoaj= z>XM!5N>|+Q&qm5ol_@U&+*TAj^*~P*l>EChS2{CT`s$I5a%_D>(FIjfapeO&q<xvU_44J#jK+x>%*7?=afjkgXWeTQ*Jy25!Jz4j=CM3Jv4CN9 z(FmBz6FA?U2x^uEEusnZ1@!R+*>MgkpefjI6O&Pqi-@j99#Ky+^4tiR8-!)c*@8bC zcY-fWbp3H33F%`JUGPU)YW(V$WWsU%0q$K|GIA&vggFD*#n$`C<9d5t#YD>>+BjkN zvQRNh^WeCVjQ&Z`ML&s#6@)SI_$3plNU#lHUW`nQ68R-D zcwiDk#z7NgdscdBBpCV-ec)~615n=sKVbsU??vj7m9myzcO`n#r90A=ov1C@=lkPj zD@=LDYL9jNucC^1ea2Q8-}%b1HGARJ?knB#{>0`L`;!@a(bb-Xo`gPeF8;!beaC0^ zl1#B<-mr$|#NteRX8!3ly8{}Rt=OGv*Ccam*S)Ix<*NF0RpW}iDN|8(v*q^THxB>w z$Wq1X<~{MUwbD(CuGd_z?@sDhO1CZ<{-yw>>LI{rNbPDXU1-T|e}xqb5^R`!@YTA}-+V0gH zjdx}$HZQ(#?S&t_w(?$Mn+F}%*> znh%dw{bK9=j-a7@huL^d-!QlLK?a{#KlA*N*Y~Br-hLfyl-HNKz2-b=`%93eaDk?k zwqBy;Wu@`&us(a$W1M?-05~LEuz-L9ZrN}Y?q5Ptb`%`@z}j?MWrs0OQs625{6d^n8px%uKBX{c0|T(du1k}OLU}**i|zN9J#O*xZPxgQ&b#per4*WKGE@0bFw*k>Fxb1l{-HzE?+C&v{>+3K_Zy!SZaUYu;jl} z_db`}){-vXf5*RCd}zLXt*A7M4zkN7W z)vzSox%9#QyUiaQzSFw&LVO=Mj~1p9<%vtTDwBhsRJp*D6t{!xNH&MFdM4}xMWJj3 zjz&G~hA0NV?`V0@8r_x#@3m@qcxiN6@FxKjScd2q$VqIMj)+rov;y|np0(pFSY-?fx%K4irzrTtqk7ps>in;O7jO^Zk?%rA z2q(&}225%OK;#8<;s96A>7E-}P#)c|s_|>N#E@!94VTrc++N$lvyS6wc2@6Jt*YFV zVCG-5!cGT9{Hw7x5gJOk%ey<0-GxJYFEze;z3Xy<^ zB&(l1BKY6kN8Io2LAzRvrKFnboS^6}vlM@ssQ&;Eay*H}@x>NIF1| zEBi(DyVdX2tZv?ahfmvE!82{P$;#7zX-6t{$O4Y#+x24LS{>0q< zMFFT(j|wS+NoJTYohGW&*I9`PRzpay58@aUHY7b- z1k^PE;d*rnx^>X#+XIBt4X^Y1f-^++(CUDgw1|jv%nJ(2Lm%SF&3U}s*1V=rgbPOl zkiSt2RUy&KFqcbRK#-i_Ytm3e?tj@9|JFC)c zT9!>6B-MG!wY@fCd7e$43WasRsw=VIH(=H79K#!~P_YP{DhdV#K+OQvQ)S`baOQCgH9$G&84f3x!(m5okL!#;Qk1dvajY`wn+As<4> z_x}$NK3Lg2cS+KXOiMapRMG`tag3j4WR&*crwqD81&-j8|*gq>3`a?Wk z)cNH1KcESsN+2`f%aD`wcoCxojIKZ= z(W0c|XC$3}R6u%_HAghf*J&lB`y$l;1b)JgL4ask_L{jgzW4f6!k;WjoPGWIW$5*VO0&V=Vy%RDpx?6SEs!`3CYy={8O_NMKx3*eh?+3Z{^ zt6KctYu`(jFVRa+zHd&KH7}S!o!q*8sqF2;OMyEb?}t*LU^4UG1zUVD@#M9kjPF9q zA6WGTQ=?<4@#j`YFQz6UspzHE2|guGr7q8`inA%-?6Ubh^g6$6u1<8O>ROg-_TLfj zo?1TG{n56M{>^gF`3(nVGDnVmxb4?RKGJO-zQqK;h&i}>gv%b_3 znDZfkdv?Jd4<=f!J(t{;6yMyn6ui^&?sJ)n%0$_ZFE2iS?fIpX@#oVO2jrH@W{o5U zU;jb!c-p)p)(#uAR#LI(e9f7dO8S@dZ;z%+8WxQ6y3D@)cbN|k-yQj|<=3$EJpcmt zF0n0D+q7K0XH8MyQ67N6dT_iy zhGS@5kD435Vg-6G1&N|M_Tj!06%-P@)NqmJ2@f-*x{WJlz_GkAaYRHZ6HMlo$w|eYo zq(YtQV$Fe->y+ICj%IMPxmqPh9JF|9B!Gh!#+L7!PW?=E&lV8=1udQeD`P99a~6Dt zhS!y?@U+A*8lDoyB|Ut@%O`wO22Tnm;Wr`~o16E;W3)MYLxTYKPhoDvAGtWkG)!I` z15*VT3XcxJ9f$7&E{B%>@4x(ut3)}IE8t1 zne(_wk}3N-O|mJ^)Vg}l@^x5+uN1)5$>U>H<&Ar;|@ERo-d2TlrzjN0q4o ze<~14T@+Gv;%bGM*}m(J<3AsHq^B+2bgccK@CNtl&V`-vmiYeprbP4YLpKg3JCj|B zV=K(=?30KTQ}NZK62&X`so4$}XXuba7xJrnI?AYjD6@3b>Hon=W6WZ_?)3}`JRkz* z;g&3kgI!0Q6QT;lf8r>6y{vRhc`Nc)Z2h)qyZh*v*hw3`vMnl9-wif zL_fugFq%YL;ZpY;hbF<#Pf}te;j;lhKPC`6NTU4`9Tt$PP_W82kP)63 z!O;BQ!q;EJPe42CMe1{OXs;ENG)iBeaA1odgcoYRapAKN<6=R`qD%> zPS8Zke^7PYYW;0lIm7`NTn%_A$WI^YAdW%i+g-chRz#xDhR3vBhe(%-bFPzb@3;Pm zpMoHd>$45!gm?brvKI&mv|AP7G)_&G1QfXPt8nP4Cq0jno~j+=r5`u0c0UwOPlyDr zFNtNeH8&5h&JK=auij4WQ4xRrV?&sSK{F54@$vs*9!4^c17tsPZWVR^zRiuYZv9=F z^)s-3lh^RA>o=2eK@7kNDu_n8OVzBJ0r?JlEQiPK4>+YYc&nV#dK|#N4gj?q?Tyy^ zNE6^NL1`rE!Fw^su-`k%N(_24B)k`!L;s8!?nGiakgub#I zMT#Tt4;~e2cn=5{X3&(I35jxNBXOVbKgD*4d)XHt7bW^Kk$b3|E6jwMfcsSb@1WI3 z@Dr@SKtb@8mM`wSwlmTEYciz~U+K7$L^Ux(-SYa_+c3v?O89=*uK)&N|!LCZfTqFn5&y9aNxb71kj7LQm85 z7mS5w9y%!6`WaR98C5|3mwZM+vG6m>1{t_zEGoG=wlH?DkX!-6(oQndrhV9d^3-(UvFZqDMV6)PA{L w4 serial port of that board + nodetool.py cli [...] text console (repeater, room server, sensor) + nodetool.py comp [...] companion: CMD_RUN_CLI_COMMAND (66) per command + nodetool.py compinfo companion: APP_START self info + tuning params + nodetool.py dfu 1200-baud touch, then adafruit-nrfutil serial DFU +""" +import os +import plistlib +import select +import struct +import subprocess +import sys +import termios +import time +import tty + +# adafruit-nrfutil as shipped with PlatformIO. NRFUTIL_PYTHON selects the interpreter +# (default: PlatformIO's own virtualenv); the tool's bundled packages are put on PYTHONPATH. +NRFUTIL_DIR = os.path.expanduser("~/.platformio/packages/tool-adafruit-nrfutil") +NRFUTIL = [os.environ.get("NRFUTIL_PYTHON", os.path.expanduser("~/.platformio/penv/bin/python")), + NRFUTIL_DIR + "/adafruit-nrfutil.py"] +NRFUTIL_ENV = dict(os.environ, PYTHONPATH=NRFUTIL_DIR + "/site-packages") + + +def find_port(usb_serial, timeout=20.0): + """Returns the first callout device below the USB device with this serial number.""" + end = time.time() + timeout + while time.time() < end: + r = subprocess.run(["ioreg", "-a", "-r", "-c", "IOUSBHostDevice", "-l"], capture_output=True) + if r.returncode == 0: + def walk(node, inside=False): + inside = inside or node.get("USB Serial Number") == usb_serial + if inside and "IOCalloutDevice" in node: + yield node["IOCalloutDevice"] + for c in node.get("IORegistryEntryChildren", []): + yield from walk(c, inside) + ports = sorted(p for root in plistlib.loads(r.stdout) for p in walk(root)) + if ports: + return ports[0] + time.sleep(0.5) + return None + + +def open_raw(port, baud=termios.B115200): + fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + tty.setraw(fd) + attrs = termios.tcgetattr(fd) + attrs[2] |= termios.CLOCAL | termios.CREAD + attrs[4] = attrs[5] = baud + termios.tcsetattr(fd, termios.TCSANOW, attrs) + return fd + + +def read_for(fd, seconds): + data, end = b"", time.time() + seconds + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.05) + if r: + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + continue + if not chunk: + break + data += chunk + return data + + +def text_cli(usb_serial, commands, timeout=6.0): + """Server roles echo the command, then print the reply as a line ' -> ...'. + Input is drained before each command and only a reply line that follows the + echo of this command counts, so a late reply can never be taken for the next one. + A command without reply is sent once more (every get/set used here is idempotent).""" + fd = open_raw(find_port(usb_serial)) + read_for(fd, 0.5) + out = [] + for cmd in commands: + if cmd == "reboot": + os.write(fd, b"reboot\r") + out.append((cmd, "(sent)")) + break + reply = None + for attempt in range(2): + read_for(fd, 0.3) + os.write(fd, (cmd + "\r").encode()) + buf, end = "", time.time() + timeout + while time.time() < end and reply is None: + buf += read_for(fd, 0.1).decode(errors="replace") + echo = buf.find(cmd) + if echo < 0: + continue + for line in buf[echo + len(cmd):].splitlines(): + if line.startswith(" -> "): + reply = line[5:] + break + if reply is not None: + break + out.append((cmd, reply)) + try: + os.close(fd) + except OSError: + pass + return out + + +def comp_frame(fd, payload, want, timeout=4.0): + """Sends one app->radio frame ('<' len16 payload) and waits for a reply frame of code `want`.""" + os.write(fd, b"<" + struct.pack("") + if i < 0 or len(buf) < i + 3: + break + n = struct.unpack(" {r}") + elif cmd == "comp": + for c, r in comp_cli(serial, sys.argv[3:]): + print(f"{c} => {r}") + elif cmd == "compinfo": + for k, v in comp_info(serial).items(): + print(f"{k} = {v}") + elif cmd == "dfu": + sys.exit(dfu(serial, sys.argv[3])) + + +if __name__ == "__main__": + main() diff --git a/tools/refactoring/scripts/comp_gets.txt b/tools/refactoring/scripts/comp_gets.txt new file mode 100644 index 00000000000..94b10913809 --- /dev/null +++ b/tools/refactoring/scripts/comp_gets.txt @@ -0,0 +1,16 @@ +ver +get radio +get freq +get tx +get af +get dutycycle +get rxdelay +get txdelay +get direct.txdelay +get int.thresh +get agc.reset.interval +get multi.acks +get path.hash.mode +get cad +get radio.rxgain +get tz.offset diff --git a/tools/refactoring/scripts/comp_sets_1.txt b/tools/refactoring/scripts/comp_sets_1.txt new file mode 100644 index 00000000000..ab4101f6ddb --- /dev/null +++ b/tools/refactoring/scripts/comp_sets_1.txt @@ -0,0 +1,12 @@ +set radio 869.618,62.5,8,8 +set tx 20 +set af 2 +set rxdelay 3.5 +set txdelay 0.75 +set direct.txdelay 0.35 +set int.thresh 12 +set agc.reset.interval 40 +set multi.acks 1 +set path.hash.mode 1 +set cad on +set radio.rxgain off diff --git a/tools/refactoring/scripts/comp_sets_2.txt b/tools/refactoring/scripts/comp_sets_2.txt new file mode 100644 index 00000000000..4d6884346e8 --- /dev/null +++ b/tools/refactoring/scripts/comp_sets_2.txt @@ -0,0 +1,11 @@ +set tx 18 +set af 1.5 +set rxdelay 2.25 +set txdelay 0.6 +set direct.txdelay 0.15 +set int.thresh 7 +set agc.reset.interval 24 +set multi.acks 0 +set path.hash.mode 2 +set cad off +set radio.rxgain on diff --git a/tools/refactoring/scripts/server_gets.txt b/tools/refactoring/scripts/server_gets.txt new file mode 100644 index 00000000000..49f651106df --- /dev/null +++ b/tools/refactoring/scripts/server_gets.txt @@ -0,0 +1,31 @@ +ver +get role +get name +get radio +get freq +get tx +get af +get dutycycle +get rxdelay +get txdelay +get direct.txdelay +get int.thresh +get agc.reset.interval +get multi.acks +get path.hash.mode +get cad +get radio.rxgain +get lat +get lon +get advert.interval +get flood.advert.interval +get flood.max +get flood.max.unscoped +get flood.max.advert +get loop.detect +get allow.read.only +get owner.info +get adc.multiplier +get repeat +gps +gps advert diff --git a/tools/refactoring/scripts/server_sets_1.txt b/tools/refactoring/scripts/server_sets_1.txt new file mode 100644 index 00000000000..ae6d8824eee --- /dev/null +++ b/tools/refactoring/scripts/server_sets_1.txt @@ -0,0 +1,15 @@ +set radio 869.618,62.5,8,8 +set tx 20 +set af 2 +set rxdelay 3.5 +set txdelay 0.75 +set direct.txdelay 0.35 +set int.thresh 12 +set agc.reset.interval 40 +set multi.acks 1 +set path.hash.mode 1 +set cad on +set radio.rxgain off +gps on +set flood.max 32 +set owner.info set-by-base diff --git a/tools/refactoring/scripts/server_sets_2.txt b/tools/refactoring/scripts/server_sets_2.txt new file mode 100644 index 00000000000..2b607604036 --- /dev/null +++ b/tools/refactoring/scripts/server_sets_2.txt @@ -0,0 +1,13 @@ +set tx 18 +set af 1.5 +set rxdelay 2.25 +set txdelay 0.6 +set direct.txdelay 0.15 +set int.thresh 7 +set agc.reset.interval 24 +set multi.acks 0 +set path.hash.mode 0 +set cad off +set radio.rxgain on +set flood.max 48 +set owner.info set-by-B From 4732e9541d8a9e93de48e0d44489bd9cd5c409a8 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 19:02:00 +0200 Subject: [PATCH 007/136] test(core): run Dispatcher and Mesh on the host and pin their tuning hooks What changes - New native test environment native_core that builds the real src/Dispatcher.cpp and src/Mesh.cpp for the host. The suite test_core_tuning drives a mesh::Mesh with nothing overridden against fakes for the radio, clock, RNG, packet manager and tables. - Link-time stand-ins for the four mesh::Identity / LocalIdentity members that Mesh.cpp references (constructors, verify, sign, calcSharedSecret). The real ones need the board crypto libraries; the tuning tests never call them, and a call would fail the test. - The tests pin what the core uses when no role overrides the tuning hooks: airtime budget factor 1.0, RX delay with base 10, retransmit delay from the raw packet length, no direct retransmit delay, no extra ACKs, interference threshold, CAD and AGC reset off. - native ignores test_core_tuning (it needs the extra sources), and the unit test workflow also runs native_core. Why - The next change lets the core read these values from the shared radio settings instead of a virtual getter per role. These tests prove that the core keeps its current defaults when no settings are attached, which is what every downstream user that does not opt in gets. No firmware code changes in this commit. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 21 ++- test/test_core_tuning/identity_stubs.cpp | 28 ++++ test/test_core_tuning/test_core_tuning.cpp | 164 +++++++++++++++++++++ 4 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 test/test_core_tuning/identity_stubs.cpp create mode 100644 test/test_core_tuning/test_core_tuning.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..dd3c25da338 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_core -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index 164a33bd048..d2f13c4e045 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,7 +167,9 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = + test_kiss_modem + test_core_tuning build_src_filter = -<*> +<../src/Utils.cpp> @@ -179,6 +181,23 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_core] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks +test_build_src = yes +test_filter = test_core_tuning +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_kiss_modem] platform = native test_framework = googletest diff --git a/test/test_core_tuning/identity_stubs.cpp b/test/test_core_tuning/identity_stubs.cpp new file mode 100644 index 00000000000..eec1e3437c9 --- /dev/null +++ b/test/test_core_tuning/identity_stubs.cpp @@ -0,0 +1,28 @@ +// Link-time stand-ins for the parts of mesh::Identity that Mesh.cpp references. +// The real implementations need the board crypto libraries, which do not exist on +// the host. The tuning tests never sign, verify or derive secrets, so any call is a +// test failure. + +#include +#include + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + ADD_FAILURE() << "Identity::verify must not be called by the tuning tests"; + return false; +} + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + ADD_FAILURE() << "LocalIdentity::sign must not be called by the tuning tests"; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + ADD_FAILURE() << "LocalIdentity::calcSharedSecret must not be called by the tuning tests"; +} + +} // namespace mesh diff --git a/test/test_core_tuning/test_core_tuning.cpp b/test/test_core_tuning/test_core_tuning.cpp new file mode 100644 index 00000000000..a2671eab75e --- /dev/null +++ b/test/test_core_tuning/test_core_tuning.cpp @@ -0,0 +1,164 @@ +// Characterization tests for the tuning hooks of mesh::Dispatcher and mesh::Mesh: +// the values the core uses for duty cycle, RX delay, retransmit delays, extra ACKs, +// interference threshold, CAD and AGC reset when no role overrides them. They run +// the real Dispatcher.cpp and Mesh.cpp against fakes for the radio, clock, RNG, +// packet manager and tables. + +#include + +#include +#include +#include + +#include + +namespace { + +// Airtime grows with the length, so a wrong length in a formula changes the result. +class FakeRadio : public mesh::Radio { +public: + int recvRaw(uint8_t* bytes, int sz) override { return 0; } + uint32_t getEstAirtimeFor(int len_bytes) override { return 11 + 37 * (uint32_t)len_bytes; } + float packetScore(float snr, int packet_len) override { return 0; } + bool startSendRaw(const uint8_t* bytes, int len) override { return false; } + bool isSendComplete() override { return true; } + void onSendFinished() override { } + bool isInRecvMode() const override { return true; } +}; + +class FakeClock : public mesh::MillisecondClock { +public: + unsigned long getMillis() override { return 0; } +}; + +class FakeRTC : public mesh::RTCClock { +public: + uint32_t getCurrentTime() override { return 0; } + void setCurrentTime(uint32_t time) override { } +}; + +// Returns a fixed sequence of 32 bit values; two instances with the same sequence +// let a test compute the expected nextInt() result independently of the core. +class SequenceRNG : public mesh::RNG { + std::vector _values; + size_t _next = 0; +public: + explicit SequenceRNG(std::vector values) : _values(values) { } + void random(uint8_t* dest, size_t sz) override { + uint32_t v = _values[_next++ % _values.size()]; + memset(dest, 0, sz); + memcpy(dest, &v, sz < sizeof(v) ? sz : sizeof(v)); + } +}; + +class NullPacketManager : public mesh::PacketManager { +public: + mesh::Packet* allocNew() override { return nullptr; } + void free(mesh::Packet* packet) override { } + void queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override { } + mesh::Packet* getNextOutbound(uint32_t now) override { return nullptr; } + int getOutboundCount(uint32_t now) const override { return 0; } + int getOutboundTotal() const override { return 0; } + int getFreeCount() const override { return 0; } + mesh::Packet* getOutboundByIdx(int i) override { return nullptr; } + mesh::Packet* removeOutboundByIdx(int i) override { return nullptr; } + void queueInbound(mesh::Packet* packet, uint32_t scheduled_for) override { } + mesh::Packet* getNextInbound(uint32_t now) override { return nullptr; } +}; + +class NullTables : public mesh::MeshTables { +public: + bool wasSeen(const mesh::Packet* packet) override { return false; } + void markSeen(const mesh::Packet* packet) override { } + void clear(const mesh::Packet* packet) override { } +}; + +// A Mesh with nothing overridden, so the core's own hook implementations run. +class CoreMesh : public mesh::Mesh { +public: + CoreMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, + mesh::PacketManager& mgr, mesh::MeshTables& tables) + : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) { } + + using mesh::Mesh::getAirtimeBudgetFactor; + using mesh::Mesh::calcRxDelay; + using mesh::Mesh::getInterferenceThreshold; + using mesh::Mesh::getCADEnabled; + using mesh::Mesh::getAGCResetInterval; + using mesh::Mesh::getRetransmitDelay; + using mesh::Mesh::getDirectRetransmitDelay; + using mesh::Mesh::getExtraAckTransmitCount; +}; + +const std::vector kRandom = { 0, 1, 7, 12345, 0x7FFFFFFF, 0xFFFFFFFF, 999983 }; + +// Packets with a 3 x 2 byte path and a 40 byte payload: one plain flood packet and +// one with transport codes (4 more bytes on air), so formulas that differ only in +// how they count the length give different results. +std::vector makePackets() { + std::vector pkts; + for (uint8_t route : {ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD}) { + mesh::Packet pkt; + pkt.header = route | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT); + pkt.setPathHashSizeAndCount(2, 3); + pkt.payload_len = 40; + pkts.push_back(pkt); + } + return pkts; +} + +struct Harness { + FakeRadio radio; + FakeClock clock; + FakeRTC rtc; + SequenceRNG rng{kRandom}; + NullPacketManager mgr; + NullTables tables; + CoreMesh mesh{radio, clock, rng, rtc, mgr, tables}; +}; + +} // namespace + +// Without any settings attached, the core keeps its built-in defaults +// (src/Dispatcher.cpp and src/Mesh.cpp as of v1.18.0). +TEST(CoreTuningDefaults, DutyCycleInterferenceCadAgcAndAcks) { + Harness h; + EXPECT_FLOAT_EQ(1.0f, h.mesh.getAirtimeBudgetFactor()); + EXPECT_EQ(0, h.mesh.getInterferenceThreshold()); + EXPECT_FALSE(h.mesh.getCADEnabled()); + EXPECT_EQ(0, h.mesh.getAGCResetInterval()); + EXPECT_EQ(0, h.mesh.getExtraAckTransmitCount()); +} + +TEST(CoreTuningDefaults, RxDelayUsesBaseTen) { + Harness h; + // Dispatcher::calcRxDelay(): (pow(10, 0.85f - score) - 1.0) * air_time + for (float score : {0.0f, 0.3f, 0.85f, 1.0f}) { + for (uint32_t air : {0u, 100u, 1234u}) { + int expected = (int) ((pow(10, 0.85f - score) - 1.0) * air); + EXPECT_EQ(expected, h.mesh.calcRxDelay(score, air)) << "score " << score << " air " << air; + } + } + // pinned values + EXPECT_EQ(6079, h.mesh.calcRxDelay(0.0f, 1000)); + EXPECT_EQ(0, h.mesh.calcRxDelay(0.85f, 1000)); +} + +TEST(CoreTuningDefaults, RetransmitDelaysUseRawLength) { + for (mesh::Packet pkt : makePackets()) { + Harness h; + SequenceRNG expected_rng(kRandom); + for (size_t i = 0; i < kRandom.size(); i++) { + // Mesh::getRetransmitDelay(): t = (airtime(raw length) * 52 / 50) / 2, nextInt(0, 5) * t + uint32_t t = (h.radio.getEstAirtimeFor(pkt.getRawLength()) * 52 / 50) / 2; + uint32_t expected = expected_rng.nextInt(0, 5) * t; + EXPECT_EQ(expected, h.mesh.getRetransmitDelay(&pkt)) << "draw " << i; + } + EXPECT_EQ(0u, h.mesh.getDirectRetransmitDelay(&pkt)); + } +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 26cb12746286d01a89b85487b0b7b35d239a7233 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 19:08:11 +0200 Subject: [PATCH 008/136] refactor(core): read the tuning values from the node's radio settings What changes - RadioPrefsFields moves from helpers/CommonRadioPrefs.h into the new core header src/RadioPrefsFields.h (no dependencies), so the core can use it. CommonRadioPrefs.h includes it; nothing else changes for its users. - Dispatcher gets useRadioPrefs(const RadioPrefsFields*). Once a node attaches its settings, the default implementations of the tuning hooks read them on every call: getAirtimeBudgetFactor(), calcRxDelay(), getInterferenceThreshold(), getCADEnabled(), getAGCResetInterval() in Dispatcher, getRetransmitDelay(), getDirectRetransmitDelay() and getExtraAckTransmitCount() in Mesh. Without settings they return exactly the built-in defaults they returned before. All hooks stay virtual. - Repeater, room server, sensor and companion call useRadioPrefs(&_prefs) in their constructor and drop the overrides that only forwarded a setting: 29 overrides that were identical in all roles, implemented in the core once instead. - The sensor keeps its two retransmit delay overrides (it draws from six discrete steps, the other roles from a continuous range) and now states explicitly that it sends no extra ACKs, which it never did: the CLI accepted multi.acks there but nothing read it. Changing either is a behaviour change and is left to a separate change. Why - Every role re-implemented the same eight one-line getters to hand a setting to the core, and a new core setting needed a new virtual plus an override in every role (CAD in 7c8e0924 touched 15 files that way). Now a new tuning value is one field the core reads. Compatibility - Behaviour is unchanged for every role: the formulas moved verbatim from the role overrides into the core, and test_core_tuning checks that the core returns exactly what the v1.18.0 role overrides returned, that settings changed at run time apply immediately, and that the core keeps its old defaults when no settings are attached. - Downstream code that overrides any of these hooks keeps working; code that does not call useRadioPrefs() gets the old defaults. --- examples/companion_radio/MyMesh.cpp | 29 +----- examples/companion_radio/MyMesh.h | 10 -- examples/simple_repeater/MyMesh.cpp | 14 +-- examples/simple_repeater/MyMesh.h | 20 ---- examples/simple_room_server/MyMesh.cpp | 14 +-- examples/simple_room_server/MyMesh.h | 19 ---- examples/simple_sensor/SensorMesh.cpp | 20 +--- examples/simple_sensor/SensorMesh.h | 8 +- src/Dispatcher.cpp | 8 +- src/Dispatcher.h | 32 ++++-- src/Mesh.cpp | 10 +- src/Mesh.h | 3 + src/RadioPrefsFields.h | 37 +++++++ src/helpers/CommonRadioPrefs.h | 32 +----- test/test_core_tuning/test_core_tuning.cpp | 113 ++++++++++++++++++++- 15 files changed, 201 insertions(+), 168 deletions(-) create mode 100644 src/RadioPrefsFields.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 99a9aa30d6e..5b14e7d0e52 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -259,34 +259,6 @@ int MyMesh::getFromOfflineQueue(uint8_t frame[]) { return 0; // queue is empty } -float MyMesh::getAirtimeBudgetFactor() const { - return _prefs.airtime_factor; -} - -int MyMesh::getInterferenceThreshold() const { - return _prefs.interference_threshold; -} -bool MyMesh::getCADEnabled() const { - return _prefs.cad_enabled; -} - -int MyMesh::calcRxDelay(float score, uint32_t air_time) const { - if (_prefs.rx_delay_base <= 0.0f) return 0; - return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); -} - -uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} -uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} - -uint8_t MyMesh::getExtraAckTransmitCount() const { - return _prefs.multi_acks; -} void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) { @@ -900,6 +872,7 @@ void MyMesh::onSendTimeout() {} MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store) : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables), _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _listener(NULL), _iter(0) { + useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings _iter_started = false; _cli_rescue = false; cli_command[0] = 0; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 944790df922..4f2d87db01f 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -127,16 +127,6 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { int getRecentlyHeard(AdvertPath dest[], int max_num); protected: - float getAirtimeBudgetFactor() const override; - int getInterferenceThreshold() const override; - bool getCADEnabled() const override; - int getAGCResetInterval() const override { - return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds - } - int calcRxDelay(float score, uint32_t air_time) const override; - uint32_t getRetransmitDelay(const mesh::Packet *packet) override; - uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override; - uint8_t getExtraAckTransmitCount() const override; bool filterRecvFloodPacket(mesh::Packet* packet) override; bool allowPacketForward(const mesh::Packet* packet) override; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 61b6c149e98..71d517d8bcc 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -539,19 +539,6 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { } } -int MyMesh::calcRxDelay(float score, uint32_t air_time) const { - if (_prefs.rx_delay_base <= 0.0f) return 0; - return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); -} - -uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} -uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { @@ -874,6 +861,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc , bridge(&_prefs, _mgr, &rtc) #endif { + useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings last_millis = 0; uptime_millis = 0; next_local_advert = next_flood_advert = 0; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index dc89325861c..cd8b5bf4249 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -128,10 +128,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); protected: - float getAirtimeBudgetFactor() const override { - return _prefs.airtime_factor; - } - bool allowPacketForward(const mesh::Packet* packet) override; const char* getLogDateTime() override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; @@ -139,23 +135,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void logRx(mesh::Packet* pkt, int len, float score) override; void logTx(mesh::Packet* pkt, int len) override; void logTxFail(mesh::Packet* pkt, int len) override; - int calcRxDelay(float score, uint32_t air_time) const override; - uint32_t getRetransmitDelay(const mesh::Packet* packet) override; - uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; - - int getInterferenceThreshold() const override { - return _prefs.interference_threshold; - } - bool getCADEnabled() const override { - return _prefs.cad_enabled; - } - int getAGCResetInterval() const override { - return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds - } - uint8_t getExtraAckTransmitCount() const override { - return _prefs.multi_acks; - } #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index da4c776c0ea..10cca41c8f3 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -274,11 +274,6 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { } } -int MyMesh::calcRxDelay(float score, uint32_t air_time) const { - if (_prefs.rx_delay_base <= 0.0f) return 0; - return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); -} - const char *MyMesh::getLogDateTime() { static char tmp[32]; uint32_t now = getRTCClock()->getCurrentTime(); @@ -288,14 +283,6 @@ const char *MyMesh::getLogDateTime() { return tmp; } -uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} -uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); - return getRNG()->nextInt(0, 5*t + 1); -} bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; @@ -634,6 +621,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4) { + useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings last_millis = 0; uptime_millis = 0; next_local_advert = next_flood_advert = 0; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 4dee8bdbf40..00fee6333b3 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -129,32 +129,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); protected: - float getAirtimeBudgetFactor() const override { - return _prefs.airtime_factor; - } - void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; void logRx(mesh::Packet* pkt, int len, float score) override; void logTx(mesh::Packet* pkt, int len) override; void logTxFail(mesh::Packet* pkt, int len) override; - int calcRxDelay(float score, uint32_t air_time) const override; const char* getLogDateTime() override; - uint32_t getRetransmitDelay(const mesh::Packet* packet) override; - uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; - int getInterferenceThreshold() const override { - return _prefs.interference_threshold; - } - bool getCADEnabled() const override { - return _prefs.cad_enabled; - } - int getAGCResetInterval() const override { - return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds - } - uint8_t getExtraAckTransmitCount() const override { - return _prefs.multi_acks; - } mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b186066810d..7215031e2dd 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -312,21 +312,12 @@ void SensorMesh::alertIf(bool condition, Trigger& t, AlertPriority pri, const ch } } -float SensorMesh::getAirtimeBudgetFactor() const { - return _prefs.airtime_factor; -} - bool SensorMesh::allowPacketForward(const mesh::Packet* packet) { if (_prefs.disable_fwd) return false; if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false; return true; } -int SensorMesh::calcRxDelay(float score, uint32_t air_time) const { - if (_prefs.rx_delay_base <= 0.0f) return 0; - return (int) ((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); -} - uint32_t SensorMesh::getRetransmitDelay(const mesh::Packet* packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); return getRNG()->nextInt(0, 6)*t; @@ -335,14 +326,8 @@ uint32_t SensorMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 6)*t; } -int SensorMesh::getInterferenceThreshold() const { - return _prefs.interference_threshold; -} -bool SensorMesh::getCADEnabled() const { - return _prefs.cad_enabled; -} -int SensorMesh::getAGCResetInterval() const { - return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds +uint8_t SensorMesh::getExtraAckTransmitCount() const { + return 0; // the sensor has never sent extra ACKs, whatever multi.acks says } void SensorMesh::startRegionsLoad() { @@ -787,6 +772,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4) { + useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; last_read_time = 0; diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 590ca0c7657..82b940f9761 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -120,14 +120,12 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { virtual bool handleCustomCommand(uint32_t sender_timestamp, char* command, char* reply) { return false; } // Mesh overrides - float getAirtimeBudgetFactor() const override; bool allowPacketForward(const mesh::Packet* packet) override; - int calcRxDelay(float score, uint32_t air_time) const override; + // The sensor keeps its own retransmit delays and sends no extra ACKs, unlike the + // other roles; aligning it would change its behaviour (see docs/refactoring). uint32_t getRetransmitDelay(const mesh::Packet* packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; - int getInterferenceThreshold() const override; - bool getCADEnabled() const override; - int getAGCResetInterval() const override; + uint8_t getExtraAckTransmitCount() const override; mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index c0610b7f8a7..09761dfe9f0 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -32,7 +32,7 @@ void Dispatcher::begin() { } float Dispatcher::getAirtimeBudgetFactor() const { - return 1.0; + return _radio_prefs ? _radio_prefs->airtime_factor : 1.0; } void Dispatcher::updateTxBudget() { @@ -53,7 +53,11 @@ void Dispatcher::updateTxBudget() { } int Dispatcher::calcRxDelay(float score, uint32_t air_time) const { - return (int) ((pow(10, 0.85f - score) - 1.0) * air_time); + if (_radio_prefs == NULL) { + return (int) ((pow(10, 0.85f - score) - 1.0) * air_time); + } + if (_radio_prefs->rx_delay_base <= 0.0f) return 0; // RX delay disabled + return (int)((pow(_radio_prefs->rx_delay_base, 0.85f - score) - 1.0) * air_time); } uint32_t Dispatcher::getCADFailRetryDelay() const { diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec2..5e7c65c0332 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -137,9 +138,10 @@ class Dispatcher { Radio* _radio; MillisecondClock* _ms; uint16_t _err_flags; + const RadioPrefsFields* _radio_prefs; // see useRadioPrefs(); NULL = built-in tuning defaults Dispatcher(Radio& radio, MillisecondClock& ms, PacketManager& mgr) - : _radio(&radio), _ms(&ms), _mgr(&mgr) + : _radio(&radio), _ms(&ms), _mgr(&mgr), _radio_prefs(NULL) { outbound = NULL; total_air_time = rx_air_time = 0; @@ -163,19 +165,37 @@ class Dispatcher { virtual void logTxFail(Packet* packet, int len) { } virtual const char* getLogDateTime() { return ""; } - virtual float getAirtimeBudgetFactor() const; - virtual int calcRxDelay(float score, uint32_t air_time) const; + // Tuning hooks. Once settings are attached with useRadioPrefs(), the implementations + // below read them; without settings they return the built-in defaults noted here. + // Subclasses can still override any of them. + virtual float getAirtimeBudgetFactor() const; // default: 1.0 + virtual int calcRxDelay(float score, uint32_t air_time) const; // default: base 10 virtual uint32_t getCADFailRetryDelay() const; virtual uint32_t getCADFailMaxDuration() const; - virtual int getInterferenceThreshold() const { return 0; } // disabled by default - virtual bool getCADEnabled() const { return false; } // hardware CAD disabled by default - virtual int getAGCResetInterval() const { return 0; } // disabled by default + virtual int getInterferenceThreshold() const { // default: disabled + return _radio_prefs ? _radio_prefs->interference_threshold : 0; + } + virtual bool getCADEnabled() const { // default: hardware CAD disabled + return _radio_prefs ? _radio_prefs->cad_enabled : false; + } + virtual int getAGCResetInterval() const { // milliseconds, default: disabled + return _radio_prefs ? ((int)_radio_prefs->agc_reset_interval) * 4000 : 0; + } virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } public: void begin(); void loop(); + /** + * \brief Attaches the node's radio settings. From then on the tuning hooks + * (duty cycle budget, RX delay, interference threshold, CAD, AGC reset, and in + * Mesh the retransmit delays and extra ACKs) read their values from `prefs` at + * the time of each call, so changes made through the CLI apply right away. + * `prefs` must outlive this object. Without it the built-in defaults apply. + */ + void useRadioPrefs(const RadioPrefsFields* prefs) { _radio_prefs = prefs; } + Packet* obtainNewPacket(); void releasePacket(Packet* packet); void sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis=0); diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 9dc42f0b04b..010fec590f0 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -15,15 +15,23 @@ bool Mesh::allowPacketForward(const mesh::Packet* packet) { return false; // by default, Transport NOT enabled } uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) { + if (_radio_prefs) { // random delay of up to 5 x the scaled airtime of the packet + uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _radio_prefs->tx_delay_factor); + return _rng->nextInt(0, 5*t + 1); + } uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52 / 50) / 2; return _rng->nextInt(0, 5)*t; } uint32_t Mesh::getDirectRetransmitDelay(const Packet* packet) { + if (_radio_prefs) { // as getRetransmitDelay(), with the direct delay factor + uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _radio_prefs->direct_tx_delay_factor); + return _rng->nextInt(0, 5*t + 1); + } return 0; // by default, no delay } uint8_t Mesh::getExtraAckTransmitCount() const { - return 0; + return _radio_prefs ? _radio_prefs->multi_acks : 0; } uint32_t Mesh::getCADFailRetryDelay() const { diff --git a/src/Mesh.h b/src/Mesh.h index 22bb5c6d592..796a161f6f4 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -58,16 +58,19 @@ class Mesh : public Dispatcher { /** * \returns number of milliseconds delay to apply to retransmitting the given packet. + * With radio settings attached (useRadioPrefs()), random up to 5 x airtime x tx_delay_factor. */ virtual uint32_t getRetransmitDelay(const Packet* packet); /** * \returns number of milliseconds delay to apply to retransmitting the given packet, for DIRECT mode. + * With radio settings attached, as above with direct_tx_delay_factor; otherwise no delay. */ virtual uint32_t getDirectRetransmitDelay(const Packet* packet); /** * \returns number of extra (Direct) ACK transmissions wanted. + * With radio settings attached, their multi_acks; otherwise none. */ virtual uint8_t getExtraAckTransmitCount() const; diff --git a/src/RadioPrefsFields.h b/src/RadioPrefsFields.h new file mode 100644 index 00000000000..8702ab9badb --- /dev/null +++ b/src/RadioPrefsFields.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +/** + * \brief The radio settings every node role persists: LoRa modulation, TX power, + * timing factors and radio front-end options. + * + * Defined once here and inherited by both NodePrefs classes (the server roles' + * one in helpers/CommonNodePrefs.h and the companion radio's one), so a radio setting has + * exactly one name, type and default for all firmwares. Inheriting, rather than + * holding it as a member, keeps the existing `prefs.freq` style of access working + * for every caller. + * + * The core reads the tuning values it needs from here once a node attaches its + * settings with Dispatcher::useRadioPrefs(). + */ +struct RadioPrefsFields { + // ordered by size, so the struct has no padding inside + float freq = 0; // MHz + float bw = 0; // kHz + float airtime_factor = 0; // duty cycle budget: 100% / (airtime_factor + 1) + float rx_delay_base = 0; + float tx_delay_factor = 0; // flood retransmit delay factor + float direct_tx_delay_factor = 0; // direct retransmit delay factor + uint8_t sf = 0; // spreading factor + uint8_t cr = 0; // coding rate (4/cr) + int8_t tx_power_dbm = 0; + uint8_t interference_threshold = 0; + uint8_t agc_reset_interval = 0; // seconds / 4 + uint8_t rx_boosted_gain = 0; // RX boosted gain mode (0 = power saving, 1 = boosted) + uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) + uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain + uint8_t path_hash_mode = 0; // which path hash size to use when sending + uint8_t multi_acks = 0; + uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) +}; diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index a7f1ad3f6b2..aece93315e3 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -1,37 +1,7 @@ #pragma once #include "ConfigSerializer.h" #include "KeyValueStore.h" - -/** - * \brief The radio settings every node role persists: LoRa modulation, TX power, - * timing factors and radio front-end options. - * - * Defined once here and inherited by both NodePrefs classes (the server roles' - * one in CommonNodePrefs.h and the companion radio's one), so a radio setting has - * exactly one name, type and default for all firmwares. Inheriting, rather than - * holding it as a member, keeps the existing `prefs.freq` style of access working - * for every caller. - */ -struct RadioPrefsFields { - // ordered by size, so the struct has no padding inside - float freq = 0; // MHz - float bw = 0; // kHz - float airtime_factor = 0; // duty cycle budget: 100% / (airtime_factor + 1) - float rx_delay_base = 0; - float tx_delay_factor = 0; // flood retransmit delay factor - float direct_tx_delay_factor = 0; // direct retransmit delay factor - uint8_t sf = 0; // spreading factor - uint8_t cr = 0; // coding rate (4/cr) - int8_t tx_power_dbm = 0; - uint8_t interference_threshold = 0; - uint8_t agc_reset_interval = 0; // seconds / 4 - uint8_t rx_boosted_gain = 0; // RX boosted gain mode (0 = power saving, 1 = boosted) - uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) - uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain - uint8_t path_hash_mode = 0; // which path hash size to use when sending - uint8_t multi_acks = 0; - uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) -}; +#include /** * \brief The "radio" object of /prefs.json, the radio commands of the CLI and the diff --git a/test/test_core_tuning/test_core_tuning.cpp b/test/test_core_tuning/test_core_tuning.cpp index a2671eab75e..22902051b74 100644 --- a/test/test_core_tuning/test_core_tuning.cpp +++ b/test/test_core_tuning/test_core_tuning.cpp @@ -1,6 +1,7 @@ -// Characterization tests for the tuning hooks of mesh::Dispatcher and mesh::Mesh: -// the values the core uses for duty cycle, RX delay, retransmit delays, extra ACKs, -// interference threshold, CAD and AGC reset when no role overrides them. They run +// Tests for the tuning hooks of mesh::Dispatcher and mesh::Mesh: the values the +// core uses for duty cycle, RX delay, retransmit delays, extra ACKs, interference +// threshold, CAD and AGC reset when no role overrides them, without and with radio +// settings attached through useRadioPrefs(). They run // the real Dispatcher.cpp and Mesh.cpp against fakes for the radio, clock, RNG, // packet manager and tables. @@ -158,6 +159,112 @@ TEST(CoreTuningDefaults, RetransmitDelaysUseRawLength) { } } +// With settings attached, the core returns what the role overrides of the repeater, +// room server and companion returned in v1.18.0 (all three were identical), e.g. +// examples/simple_repeater/MyMesh.h:131-158 and MyMesh.cpp:542-554. +namespace { + +RadioPrefsFields makePrefs(float af, float rx_base, float tx_f, float direct_f, + uint8_t thr, uint8_t agc, uint8_t cad, uint8_t acks) { + RadioPrefsFields p; + p.airtime_factor = af; + p.rx_delay_base = rx_base; + p.tx_delay_factor = tx_f; + p.direct_tx_delay_factor = direct_f; + p.interference_threshold = thr; + p.agc_reset_interval = agc; + p.cad_enabled = cad; + p.multi_acks = acks; + return p; +} + +// v1.18.0 role override: calcRxDelay() +int roleRxDelay(const RadioPrefsFields& prefs, float score, uint32_t air_time) { + if (prefs.rx_delay_base <= 0.0f) return 0; + return (int)((pow(prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); +} + +// v1.18.0 role override: getRetransmitDelay() / getDirectRetransmitDelay() +uint32_t roleRetransmitDelay(mesh::Radio& radio, mesh::RNG& rng, const mesh::Packet& pkt, float factor) { + uint32_t t = (radio.getEstAirtimeFor(pkt.getPathByteLen() + pkt.payload_len + 2) * factor); + return rng.nextInt(0, 5*t + 1); +} + +const RadioPrefsFields kPrefsSets[] = { + makePrefs(1.0f, 0.0f, 0.5f, 0.2f, 0, 0, 0, 0), // typical repeater defaults + makePrefs(2.0f, 3.25f, 0.75f, 0.3f, 14, 5, 1, 1), + makePrefs(9.0f, 20.0f, 2.0f, 2.0f, 255, 255, 1, 2), + makePrefs(0.0f, -1.0f, 0.0f, 0.0f, 1, 1, 0, 0), +}; + +} // namespace + +TEST(CoreTuningWithPrefs, DutyCycleInterferenceCadAgcAndAcks) { + for (const RadioPrefsFields& prefs : kPrefsSets) { + Harness h; + h.mesh.useRadioPrefs(&prefs); + EXPECT_FLOAT_EQ(prefs.airtime_factor, h.mesh.getAirtimeBudgetFactor()); + EXPECT_EQ((int)prefs.interference_threshold, h.mesh.getInterferenceThreshold()); + EXPECT_EQ((bool)prefs.cad_enabled, h.mesh.getCADEnabled()); + EXPECT_EQ(((int)prefs.agc_reset_interval) * 4000, h.mesh.getAGCResetInterval()); + EXPECT_EQ(prefs.multi_acks, h.mesh.getExtraAckTransmitCount()); + } +} + +TEST(CoreTuningWithPrefs, RxDelayMatchesTheRoleFormula) { + for (const RadioPrefsFields& prefs : kPrefsSets) { + Harness h; + h.mesh.useRadioPrefs(&prefs); + for (float score : {0.0f, 0.3f, 0.85f, 1.0f}) { + for (uint32_t air : {0u, 100u, 1234u, 60000u}) { + EXPECT_EQ(roleRxDelay(prefs, score, air), h.mesh.calcRxDelay(score, air)) + << "rx_delay_base " << prefs.rx_delay_base << " score " << score << " air " << air; + } + } + } +} + +TEST(CoreTuningWithPrefs, RetransmitDelaysMatchTheRoleFormula) { + for (mesh::Packet pkt : makePackets()) { + for (const RadioPrefsFields& prefs : kPrefsSets) { + Harness h; + h.mesh.useRadioPrefs(&prefs); + SequenceRNG expected_rng(kRandom); + for (size_t i = 0; i < kRandom.size(); i++) { + EXPECT_EQ(roleRetransmitDelay(h.radio, expected_rng, pkt, prefs.tx_delay_factor), h.mesh.getRetransmitDelay(&pkt)); + EXPECT_EQ(roleRetransmitDelay(h.radio, expected_rng, pkt, prefs.direct_tx_delay_factor), h.mesh.getDirectRetransmitDelay(&pkt)); + } + } + } +} + +// The roles read their prefs on every call, so a CLI change applied immediately. +// The core keeps that: it holds a pointer, not a copy. +TEST(CoreTuningWithPrefs, ChangesApplyImmediately) { + RadioPrefsFields prefs = kPrefsSets[0]; + Harness h; + h.mesh.useRadioPrefs(&prefs); + EXPECT_FALSE(h.mesh.getCADEnabled()); + prefs.cad_enabled = 1; + prefs.airtime_factor = 4.0f; + prefs.multi_acks = 1; + EXPECT_TRUE(h.mesh.getCADEnabled()); + EXPECT_FLOAT_EQ(4.0f, h.mesh.getAirtimeBudgetFactor()); + EXPECT_EQ(1, h.mesh.getExtraAckTransmitCount()); +} + +// Detaching the settings returns to the built-in defaults. +TEST(CoreTuningWithPrefs, DetachingRestoresTheDefaults) { + RadioPrefsFields prefs = kPrefsSets[1]; + Harness h; + h.mesh.useRadioPrefs(&prefs); + h.mesh.useRadioPrefs(NULL); + EXPECT_FLOAT_EQ(1.0f, h.mesh.getAirtimeBudgetFactor()); + EXPECT_EQ(6079, h.mesh.calcRxDelay(0.0f, 1000)); + EXPECT_FALSE(h.mesh.getCADEnabled()); + EXPECT_EQ(0, h.mesh.getExtraAckTransmitCount()); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From e0dbc46fb33685a06d51791ba7fcfa52cd75fca0 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 19:28:21 +0200 Subject: [PATCH 009/136] docs(refactoring): design note for step 2, the core reading its tuning values - docs/refactoring/0002-core-reads-radio-prefs.md: context (31 role overrides, 29 identical), decision, rejected alternatives (constructor argument, copying values on change, removing the hooks), compatibility and the verification: host tests on the real Dispatcher and Mesh, mutation tests, sizes for 18 environments against step 1 and dev, the pow/powf check on every object file, and the hardware run with a diagnostic build on a room server and a companion. - README.md: step 2 in the status table. - architecture-review-v1.18.md: which part of finding F1 step 2 addresses. --- .../0002-core-reads-radio-prefs.md | 172 ++++++++++++++++++ docs/refactoring/README.md | 3 +- docs/refactoring/architecture-review-v1.18.md | 5 +- 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 docs/refactoring/0002-core-reads-radio-prefs.md diff --git a/docs/refactoring/0002-core-reads-radio-prefs.md b/docs/refactoring/0002-core-reads-radio-prefs.md new file mode 100644 index 00000000000..1ca8c6975bd --- /dev/null +++ b/docs/refactoring/0002-core-reads-radio-prefs.md @@ -0,0 +1,172 @@ +# 0002: Let the core read its tuning values from the radio settings + +Status: ready for review. Behaviour change: none. Builds on 0001. + +## Context + +The core needs a handful of values that users set through the CLI or the +companion app: the duty cycle budget, the RX delay base, the flood and direct +retransmit delay factors, the interference threshold, CAD, the AGC reset interval +and the number of extra ACKs. It asked for each of them through a virtual getter +(`src/Dispatcher.h`, `src/Mesh.h`), and every role answered with a one-line +override that returned its own `_prefs` field: + +| Hook | Repeater | Room server | Companion | Sensor | +|------|----------|-------------|-----------|--------| +| `getAirtimeBudgetFactor()` | same | same | same | same | +| `calcRxDelay()` | same | same | same | same | +| `getInterferenceThreshold()` | same | same | same | same | +| `getCADEnabled()` | same | same | same | same | +| `getAGCResetInterval()` | same | same | same | same | +| `getRetransmitDelay()` | same | same | same | **own formula** | +| `getDirectRetransmitDelay()` | same | same | same | **own formula** | +| `getExtraAckTransmitCount()` | same | same | same | **not overridden** | + +31 overrides, 29 of them identical. A new core setting needed a new virtual and +an override in every role: making CAD a setting (`7c8e0924`) touched 15 files. + +Since 0001 both `NodePrefs` classes share these fields through +`RadioPrefsFields`, so the core can read them from one place. + +## Decision + +1. `RadioPrefsFields` moves into the core as `src/RadioPrefsFields.h`, a header + without dependencies. `helpers/CommonRadioPrefs.h` includes it. +2. `Dispatcher::useRadioPrefs(const RadioPrefsFields*)` attaches a node's + settings. The default implementations of the eight hooks read them on every + call, so a CLI change applies at once, exactly like the role overrides did. + The formulas moved verbatim from the role overrides. +3. Without attached settings, every hook returns the built-in default it returned + before. All hooks stay virtual. +4. Repeater, room server, sensor and companion attach their `_prefs` in their + constructor and drop the 29 identical overrides. +5. The sensor keeps what differs: its two retransmit delay formulas (it draws + from six discrete steps where the other roles draw from a continuous range), + and it now states explicitly that it sends no extra ACKs. It never did, + although the CLI accepts `multi.acks` there. Aligning the sensor with the other + roles changes its behaviour on air and is left to a separate change. + +## Alternatives considered + +- **Pass the settings to the constructor of `Dispatcher` / `Mesh`.** Rejected: + it changes the constructor every downstream subclass calls. A setter is + additive. +- **Copy the values into the core on change.** Rejected: every code path that + changes a setting (CLI, companion frames, UI, board key/value access) would + have to notify the core, and a missed one would silently keep the old value. + Reading through a pointer keeps the current behaviour of the overrides. +- **Remove the virtual hooks.** Rejected: downstream firmware overrides them. + +## Compatibility + +- Behaviour of all roles is unchanged (see verification). +- Downstream subclasses that override any of the hooks keep working. Code that + does not call `useRadioPrefs()` gets the same defaults as before. +- `RadioPrefsFields` is now declared in `src/RadioPrefsFields.h`; it is still + available through `helpers/CommonRadioPrefs.h`. + +## Verification + +### Host tests + +A new native environment, `native_core`, builds the real `src/Dispatcher.cpp` and +`src/Mesh.cpp` for the host for the first time, against fakes for the radio, +clock, RNG, packet manager and tables, and link-time stand-ins for the four +`Identity` members `Mesh.cpp` references (they need the board crypto libraries; +the tests never call them). The unit test workflow runs it. + +The step is two commits. The first only adds the tests; they pin what the core +returns without settings and pass on the code before the change: + +- airtime budget factor 1.0, RX delay with base 10, retransmit delay from the raw + packet length, no direct delay, no extra ACKs, interference threshold, CAD and + AGC reset off. + +The second commit adds tests that, with settings attached, the core returns +exactly what the v1.18.0 role overrides returned (their formulas are copied into +the test with the source lines), for four sets of settings, two kinds of packets +(with and without transport codes) and a range of scores and airtimes; that +settings changed at run time apply immediately; and that detaching restores the +defaults. The tests from the first commit pass unchanged. + +Mutation tests, each of which must fail: + +| Mutation | Result | +|----------|--------| +| airtime budget factor ignores the settings | 2 tests fail | +| RX delay without the "base <= 0 disables" check | 1 test fails | +| flood retransmit delay counts the raw packet length | 1 test fails | +| direct retransmit delay uses the flood factor | 1 test fails | +| extra ACKs ignore the settings | 2 tests fail | + +### Builds + +The same 18 environments as in 0001 were built from this step and from 0001, +with the same toolchain. All build. Compared with 0001, the step is between 136 +bytes smaller and 464 bytes larger in flash, and RAM grows by 0 or 8 bytes. The +only new data member is the settings pointer in `Dispatcher`; the core now holds +both the built-in default and the settings-based formula of each hook, while +the identical overrides are gone from the roles. Together with 0001, every firmware is still +smaller than `dev`. + +| Environment | Platform | Flash vs 0001 | RAM vs 0001 | Flash vs `dev` | RAM vs `dev` | +|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32-S3 | -100 | +0 | -1064 | +0 | +| `Heltec_v3_room_server` | ESP32-S3 | -76 | +0 | -1012 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32-S3 | -136 | +8 | -1040 | +8 | +| `Heltec_v3_companion_radio_wifi` | ESP32-S3 | -116 | +8 | -1048 | +8 | +| `Heltec_v3_sensor` | ESP32-S3 | +160 | +8 | -800 | +8 | +| `RAK_4631_repeater` | nRF52840 | +160 | +0 | -336 | +0 | +| `RAK_4631_room_server` | nRF52840 | +208 | +0 | -304 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52840 | +192 | +8 | -288 | +8 | +| `RAK_4631_sensor` | nRF52840 | +464 | +8 | -64 | +8 | +| `PicoW_repeater` | RP2040 | -32 | +0 | -688 | +0 | +| `wio-e5-mini_repeater` | STM32WL | -32 | +0 | -584 | +0 | +| `wio-e5-mini_sensor` | STM32WL | +120 | +8 | -448 | +8 | +| `RAK_3112_repeater_bridge_espnow` | ESP32-S3 | -92 | +0 | -1048 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | -32 | +0 | -680 | +0 | +| `ProMicro_repeater` | nRF52840 | +160 | +0 | -352 | +0 | +| `ProMicro_room_server` | nRF52840 | +208 | +0 | -288 | +0 | +| `ProMicro_companion_radio_usb` | nRF52840 | +192 | +8 | -288 | +8 | +| `ProMicro_sensor` | nRF52840 | +464 | +8 | -64 | +8 | + +The RX delay formula moved from the roles into `Dispatcher.cpp`, which is compiled +with different headers. In all 18 environments the role object files of 0001 +call `powf` for it, and `Dispatcher.cpp` of this step calls `powf` too (checked +with `nm` on every object file), so the computed delays are bit for bit the same. +`Dispatcher.cpp` still calls `pow` (ESP32, RP2040, STM32) or `exp` (nRF52) for +the built-in default, as before. + +### Hardware + +### Hardware + +The hooks only change values inside the running core, which no command shows. +For the hardware run, one identical diagnostic patch (a `diag tuning` CLI command +that prints what the running core returns for the duty cycle budget, the RX delay +at score 0.3 and 1000 ms airtime, the interference threshold, CAD, the AGC reset +interval and the extra ACKs; not part of any commit) was applied to `dev` and to +this step, and both were run on two LilyGo T-Echo boards (nRF52840, SX1262): +room server (server roles' `NodePrefs`) and USB companion (companion `NodePrefs`). + +Sequence on each board: flash `dev`, change the settings through the CLI, read +the core values, reboot, read; flash this step, read; change the settings again +without a reboot, read; reboot, read; flash `dev`, read. On both boards: + +- `dev` and this step returned the same core values for the same settings, in + both directions: `af100=200 rx=991 thr=12 cad=1 agc=40000 acks=1` for the first + set and `af100=150 rx=562 thr=7 cad=0 agc=24000 acks=0` for the second. +- These are the values the settings imply (for example `rx=991` is + `(3.5^0.55 - 1) x 1000` for an RX delay base of 3.5). +- A change through the CLI showed up in the core immediately, without a reboot. + +The retransmit delays are random and were covered by the host tests only. The +sensor role, the BLE and WiFi companions and the ESP32, RP2040 and STM32 +platforms were built, not run on hardware. + +## Follow-ups + +- Decide whether the sensor should use the same retransmit delays and honour + `multi.acks` like the other roles (behaviour change, own pull request). +- Remove the 2 second polling of CAD and the interference threshold in + `Dispatcher::loop()` by pushing changes to the radio when they happen. diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index 04580f72966..8b3ef30713e 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -48,9 +48,10 @@ The full analysis, with file and line references for every finding, is in | # | Step | Design note | Status | |---|------|-------------|--------| | 1 | Share the radio and GPS settings between the server roles' and the companion's NodePrefs | [0001](0001-shared-radio-gps-prefs.md) | ready for review | +| 2 | Let the core read its tuning values from the radio settings instead of a getter per role | [0002](0002-core-reads-radio-prefs.md) | ready for review | Planned next steps (each needs its own design note before code), in order of -expected benefit: one settings module with one table per setting group; +expected benefit: one table per setting group for the radio CLI; a shared base for repeater, room server and sensor; one generic variant target built from a board description; one RadioLib adapter per chip family; a companion frame codec with a command table. See the review for the reasoning. diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md index 9df98415502..93bd0444fb2 100644 --- a/docs/refactoring/architecture-review-v1.18.md +++ b/docs/refactoring/architecture-review-v1.18.md @@ -45,8 +45,9 @@ or where a value cannot reach the code that needs it. - The role callbacks `setTxPower()` and `setRxBoostedGain()` declared in `CommonCLICallbacks` have no caller anywhere in `src/`, `examples/` or `variants/`. -Addressed by step 1 (shared radio and GPS settings); the remaining points are -candidates for the next steps. +Step 1 shares the radio and GPS settings between both classes; step 2 lets the +core read its tuning values from them instead of a getter per role. The +remaining points are candidates for the next steps. ### F2. Server roles carry the same code three times From 3ecb6a32d842cdf5fdc10d70c7a7f638a5465bb0 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 19:28:53 +0200 Subject: [PATCH 010/136] tools(refactoring): add the diagnostic patch used for the step 2 hardware run diag-tuning.patch adds a "diag tuning" CLI command to the room server and the companion that prints the values the running core uses for its tuning hooks. Applied identically to dev and to step 2, it showed the same core values for the same settings on both. For testing only; it lives on the integration branch, not on a step branch. --- tools/refactoring/README.md | 14 ++++++++++++ tools/refactoring/diag-tuning.patch | 34 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 tools/refactoring/diag-tuning.patch diff --git a/tools/refactoring/README.md b/tools/refactoring/README.md index 1cecf950b79..b069d8d282e 100644 --- a/tools/refactoring/README.md +++ b/tools/refactoring/README.md @@ -31,6 +31,20 @@ PLATFORMIO_BUILD_FLAGS='-D FIRMWARE_BUILD_DATE=\"step1-\"' pio run -e Li `PLATFORMIO_BUILD_FLAGS` makes PlatformIO clean the whole build folder of the project, so build the marked images in a separate worktree. +## Diagnostic build for step 2 + +`diag-tuning.patch` adds a `diag tuning` command to the room server and the +companion that prints what the running core uses for its tuning hooks (duty cycle +budget, RX delay at score 0.3 and 1000 ms airtime, interference threshold, CAD, +AGC reset interval, extra ACKs). Apply the same patch to `dev` and to the step, +build both with a marker, and compare the output for the same settings: + +```sh +git apply tools/refactoring/diag-tuning.patch +``` + +The patch is for testing only and is not part of any step. + ## Restoring a board Since v1.17 every role reads `/prefs.json`. Flashing the old firmware back keeps diff --git a/tools/refactoring/diag-tuning.patch b/tools/refactoring/diag-tuning.patch new file mode 100644 index 00000000000..e5b933d7a55 --- /dev/null +++ b/tools/refactoring/diag-tuning.patch @@ -0,0 +1,34 @@ +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index 5b14e7d0..56befd36 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -2050,6 +2050,12 @@ void MyMesh::enterCLIRescue() { + } + + bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { ++ if (strcmp(command, "diag tuning") == 0) { // diagnostic build only, not part of any commit ++ sprintf(reply, "> af100=%d rx=%d thr=%d cad=%d agc=%d acks=%d", (int)(getAirtimeBudgetFactor() * 100), ++ calcRxDelay(0.3f, 1000), getInterferenceThreshold(), (int)getCADEnabled(), getAGCResetInterval(), ++ (int)getExtraAckTransmitCount()); ++ return true; ++ } + while (*command == ' ') command++; // skip leading spaces + + if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) +diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp +index 10cca41c..1efd7eae 100644 +--- a/examples/simple_room_server/MyMesh.cpp ++++ b/examples/simple_room_server/MyMesh.cpp +@@ -885,6 +885,12 @@ void MyMesh::formatPacketStatsReply(char *reply) { + } + + void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { ++ if (strcmp(command, "diag tuning") == 0) { // diagnostic build only, not part of any commit ++ sprintf(reply, "> af100=%d rx=%d thr=%d cad=%d agc=%d acks=%d", (int)(getAirtimeBudgetFactor() * 100), ++ calcRxDelay(0.3f, 1000), getInterferenceThreshold(), (int)getCADEnabled(), getAGCResetInterval(), ++ (int)getExtraAckTransmitCount()); ++ return; ++ } + if (region_load_active) { + if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation + region_map = temp_map; // copy over the temp instance as new current map From b49eb6cb431b08e03bb17702c418ec8005f48d39 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 20:08:58 +0200 Subject: [PATCH 011/136] test(prefs): pin the edge cases of the radio CLI Adds RadioCliEdgeCases for both NodePrefs classes: boundaries and out of range values for every radio command, values that do not fit the field (int.thresh 300 is stored as 44, tx -5 reads back as 251), garbage input (atof/atoi read it as 0), NaN and infinity (rejected by "set rxdelay" and "set txdelay"), prefix look-alikes ("get tx foo" is "get tx", "set afx" is not handled), commands owned by other handlers ("set freq") and the key/value store exposing only the FEM gains. The expected values are what the current code returns, including the odd ones, which are kept on purpose. "set af nan" is accepted by IEEE builds. nRF52 builds compile with -Ofast, whose -ffast-math lets float comparisons assume that NaN never occurs, so the result there depends on the compiler; that case is only checked when __FAST_MATH__ is not defined. NaN is detected through its bits, not std::isnan, for the same reason. Why: the next change makes the radio CLI table driven. These cases are where a generic parser would most likely differ from the hand-written branches. --- test/mocks/RadioPrefsChecks.h | 159 ++++++++++++++++++ .../test_common_node_prefs.cpp | 5 + .../test_companion_node_prefs.cpp | 5 + 3 files changed, 169 insertions(+) diff --git a/test/mocks/RadioPrefsChecks.h b/test/mocks/RadioPrefsChecks.h index fa990ca6a74..11653331084 100644 --- a/test/mocks/RadioPrefsChecks.h +++ b/test/mocks/RadioPrefsChecks.h @@ -6,6 +6,7 @@ // way, so the same checks run against both. #include +#include #include #include @@ -188,3 +189,161 @@ void expectCustomStoreReachesFemGains(P& p) { EXPECT_EQ(0, p.radio_fem_rxgain); EXPECT_TRUE(p.isDirty()); } + +// NaN test that does not rely on float comparisons, which -ffast-math may assume +// never see a NaN (nRF52 builds use -Ofast). +inline bool isNanBits(float v) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + return (bits & 0x7F800000) == 0x7F800000 && (bits & 0x007FFFFF) != 0; +} + +// Edge cases of the radio CLI: boundaries, out of range values, values that do not +// fit the field, garbage, "nan", prefix look-alikes. The expected replies and field +// values are what the code returned before the CLI became table driven; some of them +// are odd (noted inline) and are kept on purpose, because changing them would change +// what users see. +template +void expectRadioCliEdgeCases(P& p) { + radio_driver.reset(); + fillRadioAndGps(p); + + auto accepted = [&p](const char* command) { + p.clearDirty(); + std::string reply = radioCli(p, command); + EXPECT_TRUE(p.isDirty()) << command; + return reply; + }; + auto rejected = [&p](const char* command) { + p.clearDirty(); + std::string reply = radioCli(p, command); + EXPECT_FALSE(p.isDirty()) << command; + return reply; + }; + auto unhandled = [&p](const char* command) { + char reply[160]; + reply[0] = 0; + EXPECT_FALSE(p.getRadioPrefs()->handleCommand(command, 0, reply)) << command; + }; + + // af: strtof with an end check, range 0..9 + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af abc")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af -1")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af 9.01")); + EXPECT_FLOAT_EQ(1.5f, p.airtime_factor); + EXPECT_EQ("OK", accepted("set af 9")); + EXPECT_FLOAT_EQ(9.0f, p.airtime_factor); + EXPECT_EQ("OK", accepted("set af 0")); + EXPECT_FLOAT_EQ(0.0f, p.airtime_factor); +#ifndef __FAST_MATH__ + // odd: NaN passes the range check of "set af". With -ffast-math (nRF52 builds use + // -Ofast) float comparisons may assume that NaN never occurs, so what "set af nan" + // does there depends on the compiler; it is only pinned for IEEE builds. + EXPECT_EQ("OK", accepted("set af nan")); + EXPECT_TRUE(isNanBits(p.airtime_factor)); +#endif + + // dutycycle: derived from af, range 1..100 + EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle 0.5")); + EXPECT_EQ("OK - 100.0%", accepted("set dutycycle 100")); + EXPECT_FLOAT_EQ(0.0f, p.airtime_factor); + EXPECT_EQ("> 100.0%", radioCli(p, "get dutycycle")); + EXPECT_EQ("OK - 1.0%", accepted("set dutycycle 1")); + EXPECT_FLOAT_EQ(99.0f, p.airtime_factor); // odd: beyond the 0..9 of "set af" + EXPECT_EQ("OK - 33.0%", accepted("set dutycycle 33")); + + // int.thresh: atoi, no range, stored as uint8_t + EXPECT_EQ("OK", accepted("set int.thresh 300")); + EXPECT_EQ(44, p.interference_threshold); + EXPECT_EQ("OK", accepted("set int.thresh -1")); + EXPECT_EQ(255, p.interference_threshold); + EXPECT_EQ("OK", accepted("set int.thresh abc")); + EXPECT_EQ(0, p.interference_threshold); + + // cad: on only if the value starts with "on" + EXPECT_EQ("OK", accepted("set cad onx")); + EXPECT_EQ(1, p.cad_enabled); + EXPECT_EQ("OK", accepted("set cad ON")); + EXPECT_EQ(0, p.cad_enabled); + EXPECT_EQ("OK", accepted("set cad 1")); + EXPECT_EQ(0, p.cad_enabled); + + // radio.rxgain: same rule, pushed to the radio + EXPECT_EQ("OK", accepted("set radio.rxgain onfoo")); + EXPECT_EQ(1, p.rx_boosted_gain); + EXPECT_TRUE(radio_driver.rx_boosted); + + // tx: "get tx" also matches with trailing text; values wrap into int8_t + EXPECT_EQ("> 21", radioCli(p, "get tx foo")); + EXPECT_EQ("OK", accepted("set tx -5")); + EXPECT_EQ(-5, p.tx_power_dbm); + EXPECT_EQ(-5, radio_driver.tx_power); + EXPECT_EQ("> 251", radioCli(p, "get tx")); // odd: printed as unsigned + EXPECT_EQ("OK", accepted("set tx 300")); + EXPECT_EQ(44, p.tx_power_dbm); + + // rxdelay: atof, range 0..20, NaN rejected, garbage reads as 0 + EXPECT_EQ("OK", accepted("set rxdelay 20")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay 20.01")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay -0.01")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay nan")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay inf")); + EXPECT_FLOAT_EQ(20.0f, p.rx_delay_base); + EXPECT_EQ("OK", accepted("set rxdelay abc")); + EXPECT_FLOAT_EQ(0.0f, p.rx_delay_base); + + // agc.reset.interval: seconds, clamped to 0..1020, stored in 4 s units + EXPECT_EQ("OK - interval rounded to 1020", accepted("set agc.reset.interval 1023")); + EXPECT_EQ(255, p.agc_reset_interval); + EXPECT_EQ("OK - interval rounded to 0", accepted("set agc.reset.interval -5")); + EXPECT_EQ("OK - interval rounded to 0", accepted("set agc.reset.interval 3")); + EXPECT_EQ("OK - interval rounded to 0", accepted("set agc.reset.interval abc")); + EXPECT_EQ("> 0", radioCli(p, "get agc.reset.interval")); + + // path.hash.mode: atoi into uint8_t, must be < 3 + EXPECT_EQ("OK", accepted("set path.hash.mode 2")); + EXPECT_EQ("Error, must be 0,1, or 2", rejected("set path.hash.mode -1")); + EXPECT_EQ(2, p.path_hash_mode); + EXPECT_EQ("OK", accepted("set path.hash.mode abc")); + EXPECT_EQ(0, p.path_hash_mode); + + // multi.acks: atoi, no range, stored as uint8_t + EXPECT_EQ("OK", accepted("set multi.acks 300")); + EXPECT_EQ(44, p.multi_acks); + + // txdelay / direct.txdelay: atof, range 0..2, NaN rejected + EXPECT_EQ("OK", accepted("set txdelay 2")); + EXPECT_EQ("Error, must be 0-2", rejected("set txdelay 2.01")); + EXPECT_EQ("Error, must be 0-2", rejected("set txdelay nan")); + EXPECT_EQ("Error, must be 0-2", rejected("set txdelay inf")); + EXPECT_EQ("Error, must be 0-2", rejected("set direct.txdelay nan")); + EXPECT_EQ("OK", accepted("set direct.txdelay 0")); + EXPECT_EQ("Error, must be 0-2", rejected("set direct.txdelay -1")); + EXPECT_FLOAT_EQ(2.0f, p.tx_delay_factor); + EXPECT_FLOAT_EQ(0.0f, p.direct_tx_delay_factor); + + // radio: all four parts must be in range + EXPECT_EQ("Error, invalid radio params", rejected("set radio 869")); + EXPECT_EQ("Error, invalid radio params", rejected("set radio 149.9,125,9,5")); + EXPECT_EQ("Error, invalid radio params", rejected("set radio 433,6.9,9,5")); + EXPECT_EQ("Error, invalid radio params", rejected("set radio 433,125,13,5")); + EXPECT_EQ("Error, invalid radio params", rejected("set radio 433,125,9,9")); + EXPECT_EQ("OK - reboot to apply", accepted("set radio 150,7,5,5")); + EXPECT_EQ("> 150.0,7,5,5", radioCli(p, "get radio")); + EXPECT_EQ("OK - reboot to apply", accepted("set radio 2500,500,12,8")); + EXPECT_EQ("> 2500.0,500,12,8", radioCli(p, "get radio")); + + // look-alikes and commands other handlers own are not taken + unhandled("get radiox"); + unhandled("get af2"); + unhandled("set afx 1"); + unhandled("set txdelayx 1"); + unhandled("get txdelay2"); + unhandled("set freq 869"); + unhandled("get name"); + + // the key/value store only exposes the FEM gains + char value[16]; + EXPECT_FALSE(p.getRadioPrefs()->getByKey("af", value, sizeof(value))); + EXPECT_FALSE(p.getRadioPrefs()->setByKey("tx", "5")); +} diff --git a/test/test_common_node_prefs/test_common_node_prefs.cpp b/test/test_common_node_prefs/test_common_node_prefs.cpp index 9e7d57f2ecf..0bcb7bb4e01 100644 --- a/test/test_common_node_prefs/test_common_node_prefs.cpp +++ b/test/test_common_node_prefs/test_common_node_prefs.cpp @@ -102,6 +102,11 @@ TEST(CommonNodePrefs, RadioCliReadsAndWritesTheNodeFields) { expectRadioCliDrivesNodeFields(prefs); } +TEST(CommonNodePrefs, RadioCliEdgeCases) { + NodePrefs prefs; + expectRadioCliEdgeCases(prefs); +} + TEST(CommonNodePrefs, CustomStoreReachesTheFemGains) { NodePrefs prefs; expectCustomStoreReachesFemGains(prefs); diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index 24d7a4a069f..4bdad781cd8 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -88,6 +88,11 @@ TEST(CompanionNodePrefs, RadioCliReadsAndWritesTheNodeFields) { expectRadioCliDrivesNodeFields(prefs); } +TEST(CompanionNodePrefs, RadioCliEdgeCases) { + NodePrefs prefs; + expectRadioCliEdgeCases(prefs); +} + TEST(CompanionNodePrefs, CustomStoreReachesTheFemGains) { NodePrefs prefs; expectCustomStoreReachesFemGains(prefs); From 4d43e19d3745cf6e733b648ba8d2e1a55005c59a Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 20:12:02 +0200 Subject: [PATCH 012/136] refactor(prefs): drive the radio settings from one table What changes - CommonRadioPrefs.cpp gets a table with one row per radio setting: its /prefs.json key, its field in RadioPrefsFields, its type, its CLI name and how the CLI treats it (get only, float with an upper limit, uint8_t with an optional upper limit, on/off), the reply for out of range values, and whether boards may read and write it through the key/value store. - The table drives the "radio" object of /prefs.json (structure() moves from the header into the .cpp and loops over the rows), the key/value access (fem_rxgain, fem_txgain), and the CLI of the eight settings that fit a generic pattern: freq (get only), cad, int.thresh, rxdelay, txdelay, direct.txdelay, path.hash.mode, multi.acks. - The float range check of the table recognises NaN and infinity by their bits. nRF52 builds compile with -Ofast, whose -ffast-math lets float comparisons assume that NaN never occurs; on a T-Echo the first version of this change accepted "set rxdelay nan" while the old branch rejected it. The bit check rejects NaN and infinity on every platform and with any flags, which is what the old branches did on the boards. - New native environment native_ofast runs the prefs tests with -Ofast, so host tests see the optimisation the nRF52 firmware is built with. The unit test workflow runs it. - radio, af, dutycycle, radio.rxgain, tx and agc.reset.interval keep their hand-written branches unchanged (composite value, derived value, radio driver calls, unit conversion, prefix quirk). They were moved verbatim. Why - A radio setting used to be spelled out in three places in this file alone: a def() line, a get branch and a set branch with its own parsing, range check and reply. A new setting of a common kind is now one row. Compatibility - /prefs.json, CLI replies, dirty marking and the key/value store are unchanged: the characterization and edge case tests (including garbage input, values that do not fit the field, NaN, prefix look-alikes) pass unchanged for both NodePrefs classes. - The public interface of CommonRadioPrefs is unchanged except that structure() is no longer defined inline. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 15 ++ src/helpers/CommonRadioPrefs.cpp | 246 +++++++++++++++------------ src/helpers/CommonRadioPrefs.h | 21 +-- 4 files changed, 158 insertions(+), 126 deletions(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index dd3c25da338..8b2de2b4442 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -e native_core -vv + run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index d2f13c4e045..805aab6b1e2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -181,6 +181,21 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_ofast] +; the prefs tests again with -Ofast, the optimisation nRF52 builds use +; (it includes -ffast-math, under which float comparisons may assume no NaN) +platform = native +test_framework = googletest +build_flags = ${env:native.build_flags} + -Ofast +test_build_src = yes +test_filter = + test_common_node_prefs + test_companion_node_prefs +build_src_filter = ${env:native.build_src_filter} +lib_deps = + google/googletest @ 1.17.0 + [env:native_core] platform = native test_framework = googletest diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 41b179932a5..b6a543c43d8 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -2,33 +2,123 @@ #include "TxtDataHelpers.h" #include "Utils.h" #include "target.h" +#include -bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { - if (strcmp(key, "fem_rxgain") == 0) { - snprintf(value, max_len, "%d", (uint32_t)getFEMRxGain()); - return true; +namespace { + +enum FieldType : uint8_t { FIELD_F32, FIELD_U8, FIELD_I8 }; + +// How the generic code handles a setting on the CLI. +enum CliKind : uint8_t { + CLI_NONE, // no generic command (none at all, or a special one in handleCommand()) + CLI_GET_ONLY, // "get " only + CLI_FLOAT, // "set ": atof(), accepted if 0 <= value <= max (NaN and infinity rejected) + CLI_UINT8, // "set ": atoi() into uint8_t, accepted if value <= max (max < 0: no limit) + CLI_ON_OFF, // "set ": on if the value starts with "on", off otherwise +}; + +struct RadioSetting { + const char* json_key; // key in the "radio" object of /prefs.json (persisted format, never rename) + uint8_t offset; // the field in RadioPrefsFields + FieldType type; + const char* cli_name; // command name, or NULL + CliKind cli; + float max; // upper limit, see CliKind + const char* range_error; // reply when a value is out of range + bool key_value; // readable and writable through the key/value store (used by boards) +}; + +#define FIELD(f) offsetof(RadioPrefsFields, f) + +/** + * One row per radio setting. The order of the rows is the order of the keys in the + * "radio" object of /prefs.json. A new setting needs a field in RadioPrefsFields and a + * row here; settings whose CLI does not fit a CliKind get CLI_NONE and a branch in + * handleCommand(). + */ +const RadioSetting SETTINGS[] = { + // json key field type CLI name CLI kind max range error + { "freq", FIELD(freq), FIELD_F32, "freq", CLI_GET_ONLY, -1, NULL, false }, + { "bw", FIELD(bw), FIELD_F32, NULL, CLI_NONE, -1, NULL, false }, // "radio" + { "sf", FIELD(sf), FIELD_U8, NULL, CLI_NONE, -1, NULL, false }, // "radio" + { "cr", FIELD(cr), FIELD_U8, NULL, CLI_NONE, -1, NULL, false }, // "radio" + { "cad", FIELD(cad_enabled), FIELD_U8, "cad", CLI_ON_OFF, -1, NULL, false }, + { "int_thr", FIELD(interference_threshold), FIELD_U8, "int.thresh", CLI_UINT8, -1, NULL, false }, + { "rxgain", FIELD(rx_boosted_gain), FIELD_U8, NULL, CLI_NONE, -1, NULL, false }, // "radio.rxgain" + { "fem_rxgain", FIELD(radio_fem_rxgain), FIELD_U8, NULL, CLI_NONE, -1, NULL, true }, // board CLI + { "fem_txgain", FIELD(radio_fem_txgain), FIELD_U8, NULL, CLI_NONE, -1, NULL, true }, // board CLI + { "tx", FIELD(tx_power_dbm), FIELD_I8, NULL, CLI_NONE, -1, NULL, false }, // "tx" + { "af", FIELD(airtime_factor), FIELD_F32, NULL, CLI_NONE, -1, NULL, false }, // "af", "dutycycle" + { "rxdelay", FIELD(rx_delay_base), FIELD_F32, "rxdelay", CLI_FLOAT, 20, "Error, must be 0-20", false }, + { "f_txdelay", FIELD(tx_delay_factor), FIELD_F32, "txdelay", CLI_FLOAT, 2, "Error, must be 0-2", false }, + { "d_txdelay", FIELD(direct_tx_delay_factor), FIELD_F32, "direct.txdelay", CLI_FLOAT, 2, "Error, must be 0-2", false }, + { "agc_int", FIELD(agc_reset_interval), FIELD_U8, NULL, CLI_NONE, -1, NULL, false }, // "agc.reset.interval" + { "hash_mode", FIELD(path_hash_mode), FIELD_U8, "path.hash.mode", CLI_UINT8, 2, "Error, must be 0,1, or 2", false }, + { "multi_ack", FIELD(multi_acks), FIELD_U8, "multi.acks", CLI_UINT8, -1, NULL, false }, +}; + +const int NUM_SETTINGS = sizeof(SETTINGS) / sizeof(SETTINGS[0]); + +// True if v is a number in [0, max]. NaN and infinity are recognised by their bits: +// nRF52 builds use -Ofast, and its -ffast-math lets float comparisons assume that +// NaN and infinity never occur, so a comparison alone may let them through. +bool inRange(float v, float max) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + if ((bits & 0x7F800000) == 0x7F800000) return false; + return v >= 0 && v <= max; +} + +// "get " +bool isGet(const char* command, const char* name) { + return memcmp(command, "get ", 4) == 0 && strcmp(&command[4], name) == 0; +} + +// "set ": returns the value, or NULL if the command is something else +const char* setValue(const char* command, const char* name) { + size_t len = strlen(name); + if (memcmp(command, "set ", 4) != 0 || memcmp(&command[4], name, len) != 0 || command[4 + len] != ' ') return NULL; + return &command[5 + len]; +} + +} // namespace + +void CommonRadioPrefs::structure() { + for (int i = 0; i < NUM_SETTINGS; i++) { + const RadioSetting& s = SETTINGS[i]; + uint8_t* field = (uint8_t *) _f + s.offset; + switch (s.type) { + case FIELD_F32: def(s.json_key, *(float *) field); break; + case FIELD_U8: def(s.json_key, *(uint8_t *) field); break; + case FIELD_I8: def(s.json_key, *(int8_t *) field); break; + } } - if (strcmp(key, "fem_txgain") == 0) { - snprintf(value, max_len, "%d", (uint32_t)getFEMTxGain()); - return true; +} + +bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { + for (int i = 0; i < NUM_SETTINGS; i++) { + const RadioSetting& s = SETTINGS[i]; + if (s.key_value && s.type == FIELD_U8 && strcmp(key, s.json_key) == 0) { + snprintf(value, max_len, "%d", (uint32_t) *((uint8_t *) _f + s.offset)); + return true; + } } return false; } bool CommonRadioPrefs::setByKey(const char* key, const char* value) { - if (strcmp(key, "fem_rxgain") == 0) { - setFEMRxGain(atoi(value)); - markDirty(); - return true; - } - if (strcmp(key, "fem_txgain") == 0) { - setFEMTxGain(atoi(value)); - markDirty(); - return true; + for (int i = 0; i < NUM_SETTINGS; i++) { + const RadioSetting& s = SETTINGS[i]; + if (s.key_value && s.type == FIELD_U8 && strcmp(key, s.json_key) == 0) { + *((uint8_t *) _f + s.offset) = atoi(value); + markDirty(); + return true; + } } return false; } bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + // Settings whose CLI does not fit a CliKind. if (strcmp(command, "get radio") == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(getFreq())); @@ -57,11 +147,6 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest return true; } - if (strcmp(command, "get freq") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(getFreq())); - return true; - } - if (strcmp(command, "get af") == 0) { sprintf(reply, "> %s", StrHelper::ftoa(getAirtimeFactor())); return true; @@ -99,26 +184,6 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest return true; } - if (strcmp(command, "get int.thresh") == 0) { - sprintf(reply, "> %d", (uint32_t) getIntThresh()); - return true; - } - if (memcmp(command, "set int.thresh ", 15) == 0) { - setIntThresh(atoi(&command[15])); - strcpy(reply, "OK"); - return true; - } - - if (strcmp(command, "get cad") == 0) { - sprintf(reply, "> %s", isCadEnabled() ? "on" : "off"); - return true; - } - if (memcmp(command, "set cad ", 8) == 0) { - setCadEnabled(memcmp(&command[8], "on", 2) == 0); - strcpy(reply, "OK"); - return true; - } - if (strcmp(command, "get radio.rxgain") == 0) { sprintf(reply, "> %s", getRxGain() != 0 ? "on" : "off"); return true; @@ -145,21 +210,6 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest return true; } - if (strcmp(command, "get rxdelay") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(getRxDelay())); - return true; - } - if (memcmp(command, "set rxdelay ", 12) == 0) { - float db = atof(&command[12]); - if (db >= 0 && db <= 20.0f) { - setRxDelay(db); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0-20"); - } - return true; - } - if (strcmp(command, "get agc.reset.interval") == 0) { sprintf(reply, "> %d", (uint32_t) getAgcResetInt()); return true; @@ -172,59 +222,45 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest return true; } - if (strcmp(command, "get path.hash.mode") == 0) { - sprintf(reply, "> %d", (uint32_t)getHashMode()); - return true; - } - if (memcmp(command, "set path.hash.mode ", 19) == 0) { - const char* config = command + 19; - uint8_t mode = atoi(config); - if (mode < 3) { - setHashMode(mode); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0,1, or 2"); - } - return true; - } - - if (strcmp(command, "get multi.acks") == 0) { - sprintf(reply, "> %d", (uint32_t) getMultiAcks()); - return true; - } - if (memcmp(command, "set multi.acks ", 15) == 0) { - setMultiAcks(atoi(&command[15])); - strcpy(reply, "OK"); - return true; - } + // Every other setting, driven by SETTINGS. + for (int i = 0; i < NUM_SETTINGS; i++) { + const RadioSetting& s = SETTINGS[i]; + if (s.cli == CLI_NONE) continue; + uint8_t* field = (uint8_t *) _f + s.offset; - if (strcmp(command, "get txdelay") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(getFloodTxDelay())); - return true; - } - if (memcmp(command, "set txdelay ", 12) == 0) { - float f = atof(&command[12]); - if (f >= 0 && f <= 2.0f) { - setFloodTxDelay(f); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0-2"); + if (isGet(command, s.cli_name)) { + if (s.cli == CLI_ON_OFF) { + sprintf(reply, "> %s", *field ? "on" : "off"); + } else if (s.type == FIELD_F32) { + sprintf(reply, "> %s", StrHelper::ftoa(*(float *) field)); + } else { + sprintf(reply, "> %d", (uint32_t) *field); + } + return true; } - return true; - } + if (s.cli == CLI_GET_ONLY) continue; - if (strcmp(command, "get direct.txdelay") == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(getDirectTxDelay())); - return true; - } - if (memcmp(command, "set direct.txdelay ", 19) == 0) { - float f = atof(&command[19]); - if (f >= 0 && f <= 2.0f) { - setDirectTxDelay(f); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0-2"); + const char* value = setValue(command, s.cli_name); + if (value == NULL) continue; + if (s.cli == CLI_FLOAT) { + float v = atof(value); + if (!inRange(v, s.max)) { + strcpy(reply, s.range_error); + return true; + } + *(float *) field = v; + } else if (s.cli == CLI_UINT8) { + uint8_t v = atoi(value); + if (s.max >= 0 && v > s.max) { + strcpy(reply, s.range_error); + return true; + } + *field = v; + } else { // CLI_ON_OFF + *field = memcmp(value, "on", 2) == 0; } + markDirty(); + strcpy(reply, "OK"); return true; } diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index aece93315e3..0dff2a26de1 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -16,26 +16,7 @@ class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { bool _is_dirty = false; protected: - // JSON keys and their order are the persisted format: do not rename or reorder. - void structure() override { - def("freq", _f->freq); - def("bw", _f->bw); - def("sf", _f->sf); - def("cr", _f->cr); - def("cad", _f->cad_enabled); - def("int_thr", _f->interference_threshold); - def("rxgain", _f->rx_boosted_gain); - def("fem_rxgain", _f->radio_fem_rxgain); - def("fem_txgain", _f->radio_fem_txgain); - def("tx", _f->tx_power_dbm); - def("af", _f->airtime_factor); - def("rxdelay", _f->rx_delay_base); - def("f_txdelay", _f->tx_delay_factor); - def("d_txdelay", _f->direct_tx_delay_factor); - def("agc_int", _f->agc_reset_interval); - def("hash_mode", _f->path_hash_mode); - def("multi_ack", _f->multi_acks); - } + void structure() override; // the "radio" object of /prefs.json, see SETTINGS in CommonRadioPrefs.cpp public: explicit CommonRadioPrefs(RadioPrefsFields& fields) : _f(&fields) { } From 98ffa5c162594fff6c47798cc6c37a794dde50a2 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 20:46:33 +0200 Subject: [PATCH 013/136] docs(refactoring): design note for step 3, the radio settings table - docs/refactoring/0003-radio-settings-table.md: context (27 branches for 14 settings), the table and its CLI kinds, the six settings that keep hand-written branches and why, alternatives, compatibility, and the verification: about 70 edge cases pinned on the old code, mutation tests in IEEE and -Ofast host builds plus the GCC check in CI, sizes for 18 environments, and the hardware run of 58 commands on a room server and a companion, including the NaN difference it found in the first version. - README.md: step 3 in the status table. - architecture-review-v1.18.md: the -Ofast / NaN observation as issue 9. --- docs/refactoring/0003-radio-settings-table.md | 165 ++++++++++++++++++ docs/refactoring/README.md | 4 +- docs/refactoring/architecture-review-v1.18.md | 1 + 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 docs/refactoring/0003-radio-settings-table.md diff --git a/docs/refactoring/0003-radio-settings-table.md b/docs/refactoring/0003-radio-settings-table.md new file mode 100644 index 00000000000..49c17e62b2d --- /dev/null +++ b/docs/refactoring/0003-radio-settings-table.md @@ -0,0 +1,165 @@ +# 0003: Drive the radio settings from one table + +Status: ready for review. Behaviour change: none. Builds on 0001 and 0002. + +## Context + +After 0001 and 0002, all radio settings live in `RadioPrefsFields`, and +`CommonRadioPrefs` is the one place that persists them and handles their CLI. In +that place each setting was still spelled out up to three times: a `def()` line in +`structure()` for `/prefs.json`, a `get` branch and a `set` branch in +`handleCommand()` with their own parsing, range check and reply (27 branches for +14 settings), plus a branch in the key/value store for the two FEM gains. + +## Decision + +`src/helpers/CommonRadioPrefs.cpp` gets a table with one row per radio setting: + +| Column | Meaning | +|--------|---------| +| JSON key | key in the `radio` object of `/prefs.json`; the row order is the key order | +| field, type | the field in `RadioPrefsFields` (`offsetof`), float, uint8_t or int8_t | +| CLI name, CLI kind | `get ` / `set `, and how the value is parsed | +| max, range error | upper limit and the reply for an out of range value | +| key/value | whether boards may read and write it through the key/value store | + +CLI kinds, each reproducing the parsing of the branches they replace: + +- get only: `> value` +- float: `atof()`, accepted if `0 <= value <= max`; NaN and infinity are rejected + by looking at the bits (see below) +- uint8_t: `atoi()` into `uint8_t`, accepted if `value <= max` (no limit if none) +- on/off: on if the value starts with `on` + +The table drives `structure()`, the key/value store and the CLI of the eight +settings that fit: `freq` (get only), `cad`, `int.thresh`, `rxdelay`, `txdelay`, +`direct.txdelay`, `path.hash.mode`, `multi.acks`. Six settings keep their +hand-written branches, moved verbatim, because their CLI does not fit a kind: +`radio` (four values at once), `af` (a stricter parser that rejects garbage), +`dutycycle` (derived from `af`), `radio.rxgain` and `tx` (pushed to the radio +driver; `get tx` also matches with trailing text), `agc.reset.interval` (unit +conversion and its own reply). + +A new setting of a common kind is now a field and one row. + +## Alternatives considered + +- **Make the six special settings fit generic kinds too.** Rejected for this step: + each would need its own kind or a hook, and some of their quirks (NaN passes + `set af`, `get tx foo` reads the TX power, `dutycycle 1` sets an airtime factor + of 99 that `set af` would refuse) are visible to users. Changing them is a + behaviour change. +- **A table in the header with pointer-to-member fields.** Rejected: members of + different types do not fit one array without a type tag anyway, and `offsetof` + on the standard-layout `RadioPrefsFields` keeps the table plain data. +- **Generate the CLI documentation from the table.** Left for later. + +## Compatibility + +- `/prefs.json`, all CLI replies, the dirty marking and the key/value store are + unchanged. +- The public interface of `CommonRadioPrefs` is unchanged; its getters and setters + stay for source compatibility. `structure()` is now defined in the `.cpp`. + +## Verification + +### Host tests + +Before the change, a new test (`RadioCliEdgeCases`, run for both `NodePrefs` +classes) pinned about 70 edge cases of the radio CLI on the old code: boundaries, +out of range values, values that do not fit the field (`int.thresh 300` is stored +as 44, `tx -5` reads back as 251), garbage (read as 0 by `atof`/`atoi`), NaN +(accepted by `set af`, rejected by `set rxdelay`), prefix look-alikes and commands +other handlers own (`set freq`). Together with the characterization tests of 0001 +(exact `/prefs.json`, every command's reply and side effect) they pass unchanged +after the change. + +A new native environment, `native_ofast`, runs the prefs tests again with `-Ofast`, +the optimisation the nRF52 firmware is built with (see "NaN and -Ofast" below). +The unit test workflow runs it. + +Mutation tests (number of failing tests; `native` and `native_ofast` on the +author's machine use clang, the CI uses GCC): + +| Mutation | `native` | `native_ofast` (clang) | +|----------|---------:|-----------------------:| +| swap the `sf` and `cr` rows (JSON order) | 2 | 2 | +| float check as `v < 0 \|\| v > max` (lets NaN through) | 2 | 0 | +| uint8_t limit off by one | 2 | 2 | +| on/off compares three characters | 2 | 2 | +| `tx` exposed in the key/value store | 2 | 2 | +| `set ` without checking the space after the name | 2 | 2 | +| bit check removed, plain `!(v >= 0 && v <= max)` | 0 | 0 | + +The last row is the defect the hardware run found (below). Clang keeps rejecting +NaN with that comparison even under `-Ofast`; GCC does not. Pushed to a temporary +branch, the same mutation made the CI's `native_ofast` (GCC) fail with 2 failing +tests, while the unmutated branch passed. + +### NaN and -Ofast + +nRF52 firmware is compiled with `-Ofast` (set by PlatformIO's Adafruit nRF52 +builder), which includes `-ffast-math`: the compiler may assume that floats are +never NaN or infinite. The old hand-written branch `if (db >= 0 && db <= 20.0f)` +happened to reject NaN on the boards; the first version of the table, with an +equivalent comparison written differently, accepted `set rxdelay nan` on a T-Echo. +The table now recognises NaN and infinity by their bits, which rejects them with +any compiler and flags, as the old branches did on the boards and in IEEE builds. +`set af`, whose branch did not change, accepts `nan` on the boards before and after +this step (measured); how it behaves elsewhere depends on the compiler. + +### Builds + +The same 18 environments were built from this step and from 0002. All build. Every +firmware is 8 to 96 bytes smaller than with 0002; RAM is unchanged. + +| Environment | Platform | Flash vs 0002 | RAM vs 0002 | Flash vs `dev` | RAM vs `dev` | +|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32-S3 | -84 | +0 | -1148 | +0 | +| `Heltec_v3_room_server` | ESP32-S3 | -68 | +0 | -1080 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32-S3 | -8 | +0 | -1048 | +8 | +| `Heltec_v3_companion_radio_wifi` | ESP32-S3 | -68 | +0 | -1116 | +8 | +| `Heltec_v3_sensor` | ESP32-S3 | -56 | +0 | -856 | +8 | +| `RAK_4631_repeater` | nRF52840 | -96 | +0 | -432 | +0 | +| `RAK_4631_room_server` | nRF52840 | -80 | +0 | -384 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52840 | -64 | +0 | -352 | +8 | +| `RAK_4631_sensor` | nRF52840 | -64 | +0 | -128 | +8 | +| `PicoW_repeater` | RP2040 | -40 | +0 | -728 | +0 | +| `wio-e5-mini_repeater` | STM32WL | -24 | +0 | -608 | +0 | +| `wio-e5-mini_sensor` | STM32WL | -32 | +0 | -480 | +8 | +| `RAK_3112_repeater_bridge_espnow` | ESP32-S3 | -60 | +0 | -1108 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | -40 | +0 | -720 | +0 | +| `ProMicro_repeater` | nRF52840 | -64 | +0 | -416 | +0 | +| `ProMicro_room_server` | nRF52840 | -96 | +0 | -384 | +0 | +| `ProMicro_companion_radio_usb` | nRF52840 | -48 | +0 | -336 | +8 | +| `ProMicro_sensor` | nRF52840 | -64 | +0 | -128 | +8 | + +### Hardware + +### Hardware + +Two LilyGo T-Echo boards (nRF52840, SX1262), room server and USB companion, each +running the 0002 image and the image of this step in turn (marked builds, see +0001). A script of 58 radio commands (edge cases that are safe on air: no +frequency change, no extreme TX power) ran on both images from the same starting +values, through the serial console (room server) and `CMD_RUN_CLI_COMMAND` frames +(companion). + +- All 58 replies were identical between 0002 and this step on both boards, + including garbage, out of range values, NaN and infinity, and look-alikes. +- `set af nan` behaved the same on both images (accepted). +- Each image read the settings the other had written (upgrade and downgrade). + +The first version of this step failed this run: it answered `OK` to +`set rxdelay nan` and `set txdelay nan` where 0002 answered with an error. That +led to the bit check and to `native_ofast` described above. + +On the room server, the first read right after flashing the 0002 image twice +returned a reply for a garbled command (`??: txdelay`); repeated reads were +identical. It was not observed with the image of this step in these runs. + +## Follow-ups + +- Generate the radio part of `docs/cli_commands.md` from the table. +- Decide whether the quirks listed above should be fixed (behaviour change, own + pull request). diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index 8b3ef30713e..acdc1cebe22 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -49,10 +49,10 @@ The full analysis, with file and line references for every finding, is in |---|------|-------------|--------| | 1 | Share the radio and GPS settings between the server roles' and the companion's NodePrefs | [0001](0001-shared-radio-gps-prefs.md) | ready for review | | 2 | Let the core read its tuning values from the radio settings instead of a getter per role | [0002](0002-core-reads-radio-prefs.md) | ready for review | +| 3 | Drive the radio settings (JSON, CLI, key/value store) from one table | [0003](0003-radio-settings-table.md) | ready for review | Planned next steps (each needs its own design note before code), in order of -expected benefit: one table per setting group for the radio CLI; -a shared base for repeater, room server and sensor; one generic variant target +expected benefit: a shared base for repeater, room server and sensor; one generic variant target built from a board description; one RadioLib adapter per chip family; a companion frame codec with a command table. See the review for the reasoning. diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md index 93bd0444fb2..5619ab7c543 100644 --- a/docs/refactoring/architecture-review-v1.18.md +++ b/docs/refactoring/architecture-review-v1.18.md @@ -105,3 +105,4 @@ issue or fix. | 6 | `extra_sf` and `discovery_mod_timestamp` are not part of `/prefs.json` | `src/helpers/CommonCLI.h:65`, `:74` | | 7 | The server roles load `/prefs.json` without range checks (only the legacy loader clamps) | `src/helpers/CommonCLI.cpp:38` | | 8 | Role fallback radio defaults (915 / 250 / SF10) differ from the root `platformio.ini` (869.618 / 62.5 / SF8) | `examples/simple_repeater/MyMesh.cpp:7-13`, `platformio.ini` | +| 9 | nRF52 firmware is built with `-Ofast` (`-ffast-math`), so float comparisons may assume no NaN; whether a range check rejects `nan` then depends on how it is written. `set af nan` is accepted on the boards. (Found during step 3, measured on a T-Echo) | `src/helpers/CommonRadioPrefs.cpp` | From cf2ed8e3b884aa96fbbdf51b10f25be6afaa5309 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 22:20:48 +0200 Subject: [PATCH 014/136] docs(refactoring): drop the duplicated Hardware heading in 0002 and 0003 Both notes carried the 'Hardware' heading twice: an empty one directly followed by the real section. The stray line rendered as an empty section in the verification chapter. Remove the empty one in each note, so each has a single Hardware section. No text changes otherwise. --- docs/refactoring/0002-core-reads-radio-prefs.md | 2 -- docs/refactoring/0003-radio-settings-table.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/docs/refactoring/0002-core-reads-radio-prefs.md b/docs/refactoring/0002-core-reads-radio-prefs.md index 1ca8c6975bd..25be79f544b 100644 --- a/docs/refactoring/0002-core-reads-radio-prefs.md +++ b/docs/refactoring/0002-core-reads-radio-prefs.md @@ -139,8 +139,6 @@ the built-in default, as before. ### Hardware -### Hardware - The hooks only change values inside the running core, which no command shows. For the hardware run, one identical diagnostic patch (a `diag tuning` CLI command that prints what the running core returns for the duty cycle budget, the RX delay diff --git a/docs/refactoring/0003-radio-settings-table.md b/docs/refactoring/0003-radio-settings-table.md index 49c17e62b2d..1841b168953 100644 --- a/docs/refactoring/0003-radio-settings-table.md +++ b/docs/refactoring/0003-radio-settings-table.md @@ -136,8 +136,6 @@ firmware is 8 to 96 bytes smaller than with 0002; RAM is unchanged. ### Hardware -### Hardware - Two LilyGo T-Echo boards (nRF52840, SX1262), room server and USB companion, each running the 0002 image and the image of this step in turn (marked builds, see 0001). A script of 58 radio commands (edge cases that are safe on air: no From 11535285990bde5ceafcfcfad6ae02ee83126b8f Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 22:23:18 +0200 Subject: [PATCH 015/136] docs(refactoring): analysis and cut plan for the shared server base What this adds - 0004-shared-server-base.md: the measurement behind review finding F2 and the cut into steps 4a to 4f. Every top level member function of the three role implementations was extracted and compared after stripping comments, whitespace and the class name: 73 lines per role are identical in all three, 83 more differ only by cosmetics or one role value, and 50 more are identical in the repeater and the room server while the sensor lacks them. That is 312 lines of pure copies, about 420 counting the logging block and about 500 with the boot sequence. - The three differences that are behaviour rather than formatting, with the check that neither peer search can overflow (ClientACL caps num_clients at MAX_CLIENTS) and that the room server's missing STM32 guard is latent because no STM32 room server environment exists. - Ledger: why the sensor drifted, with the commit for each of the four deviations, and the exclusive upper bound of RNG::nextInt() that explains the two retransmit formulas and the divide by zero fix. Why - The review estimated 350 to 400 duplicated lines in finding F2. The series needs a measured cut and named role hooks before any code moves, and the sensor decision needed the upstream history first. Status - Proposal. No code moved yet, and the characterization depth is still open. --- docs/refactoring/0004-shared-server-base.md | 169 ++++++++++++++++++ docs/refactoring/architecture-review-v1.18.md | 26 +++ 2 files changed, 195 insertions(+) create mode 100644 docs/refactoring/0004-shared-server-base.md diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md new file mode 100644 index 00000000000..f58c89409b4 --- /dev/null +++ b/docs/refactoring/0004-shared-server-base.md @@ -0,0 +1,169 @@ +# 0004: A shared base for the three server roles + +Status: proposal, not implemented. This note is the analysis and the cut plan for +steps 4a to 4f; each step gets its own branch and its own note on landing. + +## Context + +Repeater, room server and sensor each carry their own copy of the same server +underpinnings. Candidate 2 of the v1.18 architecture review estimated 350 to 400 +duplicated lines. This note replaces that estimate with a measurement. + +Measured on `refactor/radio-cli-table` (the base of this step), by extracting every +top level member function of the three role implementations and comparing the bodies +after stripping comments, whitespace and the class name: + +| file | lines | +|---|---| +| `examples/simple_repeater/MyMesh.cpp` | 1328 | +| `examples/simple_room_server/MyMesh.cpp` | 1069 | +| `examples/simple_sensor/SensorMesh.cpp` | 1102 | + +### Identical in all three roles + +73 lines per role, so 146 lines exist only as copies: + +| function | lines per role | +|---|---| +| `onRecvPacket` | 14 | +| `sendFloodReply` | 20 | +| `sendFloodScoped` | 10 | +| `startRegionsLoad` | 6 | +| `saveRegions` | 3 | +| `onDefaultRegionChanged` | 7 | +| `formatStatsReply` | 3 | +| `formatRadioStatsReply` | 3 | +| `formatPacketStatsReply` | 4 | +| `setTxPower` | 3 | + +### Identical in all three apart from cosmetics or one role value + +83 lines per role, so 166 more lines exist only as copies. The differences are +pointer star placement, whitespace and redundant parentheses, with three exceptions +listed under "Real differences" below: + +`applyTempRadioParams` (9), `getPeerSharedSecret` (9), `saveIdentity` (12), +`sendSelfAdvertisement` (12), `updateAdvertTimer` (7), `updateFloodAdvertTimer` (7), +`formatFileSystem` (12), `searchPeersByHash` (9), `createSelfAdvert` (6). + +### Identical in repeater and room server, absent from the sensor + +50 lines per role, so 50 more lines exist only as copies, and the sensor lacks the +capability altogether: `clearStats` (5), `dumpLogFile` (15), `getLogDateTime` (8), +`logRxRaw` (8), `logTxFail` (11), `setRxBoostedGain` (3). Near identical in the same +two roles: `logRx` (25 and 19), `logTx` (24 and 18), `openAppend` (9 and 9). + +### The boot sequence + +`begin()` is 53, 44 and 43 lines and 87 to 96 percent identical. The only real +differences are the repeater's `WITH_BRIDGE` block, the sensor's missing RX boosted +gain apply and a debug print the room server does not have. + +### Role specific, not shared + +`handleRequest` (167, 67, 111 lines; 23 to 55 percent similar), `onPeerDataRecv` +(102, 167, 98), `onAnonDataRecv` (51, 88, 28) and `loop` (44, 85, 116) differ by +role and are out of scope. `handleCommand` (90, 93, 106; 78 to 88 percent) looks +shareable but mixes role commands into one dispatcher, so it is deferred. + +So the measurement supports the review: 312 lines of the three role files are pure +copies of each other, rising to about 420 if the logging block the sensor lacks is +counted, and about 500 if the boot sequence is unified too. + +## Real differences found while comparing + +Three differences are behaviour, not formatting. They are the reason this is a +series of small steps and not one move. + +1. `searchPeersByHash`: the sensor bounds its loop with `n < MAX_SEARCH_RESULTS` + because its `matching_peer_indexes` holds 24 entries, while the repeater and the + room server size the array `MAX_CLIENTS` (32) and need no bound. Neither can + overflow: `ClientACL` holds `clients[MAX_CLIENTS]` and caps `num_clients` at + MAX_CLIENTS in both `load()` and `putClient()`, so `getNumClients()` never + exceeds 32. The sensor can, however, report only 24 of up to 32 matching + clients. One array of `MAX_CLIENTS` in the shared base removes the truncation. +2. `formatFileSystem`: the repeater and the sensor erase with `InternalFS.format()` + under `NRF52_PLATFORM || STM32_PLATFORM`, the room server only under + `NRF52_PLATFORM`, so an STM32 room server would hit the `#error` branch. This is + latent today: the two STM32 variants (`wio-e5-mini`, `wio-e5-dev`) define no + room server environment, so the branch is never compiled. +3. `createSelfAdvert`: differs only by the advert type the role builds + (`ADV_TYPE_REPEATER`, `ADV_TYPE_ROOM`, `ADV_TYPE_SENSOR`), the classic role + parameter. + +## The cut + +A new class `ServerMesh` in `src/helpers/`, between `mesh::Mesh` and the three role +classes, holding the members all three already declare separately (the ACL, the +region map and its temp instance, the key store, the default scope, the received +packet's region, the advert timers, the temp radio state) and the shared methods. +Role differences become either a constructor argument (the advert type) or a small +virtual hook. + +Each step is a branch of its own, stacked in order, each a candidate for a separate +upstream pull request: + +- **4a Region and reply scope plumbing.** Move `onRecvPacket`, `sendFloodReply`, + `sendFloodScoped`, `startRegionsLoad`, `saveRegions`, `onDefaultRegionChanged`. + All six are byte identical in all three roles, so the move is provable by diff. + 63 lines per role. +- **4b Advert schedule.** Move `createSelfAdvert` (advert type as a constructor + argument), `sendSelfAdvertisement`, `updateAdvertTimer`, `updateFloodAdvertTimer`. + 32 lines per role. +- **4c Temp radio.** Move `applyTempRadioParams` and the revert branch of `loop()`. + The revert lives inside three different `loop()` bodies, so this step reads the + three loops carefully rather than moving a whole function. +- **4d Identity, file system and stats.** Move `saveIdentity`, `formatFileSystem` + (with the STM32 guard of the repeater and the sensor, which fixes difference 2), + `formatStatsReply`, `formatRadioStatsReply`, `formatPacketStatsReply`, + `setTxPower`, `clearStats`. +- **4e ACL peer lookup.** Move `searchPeersByHash` and `getPeerSharedSecret` with + one array of `MAX_CLIENTS` and one bound, which fixes difference 1. +- **4f Boot sequence.** Move `begin()` with a hook for the repeater's bridge block, + after the sensor's RX gain gap is closed (see below), so the three boot paths are + the same code. + +The packet logging block (`logRx`, `logTx`, `logRxRaw`, `logTxFail`, +`getLogDateTime`, `dumpLogFile`, `openAppend`) is left for a later step: giving it to +the sensor is a behaviour change, not a move. + +## The sensor, decided separately + +Marcel's decision of 19 October is to align the sensor with the other two roles, on +its own branch and with its own note, because it changes behaviour. Checking the +upstream history first, as that decision required, found no role specific reason for +any of the four deviations; each one is a change that reached the repeater and the +room server and skipped the sensor. Details are in the ledger. + +Step 2 already made two of the deviations explicit, so on this base the alignment is +small: + +- `getExtraAckTransmitCount`: delete the sensor's override. The core returns + `_radio_prefs->multi_acks` since step 2, so deleting three lines makes + `multi.acks` effective and brings the already present branch in + `SensorMesh::sendAckTo` to life. +- `getRetransmitDelay` and `getDirectRetransmitDelay`: delete both overrides. The + core applies `nextInt(0, 5*t + 1)`, the formula of the other roles. This is the + one open point, see below. +- `allowPacketForward`: use `mesh::isFloodHopLimitExceeded`, so + `flood.max.unscoped` and `flood.max.advert` take effect as on the other roles. +- RX boosted gain: initialise the pref under the same guards as the other roles and + apply it in `begin()`, mirroring upstream's own room server fix. + +## Open points + +1. **The retransmit formula.** Upstream moved the repeater and the room server off + the six slot scheme in a commit that calls itself experimental, and never + revisited it. Aligning the sensor adopts that experiment. The alternative is to + keep the slot scheme for the sensor as the one role parameter of the shared base. + Marcel decides before 4f. +2. **How deep the characterization goes.** The three role classes cannot be + instantiated on the host today: they pull in `RTClib`, `target.h`, a file system + and `CommonCLI`, and `test/mocks/` covers only part of that. Two options, one + decision: + - Pin the moved behaviour forward: for 4a the move is byte identical in all + three roles and provable by diff, and the new host test then guards the shared + base from that point on. Cheap, and the diff is the proof for 4a. + - Build a host harness for the real role classes first (a file system mock on top + of the existing mocks), then pin the current behaviour of all three roles + before moving anything. Expensive, and every later step benefits. diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md index 5619ab7c543..651274ee4da 100644 --- a/docs/refactoring/architecture-review-v1.18.md +++ b/docs/refactoring/architecture-review-v1.18.md @@ -65,6 +65,32 @@ The copies have drifted (found by reading the code, not reproduced on hardware): - The sensor does not override `getExtraAckTransmitCount()`, whose base returns 0 (`src/Mesh.cpp:25`), so `set multi.acks` is accepted but has no effect there. +Since step 2 the sensor states this explicitly (`getExtraAckTransmitCount()` +returning 0 with a comment), so the gap is documented rather than accidental, and +`SensorMesh::sendAckTo()` still carries a branch that the base's 0 makes +unreachable. + +#### Why the sensor drifted + +Checked with `git log -S` and the commit history of `upstream/dev` 428a95c6 before +deciding whether to align the sensor. No deviation has a role specific reason such +as battery life. Each one is a change that reached the repeater and the room server +and skipped the sensor. The history below is from the commits; the run time effects +are read from the source and not reproduced on a board: + +| deviation | history | +|---|---| +| Retransmit delay `nextInt(0, 6)*t` | The sensor sketch copied it verbatim from the repeater at its birth (`7fb7b69b`, 7 July 2025, where the repeater had the same line). The repeater and the room server left the six slot scheme in `8cbcd227` (29 October 2025), whose subject calls itself experimental, and got the divide by zero fix `96e786fa` a day later. The sensor keeps the old shared behaviour. The lines were touched once more, in `3e76161e` (19 February 2026, `path_len` to `getPathByteLen()`), in all three roles including the sensor, so only the formula itself was never revisited. | +| Flood limits only `flood_max` | `fad11c90` (3 August 2026) moved the repeater and the room server to `mesh::isFloodHopLimitExceeded()`, with the shared helper in `src/helpers/RoutingPolicy.h` and `test/test_routing_policy`. The sensor was not touched. | +| `multi.acks` without effect | `6bc8dd28` and `5881b04a` (16 July 2025) added the setting and the companion's use of it. `136f733d` (27 August 2025) added a consumer to `SensorMesh::sendAckTo()`, but no role side provider, so the base's 0 keeps that branch dead. | +| RX boosted gain not applied at boot | The room server had the same gap until `a71cc238` (21 July 2026) fixed it by mirroring the repeater. The sensor still initialises no default and applies nothing in `begin()`, so a value set through the CLI applies at once but is lost on the next boot. | + +`nextInt(_min, _max)` returns `(num % (_max - _min)) + _min` (`src/Utils.cpp:18`), so +the upper bound is exclusive: the sensor draws from the six multiples 0 to 5 times +`t`, the other roles uniformly from 0 to `5*t`. Both have the same maximum. The +`+ 1` of `96e786fa` exists because `t == 0` made the bound 0 and `num % 0` crashed, +which the sensor's form could not do. + ### F3. Board variants are near copies 89 `variants/*/target.cpp` files; `radio_new_identity()` is identical (ignoring From b160f39adc04d22343ff5cf96bde721525d2edae Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 22:32:03 +0200 Subject: [PATCH 016/136] fix(identity): report a failed identity write RCC-SKIPPED: defect and cause established by reading the source; the root cause chain was waived for this fix. What changes - IdentityStore::save() returns the result of writing the identity instead of always returning true. Both overloads were affected: the two argument form computed the result, logged it and then discarded it, and the form that also stores a display name ignored the result of both writes. - In that second form the display name write is now part of the result, so a medium that fills up between the keypair and the name is a failure, and it gets the debug line the other form already had. - New native environment native_identity_store covers IdentityStore against a mock filesystem whose file opens and then runs out of write budget, which is how a full or worn flash behaves. The unit test workflow runs it. - test/mocks gains that filesystem (Adafruit_LittleFS.h) and the serialisation of mesh::Identity and mesh::LocalIdentity, copied from src/Identity.cpp so that a short write is a failure in the double as well. test/mocks/Utils.h gains the no-op MESH_DEBUG macros that src/MeshCore.h defines for firmware builds, since the mock stands in for that header's constants. Why - Every role generates a keypair when the load at boot finds no file and then saves it: simple_repeater/main.cpp:94, simple_room_server/main.cpp:75, simple_sensor/main.cpp:101, kiss_modem/main.cpp:56, simple_secure_chat/main.cpp:327, companion_radio/MyMesh.cpp:955. When that write fails the node keeps running under an identity that never reached the medium and comes back with a different public key after the next boot. - One caller already acts on the result and is written for an honest one: CMD_IMPORT_PRIVATE_KEY in companion_radio/MyMesh.cpp:1544 adopts the imported key and answers OK when save() reports success, and answers ERR_CODE_FILE_IO_ERROR when it does not. While save() always reported success, a failed import was answered with OK and the old key came back at the next boot. Compatibility - The success path is unchanged, so nothing differs while the filesystem accepts the write. - All nine call sites were checked for what a false now triggers. Eight discard the result and are unchanged; three of those reach save() through void wrappers (simple_repeater and simple_room_server MyMesh::saveIdentity, SensorMesh::saveIdentity) that cannot propagate it anyway. The only consumer is CMD_IMPORT_PRIVATE_KEY above, which already has its error path. No boot path reads the result, so a failing write adds no error cascade at startup. - The overload that stores a display name has no caller in this repository. Evidence - Red before the change, green after: ReportsFailureWhenNoByteCanBeWritten, ReportsFailureWhenThePrivateKeyIsCutOff, ReportsFailureWhenTheKeypairIsCutOff and ReportsFailureWhenOnlyTheNameIsCutOff each reported "Actual: true, Expected: false" on the old code; 9 of 9 pass with the change. - pio test -e native -e native_kiss_modem -e native_identity_store: 63 of 63. - Five deliberate mutations of the new code were each caught by the suite: returning true again in either overload, dropping the check of the name write, not reading the result of writeTo(), and joining the two writes with OR instead of AND. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 19 ++- src/helpers/IdentityStore.cpp | 10 +- test/mocks/Adafruit_LittleFS.h | 116 ++++++++++++++++++ test/mocks/Identity.h | 29 ++++- test/mocks/Utils.h | 5 + .../test_identity_store.cpp | 114 +++++++++++++++++ 7 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 test/mocks/Adafruit_LittleFS.h create mode 100644 test/test_identity_store/test_identity_store.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..3af804f3c10 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_identity_store -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index f296f838213..1e993076071 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,7 +167,9 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = + test_kiss_modem + test_identity_store build_src_filter = -<*> +<../src/Utils.cpp> @@ -177,6 +179,21 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_identity_store] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks + -I src + -D NRF52_PLATFORM +test_build_src = yes +test_filter = test_identity_store +build_src_filter = + -<*> + +<../src/helpers/IdentityStore.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_kiss_modem] platform = native test_framework = googletest diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index dc85d69cddc..ddd7a452608 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -58,7 +58,7 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { bool success = id.writeTo(file); file.close(); MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err"); - return true; + return success; } MESH_DEBUG_PRINTLN("IdentityStore::save() failed"); return false; @@ -77,17 +77,19 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const File file = _fs->open(filename, "w", true); #endif if (file) { - id.writeTo(file); + bool success = id.writeTo(file); uint8_t tmp[32]; memset(tmp, 0, sizeof(tmp)); int n = strlen(display_name); if (n > sizeof(tmp)-1) n = sizeof(tmp)-1; memcpy(tmp, display_name, n); - file.write(tmp, sizeof(tmp)); + success = success && (file.write(tmp, sizeof(tmp)) == sizeof(tmp)); file.close(); - return true; + MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err"); + return success; } + MESH_DEBUG_PRINTLN("IdentityStore::save() failed"); return false; } diff --git a/test/mocks/Adafruit_LittleFS.h b/test/mocks/Adafruit_LittleFS.h new file mode 100644 index 00000000000..6d5368e5c6b --- /dev/null +++ b/test/mocks/Adafruit_LittleFS.h @@ -0,0 +1,116 @@ +#pragma once + +// Mock of the nRF52 filesystem API for native tests. Only what IdentityStore +// touches is modelled: open, exists, remove, mkdir, and a File that is a +// Stream. +// +// The write budget is the point of this mock. A real flash lets a file open +// and then refuses the write once the medium is full or worn out, so a test +// needs a file that is valid but whose writes come up short. Print::write() +// loops over write(uint8_t), so a budget that runs out mid buffer returns a +// short count, which is how the real driver reports a partial write. + +#include +#include +#include +#include +#include +#include + +#include "Stream.h" + +#define FILE_O_READ 0 +#define FILE_O_WRITE 1 + +namespace Adafruit_LittleFS_Namespace { + +struct FileData { + std::vector bytes; + size_t write_budget = std::numeric_limits::max(); +}; + +class File : public Stream { + FileData* _data; + size_t _read_pos; + bool _open; + +public: + File() : _data(nullptr), _read_pos(0), _open(false) { } + explicit File(FileData* data) : _data(data), _read_pos(0), _open(data != nullptr) { } + + // The driver signals "could not open" through the bool conversion, not an + // exception or a null return. + operator bool() const { return _open; } + + void close() { _open = false; } + + // Overriding the single byte write would otherwise hide the buffer overloads + // of Print, which is what the callers use. + using Print::write; + + size_t write(uint8_t b) override { + if (!_open || _data == nullptr || _data->write_budget == 0) return 0; + _data->bytes.push_back(b); + _data->write_budget--; + return 1; + } + + int available() override { + if (!_open || _data == nullptr) return 0; + return (int) (_data->bytes.size() - _read_pos); + } + + int read() override { + if (available() <= 0) return -1; + return _data->bytes[_read_pos++]; + } + + int peek() override { + if (available() <= 0) return -1; + return _data->bytes[_read_pos]; + } + + // The driver's File offers a buffer read next to the Stream interface, and + // IdentityStore uses it to pick up the display name. + size_t read(uint8_t* buffer, size_t size) { + size_t n = 0; + while (n < size && available() > 0) buffer[n++] = (uint8_t) read(); + return n; + } +}; + +} // namespace Adafruit_LittleFS_Namespace + +class Adafruit_LittleFS { + std::map _files; + +public: + // Test knobs. + size_t write_budget = std::numeric_limits::max(); + bool fail_open = false; + + bool exists(const char* path) { return _files.count(path) > 0; } + bool remove(const char* path) { return _files.erase(path) > 0; } + bool mkdir(const char* path) { return true; } + + Adafruit_LittleFS_Namespace::File open(const char* path, uint8_t mode = FILE_O_READ) { + if (fail_open) return Adafruit_LittleFS_Namespace::File(); + if (mode == FILE_O_WRITE) { + // std::map keeps its values at a stable address, so handing out a + // pointer stays valid while other files are added. + Adafruit_LittleFS_Namespace::FileData& data = _files[path]; + data.bytes.clear(); + data.write_budget = write_budget; + return Adafruit_LittleFS_Namespace::File(&data); + } + auto found = _files.find(path); + if (found == _files.end()) return Adafruit_LittleFS_Namespace::File(); + return Adafruit_LittleFS_Namespace::File(&found->second); + } + + // Lets a test see what actually reached the medium. + const std::vector* contents(const char* path) { + auto found = _files.find(path); + return found == _files.end() ? nullptr : &found->second.bytes; + } +}; diff --git a/test/mocks/Identity.h b/test/mocks/Identity.h index 5c3d25e94e2..4ee269d8897 100644 --- a/test/mocks/Identity.h +++ b/test/mocks/Identity.h @@ -2,6 +2,7 @@ #include #include +#include "Stream.h" #include "Utils.h" namespace mesh { @@ -21,11 +22,25 @@ class Identity { bool verify(const uint8_t*, const uint8_t*, int) const { return true; } + + // Copies of the real serialisation in src/Identity.cpp: a short read or a + // short write is a failure, which is what the file based tests rely on. + bool readFrom(Stream& s) { + return s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + } + + bool writeTo(Stream& s) const { + return s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + } }; class LocalIdentity : public Identity { public: - LocalIdentity() : Identity() {} + uint8_t prv_key[PRV_KEY_SIZE]; + + LocalIdentity() : Identity() { + std::memset(prv_key, 0, sizeof(prv_key)); + } void sign(uint8_t* sig, const uint8_t*, int) const { std::memset(sig, 0x5A, SIGNATURE_SIZE); @@ -34,6 +49,18 @@ class LocalIdentity : public Identity { void calcSharedSecret(uint8_t* secret, const uint8_t*) const { std::memset(secret, 0x11, PUB_KEY_SIZE); } + + bool readFrom(Stream& s) { + bool success = s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + success = success && s.readBytes(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE; + return success; + } + + bool writeTo(Stream& s) const { + bool success = s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + success = success && s.write(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE; + return success; + } }; } diff --git a/test/mocks/Utils.h b/test/mocks/Utils.h index 9bb6b060b03..260ee505238 100644 --- a/test/mocks/Utils.h +++ b/test/mocks/Utils.h @@ -10,6 +10,11 @@ #define SIGNATURE_SIZE 64 #define CIPHER_MAC_SIZE 16 +// Stands in for the same no-op definitions in src/MeshCore.h, which is the +// header the real Utils.h pulls these constants from. +#define MESH_DEBUG_PRINT(...) {} +#define MESH_DEBUG_PRINTLN(...) {} + namespace mesh { class RNG { diff --git a/test/test_identity_store/test_identity_store.cpp b/test/test_identity_store/test_identity_store.cpp new file mode 100644 index 00000000000..bac82bfe8de --- /dev/null +++ b/test/test_identity_store/test_identity_store.cpp @@ -0,0 +1,114 @@ +#include + +#include + +// IdentityStore is the only place that persists a node's keypair. Every role +// generates a new identity when the load at boot comes up empty and then saves +// it, so a save that reports success without having written anything costs the +// node its identity at the next boot. +// +// The mock filesystem lets a file open and then runs out of write budget, which +// is how a full or worn flash behaves. + +namespace { + +const size_t KEYPAIR_BYTES = PUB_KEY_SIZE + PRV_KEY_SIZE; // 96 +const size_t DISPLAY_NAME_BYTES = 32; + +mesh::LocalIdentity makeIdentity() { + mesh::LocalIdentity id; + for (size_t i = 0; i < PUB_KEY_SIZE; i++) id.pub_key[i] = (uint8_t) (0xA0 + i); + for (size_t i = 0; i < PRV_KEY_SIZE; i++) id.prv_key[i] = (uint8_t) (0x10 + i); + return id; +} + +class IdentityStoreSave : public ::testing::Test { +protected: + Adafruit_LittleFS fs; + mesh::LocalIdentity id = makeIdentity(); +}; + +TEST_F(IdentityStoreSave, ReportsSuccessWhenTheKeypairFits) { + fs.write_budget = KEYPAIR_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id)); + ASSERT_NE(fs.contents("/_main.id"), nullptr); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES); +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenTheFileCannotBeOpened) { + fs.fail_open = true; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); +} + +// The defect: the write fails, the identity is not on the medium, and the +// caller is told the save worked. +TEST_F(IdentityStoreSave, ReportsFailureWhenNoByteCanBeWritten) { + fs.write_budget = 0; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_TRUE(fs.contents("/_main.id")->empty()); +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenThePrivateKeyIsCutOff) { + fs.write_budget = PUB_KEY_SIZE + 1; // public key fits, private key does not + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_EQ(fs.contents("/_main.id")->size(), (size_t) PUB_KEY_SIZE + 1); +} + +TEST_F(IdentityStoreSave, RoundTripsAnIdentityThroughTheFilesystem) { + IdentityStore store(fs, ""); + ASSERT_TRUE(store.save("_main", id)); + + mesh::LocalIdentity loaded; + ASSERT_TRUE(store.load("_main", loaded)); + EXPECT_EQ(memcmp(loaded.pub_key, id.pub_key, PUB_KEY_SIZE), 0); + EXPECT_EQ(memcmp(loaded.prv_key, id.prv_key, PRV_KEY_SIZE), 0); +} + +class IdentityStoreSaveWithName : public IdentityStoreSave { }; + +TEST_F(IdentityStoreSaveWithName, ReportsSuccessWhenKeypairAndNameFit) { + fs.write_budget = KEYPAIR_BYTES + DISPLAY_NAME_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id, "room")); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES + DISPLAY_NAME_BYTES); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenTheKeypairIsCutOff) { + fs.write_budget = 0; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); +} + +// The display name is written after the keypair, so a medium that fills up in +// between leaves a file that loads as an identity but carries no name. +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenOnlyTheNameIsCutOff) { + fs.write_budget = KEYPAIR_BYTES; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenTheFileCannotBeOpened) { + fs.fail_open = true; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 7e1dd92bdbf850b4747ff0af1585f73fa521f8d7 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 22:44:30 +0200 Subject: [PATCH 017/136] test(server): run the real server roles on the host and pin the temp radio What this adds - A host harness for the server roles. The three role implementations could not be compiled natively before: they need a file system, an RTC and the globals a variant's target.h declares. test/mocks gains an in-memory file system (File derives from Stream and takes void* buffers, as the Arduino cores have it), a DateTime stand-in, the LittleFS global, Arduino's random API and a capturing Serial; target.h gains the board, the RTC and a radio driver that is now a mesh::Radio, so a role can be built exactly as its main.cpp builds it. - A native_server environment that compiles the real room server role against those mocks. RP2040_PLATFORM selects the plain string file modes the mock implements. One role per environment, because the repeater and the room server both define MyMesh. - test_server_base: six characterization tests for the temporary radio parameters, which step 4c moves into the shared base. They pin what the CLI command schedules, that the pending values reach the radio once, and that the node reverts to its own settings afterwards. - Identity stubs for the suite. Unlike the tuning tests, these are deterministic rather than refusing: a role signs every advertisement it schedules, so a refusing stub turns ordinary operation into a failure. They are not cryptographic and no test here asserts a cryptographic property. Measured while writing this - A timer set for T fires at T + 1, not at T: Dispatcher::millisHasNowPassed() is '(long)(now - timestamp) > 0'. Three tests pin that edge, since it is the observable boundary of every scheduled action in the roles, and the signed difference is what carries it across the millis() rollover. - The role's fallback radio defaults show up as 915 MHz and 250 kHz on the host, where the root platformio.ini values are absent. That is review issue 8, now visible in a test rather than only by reading. Verification - pio test -e native -e native_core -e native_kiss_modem -e native_ofast -e native_server: 88 of 88 test cases pass (82 before, 6 new). The shared mocks that other suites use (Stream.h, SHA256.h, Arduino.h, target.h) were changed, and all four pre-existing environments still pass. - Seven deliberately introduced faults each turned the suite red: applying with no reply delay, neither timer being cleared, the timeout read as seconds, the frequency and bandwidth swapped when applying, the revert restoring the temporary values, and millisHasNowPassed relaxed to >=. Both patched files were restored afterwards and the suite is green again. --- platformio.ini | 37 +++++ test/mocks/Arduino.h | 62 +++++++++ test/mocks/CayenneLPP.h | 31 +++++ test/mocks/FS.h | 145 +++++++++++++++++++ test/mocks/LittleFS.h | 9 ++ test/mocks/RTClib.h | 51 +++++++ test/mocks/SHA256.h | 20 ++- test/mocks/Stream.h | 14 ++ test/mocks/target.h | 100 ++++++++++++- test/test_server_base/identity_stubs.cpp | 98 +++++++++++++ test/test_server_base/test_server_base.cpp | 154 +++++++++++++++++++++ 11 files changed, 715 insertions(+), 6 deletions(-) create mode 100644 test/mocks/FS.h create mode 100644 test/mocks/LittleFS.h create mode 100644 test/mocks/RTClib.h create mode 100644 test/test_server_base/identity_stubs.cpp create mode 100644 test/test_server_base/test_server_base.cpp diff --git a/platformio.ini b/platformio.ini index 805aab6b1e2..335167fb855 100644 --- a/platformio.ini +++ b/platformio.ini @@ -170,6 +170,7 @@ test_build_src = yes test_ignore = test_kiss_modem test_core_tuning + test_server_base build_src_filter = -<*> +<../src/Utils.cpp> @@ -196,6 +197,42 @@ build_src_filter = ${env:native.build_src_filter} lib_deps = google/googletest @ 1.17.0 +[env:native_server] +; The server roles on the host: the real role implementation against the mocks in +; test/mocks, so the shared underpinnings can be characterized before they move. +; RP2040_PLATFORM selects the plain string file modes of the helpers, which the +; in-memory file system mock implements. One role per environment: the repeater and +; the room server both define `MyMesh`. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I src + -I test/mocks + -I examples/simple_room_server +test_build_src = yes +test_filter = test_server_base +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/ClientACL.cpp> + +<../src/helpers/CommonCLI.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/IdentityStore.cpp> + +<../src/helpers/RegionMap.cpp> + +<../src/helpers/StaticPoolPacketManager.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> + +<../examples/simple_room_server/MyMesh.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_core] platform = native test_framework = googletest diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 773ac6dab0e..45a11faf783 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -4,6 +4,9 @@ #include #include #include +#include +#include +#include #include "Stream.h" inline uint32_t g_mock_millis = 0; @@ -38,3 +41,62 @@ inline char* ltoa(long value, char* buf, int base) { *p = 0; return buf; } + +// Arduino's random API, used by helpers/ArduinoHelpers.h. The sequence is +// deterministic, so a test that exercises code drawing from it stays repeatable. +inline uint32_t g_mock_random_state = 1; + +inline void randomSeed(long seed) { + g_mock_random_state = (uint32_t)(seed ? seed : 1); +} + +inline long random(long howsmall, long howbig) { + if (howbig <= howsmall) return howsmall; + g_mock_random_state = g_mock_random_state * 1103515245u + 12345u; + uint32_t span = (uint32_t)(howbig - howsmall); + return howsmall + (long)((g_mock_random_state >> 16) % span); +} + +inline long random(long howbig) { return random(0, howbig); } + +#ifndef min + #define min(a, b) ((a) < (b) ? (a) : (b)) +#endif +#ifndef max + #define max(a, b) ((a) > (b) ? (a) : (b)) +#endif + +// Arduino's Serial. It keeps what the firmware printed, so a test can assert on +// output the roles write there (the packet log dump, for example). +class MockSerial : public Stream { +public: + std::string out; + + size_t write(uint8_t b) override { out.push_back((char)b); return 1; } + + size_t print(const char* s) { out += s; return strlen(s); } + size_t print(char c) { out.push_back(c); return 1; } + size_t print(int v, int r = DEC) { char b[24]; snprintf(b, sizeof(b), "%d", v); out += b; return strlen(b); } + size_t print(unsigned int v, int r = DEC) { char b[24]; snprintf(b, sizeof(b), "%u", v); out += b; return strlen(b); } + size_t print(long v, int r = DEC) { char b[32]; snprintf(b, sizeof(b), "%ld", v); out += b; return strlen(b); } + size_t print(unsigned long v, int r = DEC) { char b[32]; snprintf(b, sizeof(b), "%lu", v); out += b; return strlen(b); } + size_t print(double v, int p = 2) { char b[40]; snprintf(b, sizeof(b), "%.*f", p, v); out += b; return strlen(b); } + + size_t println() { out.push_back('\n'); return 1; } + template size_t println(T v) { size_t n = print(v); return n + println(); } + + size_t printf(const char* fmt, ...) { + char buf[512]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + if (n < 0) return 0; + out += buf; + return (size_t)n; + } + + void reset() { out.clear(); } +}; + +inline MockSerial Serial; diff --git a/test/mocks/CayenneLPP.h b/test/mocks/CayenneLPP.h index 9d51c0fcfcc..7f44d6df524 100644 --- a/test/mocks/CayenneLPP.h +++ b/test/mocks/CayenneLPP.h @@ -3,9 +3,40 @@ #include #include +#define LPP_DIGITAL_INPUT 0 +#define LPP_DIGITAL_OUTPUT 1 +#define LPP_ANALOG_INPUT 2 +#define LPP_ANALOG_OUTPUT 3 +#define LPP_GENERIC_SENSOR 100 +#define LPP_LUMINOSITY 101 +#define LPP_PRESENCE 102 +#define LPP_TEMPERATURE 103 +#define LPP_RELATIVE_HUMIDITY 104 +#define LPP_ACCELEROMETER 113 +#define LPP_BAROMETRIC_PRESSURE 115 +#define LPP_VOLTAGE 116 +#define LPP_CURRENT 117 +#define LPP_FREQUENCY 118 +#define LPP_PERCENTAGE 120 +#define LPP_ALTITUDE 121 +#define LPP_POWER 128 +#define LPP_DISTANCE 130 +#define LPP_ENERGY 131 +#define LPP_DIRECTION 132 +#define LPP_GYROMETER 134 +#define LPP_GPS 136 + class CayenneLPP { public: explicit CayenneLPP(size_t) {} const uint8_t* getBuffer() const { return nullptr; } uint16_t getSize() const { return 0; } + + // The roles build a telemetry frame from these; the tests here do not inspect + // the encoding, so recording the call count is enough. + void reset() { adds = 0; } + uint8_t addVoltage(uint8_t channel, float value) { adds++; return 0; } + uint8_t addTemperature(uint8_t channel, float value) { adds++; return 0; } + + int adds = 0; }; diff --git a/test/mocks/FS.h b/test/mocks/FS.h new file mode 100644 index 00000000000..012eefa55eb --- /dev/null +++ b/test/mocks/FS.h @@ -0,0 +1,145 @@ +#pragma once + +// An in-memory stand-in for a variant's file system, so the server roles and the +// helpers that persist their state (CommonCLI, ClientACL, RegionMap, IdentityStore) +// run in the native env. It implements the calls the firmware actually makes, in +// the forms the native build reaches: the platform guards for NRF52, STM32 and +// RP2040 are not set there, so the "w"/"a"/"r" modes with the optional create flag +// are the ones that matter. +// +// Files live in one map that survives a close, so a test can write, reopen and read +// back, and reboot a role by constructing it again against the same instance. + +#include +#include +#include +#include +#include +#include +#include + +#include "Stream.h" + +#ifndef FILE_O_WRITE + #define FILE_O_WRITE 1 +#endif + +namespace fs { class FS; } +using MockFS = fs::FS; + +class File : public Stream { + fs::FS* _fs = nullptr; + std::string _name; + size_t _pos = 0; + bool _valid = false; + bool _writable = false; + + std::vector* data() const; + +public: + File() { } + File(fs::FS* fsp, const std::string& name, bool writable) + : _fs(fsp), _name(name), _pos(0), _valid(true), _writable(writable) { } + + operator bool() const { return _valid; } + + // Stream + int available() override; + int read() override; + int peek() override { return -1; } + + // Print + size_t write(uint8_t b) override { return write(&b, (size_t)1); } + size_t write(const uint8_t* src, size_t len) override; + + // The cores accept any buffer type here, and the firmware relies on that. + int read(void* dest, size_t len); + size_t write(const void* src, size_t len) { + return write(static_cast(src), len); + } + + void close() { _valid = false; } + + // Test helper: the position reached so far. + size_t position() const { return _pos; } +}; + +namespace fs { + +class FS { +public: + std::map> files; + + File open(const char* name) { return open(name, "r"); } + + File open(const char* name, const char* mode, bool create = false) { + std::string n(name); + bool writing = (mode[0] == 'w' || mode[0] == 'a'); + if (!writing) { + if (files.find(n) == files.end()) return File(); // missing: a falsy File + return File(this, n, false); + } + if (mode[0] == 'w') files[n].clear(); // truncate + else files[n]; // append: create if absent + File f(this, n, true); + if (mode[0] == 'a') f = File(this, n, true); + return f; + } + + // The NRF52 and STM32 branches reach this form. + File open(const char* name, int flags) { + return open(name, flags == FILE_O_WRITE ? "a" : "r", true); + } + + // Directories are not modelled: the firmware only ever creates one for the + // identity store, and a flat map needs no directory to hold "/dir/file". + bool mkdir(const char* path) { return true; } + + bool exists(const char* name) { return files.find(std::string(name)) != files.end(); } + bool remove(const char* name) { return files.erase(std::string(name)) > 0; } + bool format() { files.clear(); return true; } + + // Test helpers. + void reset() { files.clear(); } + size_t sizeOf(const char* name) { + auto it = files.find(std::string(name)); + return it == files.end() ? 0 : it->second.size(); + } +}; + +} // namespace fs + +inline std::vector* File::data() const { + if (!_fs || !_valid) return nullptr; + auto it = _fs->files.find(_name); + return it == _fs->files.end() ? nullptr : &it->second; +} + +inline int File::read(void* dest, size_t len) { + auto* d = data(); + if (!d) return 0; + size_t n = d->size() > _pos ? d->size() - _pos : 0; + if (n > len) n = len; + if (n) memcpy(dest, d->data() + _pos, n); + _pos += n; + return (int)n; +} + +inline int File::read() { + uint8_t b; + return read(&b, (size_t)1) == 1 ? (int)b : -1; +} + +inline size_t File::write(const uint8_t* src, size_t len) { + auto* d = data(); + if (!d || !_writable) return 0; + d->insert(d->end(), src, src + len); + _pos = d->size(); + return len; +} + +inline int File::available() { + auto* d = data(); + if (!d) return 0; + return (int)(d->size() > _pos ? d->size() - _pos : 0); +} diff --git a/test/mocks/LittleFS.h b/test/mocks/LittleFS.h new file mode 100644 index 00000000000..f8f07ee3032 --- /dev/null +++ b/test/mocks/LittleFS.h @@ -0,0 +1,9 @@ +#pragma once + +// Stand-in for the RP2040 LittleFS global, which the roles call to erase the file +// system. It is the same in-memory instance a test hands to the role, so a format +// through this global is visible to the test. + +#include "FS.h" + +inline fs::FS LittleFS; diff --git a/test/mocks/RTClib.h b/test/mocks/RTClib.h new file mode 100644 index 00000000000..d592ac95953 --- /dev/null +++ b/test/mocks/RTClib.h @@ -0,0 +1,51 @@ +#pragma once + +// Stand-in for RTClib's DateTime, so the roles and CommonCLI build in the native +// env. It follows RTClib: the uint32_t constructor takes seconds since +// 2000-01-01T00:00:00. Only the accessors the firmware uses are implemented, and +// no test characterizes date formatting through this mock. + +#include + +class DateTime { + uint16_t _year = 2000; + uint8_t _month = 1, _day = 1, _hour = 0, _minute = 0, _second = 0; + +public: + DateTime() { } + + explicit DateTime(uint32_t secs_since_2000) { + uint32_t days = secs_since_2000 / 86400UL; + uint32_t rem = secs_since_2000 % 86400UL; + _hour = (uint8_t)(rem / 3600); + _minute = (uint8_t)((rem % 3600) / 60); + _second = (uint8_t)(rem % 60); + + uint16_t y = 2000; + for (;;) { + bool leap = (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)); + uint32_t len = leap ? 366 : 365; + if (days < len) break; + days -= len; + y++; + } + _year = y; + bool leap = (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)); + static const uint8_t lengths[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + uint8_t m = 0; + for (; m < 12; m++) { + uint32_t len = lengths[m] + ((m == 1 && leap) ? 1 : 0); + if (days < len) break; + days -= len; + } + _month = (uint8_t)(m + 1); + _day = (uint8_t)(days + 1); + } + + uint16_t year() const { return _year; } + uint8_t month() const { return _month; } + uint8_t day() const { return _day; } + uint8_t hour() const { return _hour; } + uint8_t minute() const { return _minute; } + uint8_t second() const { return _second; } +}; diff --git a/test/mocks/SHA256.h b/test/mocks/SHA256.h index 0cefe5c6e3c..9cb593f5bdc 100644 --- a/test/mocks/SHA256.h +++ b/test/mocks/SHA256.h @@ -24,12 +24,24 @@ class SHA256 { } } - void finalize(uint8_t* hash, size_t hashLen) { + void finalize(void* hash, size_t hashLen) { + uint8_t* out = static_cast(hash); for (size_t i = 0; i < hashLen; i++) { - hash[i] = _state[i % 32]; + out[i] = _state[i % 32]; } } - void resetHMAC(const uint8_t* key, size_t keyLen) {} - void finalizeHMAC(const uint8_t* key, size_t keyLen, uint8_t* hash, size_t hashLen) {} + // The HMAC path is what transport codes are derived from, so it has to produce + // deterministic output that depends on the key and the data; an empty body would + // leave the caller's code word uninitialised. + void resetHMAC(const void* key, size_t keyLen) { + _len = 0; + memset(_state, 0, sizeof(_state)); + update(key, keyLen); + } + + void finalizeHMAC(const void* key, size_t keyLen, void* hash, size_t hashLen) { + update(key, keyLen); + finalize(hash, hashLen); + } }; diff --git a/test/mocks/Stream.h b/test/mocks/Stream.h index 56675607e19..15745b37867 100644 --- a/test/mocks/Stream.h +++ b/test/mocks/Stream.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include // Mock Stream class for native testing // Provides minimal interface needed by Utils.h @@ -48,6 +50,18 @@ class Print //size_t println(void) { return 0; } virtual void flush() { /* Empty implementation for backward compatibility */ } + + // The ESP32 and RP2040 cores add printf() to Print; helpers/RegionMap.cpp uses it. + size_t printf(const char* fmt, ...) { + char buf[512]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + if (n < 0) return 0; + if ((size_t) n >= sizeof(buf)) n = sizeof(buf) - 1; + return write((const uint8_t *) buf, (size_t) n); + } }; class Stream: public Print diff --git a/test/mocks/target.h b/test/mocks/target.h index 22ed9638dee..50cba6f7f18 100644 --- a/test/mocks/target.h +++ b/test/mocks/target.h @@ -2,19 +2,115 @@ #include +#include + +// A variant's target.h brings in the platform file system; the native env gets the +// in-memory stand-in, so FILESYSTEM and File are defined for the server helpers. +#include "FS.h" + // Stand-in for a variant's target.h, so code that reaches the global // radio_driver (e.g. CommonRadioPrefs.cpp) builds and runs in the native env. -struct MockRadioDriver { +// On a board, radio_driver is a RadioLibWrapper, which is a mesh::Radio, and the +// role is constructed with it. Deriving here keeps that true in the native env, so +// a test can build a role exactly the way the role's main.cpp does. +struct MockRadioDriver : public mesh::Radio { int8_t tx_power = 0; int tx_power_calls = 0; bool rx_boosted = false; int rx_boost_calls = 0; bool rx_boost_supported = true; + float freq = 0, bw = 0; + uint8_t sf = 0, cr = 0; + int set_params_calls = 0; + float last_rssi = -90.0f, last_snr = 5.0f; + uint32_t packets_recv = 0, packets_sent = 0, packets_recv_errors = 0; + void setTxPower(int8_t dbm) { tx_power = dbm; tx_power_calls++; } bool setRxBoostedGainMode(bool en) { rx_boosted = en; rx_boost_calls++; return rx_boost_supported; } + bool getRxBoostedGainMode() const { return rx_boosted; } + + void setParams(float f, float b, uint8_t s, uint8_t c) { + freq = f; bw = b; sf = s; cr = c; set_params_calls++; + } + + float getLastRSSI() const { return last_rssi; } + float getLastSNR() const { return last_snr; } + uint32_t getPacketsRecv() const { return packets_recv; } + uint32_t getPacketsSent() const { return packets_sent; } + uint32_t getPacketsRecvErrors() const { return packets_recv_errors; } + void resetStats() { packets_recv = packets_sent = packets_recv_errors = 0; } + bool configSideDetectors(const uint8_t[], uint8_t, float) { return false; } - void reset() { *this = MockRadioDriver(); } + // mesh::Radio, as in test_core_tuning: airtime grows with the length, so a wrong + // length in a formula changes the result. + int recvRaw(uint8_t* bytes, int sz) override { return 0; } + uint32_t getEstAirtimeFor(int len_bytes) override { return 11 + 37 * (uint32_t)len_bytes; } + float packetScore(float snr, int packet_len) override { return 0; } + bool startSendRaw(const uint8_t* bytes, int len) override { return false; } + bool isSendComplete() override { return true; } + void onSendFinished() override { } + bool isInRecvMode() const override { return true; } + + void reset() { + tx_power = 0; tx_power_calls = 0; + rx_boosted = false; rx_boost_calls = 0; rx_boost_supported = true; + freq = bw = 0; sf = cr = 0; set_params_calls = 0; + last_rssi = -90.0f; last_snr = 5.0f; + packets_recv = packets_sent = packets_recv_errors = 0; + } }; inline MockRadioDriver radio_driver; + +#include +#include +#include + +// A variant's target.h declares the board and the sensor manager as globals the +// role code reaches directly. The native env provides the same two names, so the +// real role implementations compile and run unchanged. + +class MockBoard : public mesh::MainBoard { +public: + uint16_t batt_milli_volts = 4100; + float mcu_temperature = 25.0f; + float adc_multiplier = 1.0f; + uint32_t gpio = 0; + int reboot_calls = 0; + + uint16_t getBattMilliVolts() override { return batt_milli_volts; } + float getMCUTemperature() override { return mcu_temperature; } + bool setAdcMultiplier(float m) override { adc_multiplier = m; return true; } + float getAdcMultiplier() const override { return adc_multiplier; } + const char* getManufacturerName() const override { return "Mock"; } + void reboot() override { reboot_calls++; } + uint8_t getStartupReason() const override { return 0; } + uint32_t getGpio() override { return gpio; } + void setGpio(uint32_t values) override { gpio = values; } + + // Not on MainBoard: the concrete variant boards declare it, and the role code + // calls it on the concrete type (see helpers/NRF52Board.h and its siblings). + void attachDynamicPrefs(KeyValueStore* prefs) { } + + void reset() { + batt_milli_volts = 4100; + mcu_temperature = 25.0f; + adc_multiplier = 1.0f; + gpio = 0; + reboot_calls = 0; + } +}; + +class MockRTCClock : public mesh::RTCClock { +public: + uint32_t now = 1760000000; // a fixed point in time, so replies stay repeatable + uint32_t getCurrentTime() override { return now; } + void setCurrentTime(uint32_t time) override { now = time; } +}; + +inline MockBoard board; +inline MockRTCClock rtc_clock; + +// SensorManager is concrete, so the real class serves as the stand-in. +inline SensorManager sensors; diff --git a/test/test_server_base/identity_stubs.cpp b/test/test_server_base/identity_stubs.cpp new file mode 100644 index 00000000000..feaeca665f5 --- /dev/null +++ b/test/test_server_base/identity_stubs.cpp @@ -0,0 +1,98 @@ +// Link-time stand-ins for the parts of mesh::Identity that the core and the server +// helpers reference. The real implementations need the board crypto libraries, +// which do not exist on the host. +// +// These are deterministic, NOT cryptographic. The roles sign every advertisement +// they schedule, so a stub that refused to run would turn ordinary operation into a +// test failure; instead each operation derives its output from its inputs, so a +// round trip is self consistent and a test stays repeatable. No test in this suite +// asserts a cryptographic property, and none may: a passing signature here says +// nothing about Ed25519. + +#include +#include + +#include + +namespace { + +// A stable byte mix, so output depends on every input byte. +void derive(uint8_t* dest, size_t dest_len, const uint8_t* a, size_t a_len, + const uint8_t* b, size_t b_len) { + uint8_t acc = 0x5A; + for (size_t i = 0; i < a_len; i++) acc = (uint8_t)(acc * 31 + a[i]); + for (size_t i = 0; i < b_len; i++) acc = (uint8_t)(acc * 31 + b[i]); + for (size_t i = 0; i < dest_len; i++) { + acc = (uint8_t)(acc * 31 + (uint8_t)i); + dest[i] = acc; + } +} + +} // namespace + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + derive(sig, SIGNATURE_SIZE, prv_key, PRV_KEY_SIZE, message, (size_t)msg_len); +} + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + // Cannot be checked without the private key, so accept anything. A test that + // needs to tell a good signature from a bad one must not use this stub. + return true; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + derive(secret, PUB_KEY_SIZE, prv_key, PRV_KEY_SIZE, other_pub_key, PUB_KEY_SIZE); +} + +// The identity file and the identity CLI commands are not exercised here, so these +// only have to satisfy the linker, and any call is a test failure. + +bool Identity::readFrom(Stream& s) { + ADD_FAILURE() << "Identity::readFrom must not be called by the server base tests"; + return false; +} + +bool Identity::writeTo(Stream& s) const { + ADD_FAILURE() << "Identity::writeTo must not be called by the server base tests"; + return false; +} + +void Identity::printTo(Stream& s) const { + ADD_FAILURE() << "Identity::printTo must not be called by the server base tests"; +} + +bool LocalIdentity::readFrom(Stream& s) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the server base tests"; + return false; +} + +bool LocalIdentity::writeTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the server base tests"; + return false; +} + +void LocalIdentity::printTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::printTo must not be called by the server base tests"; +} + +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the server base tests"; + return 0; +} + +void LocalIdentity::readFrom(const uint8_t* src, size_t len) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the server base tests"; +} + +bool LocalIdentity::validatePrivateKey(const uint8_t prv[64]) { + ADD_FAILURE() << "LocalIdentity::validatePrivateKey must not be called by the server base tests"; + return false; +} + +} // namespace mesh diff --git a/test/test_server_base/test_server_base.cpp b/test/test_server_base/test_server_base.cpp new file mode 100644 index 00000000000..17d652d0ea0 --- /dev/null +++ b/test/test_server_base/test_server_base.cpp @@ -0,0 +1,154 @@ +// Characterization tests for the server underpinnings the three roles carry in +// triplicate (see docs/refactoring/0004-shared-server-base.md). They run the real +// role implementation on the host against the mocks in test/mocks, so the recorded +// behaviour is the firmware's, not a copy of its formulas. +// +// This file pins the temporary radio parameters, which step 4c moves into the +// shared base: what `set radio ...` schedules, when the pending values reach the +// radio, that they reach it once, and when the node reverts to its own settings. +// The room server stands in for the three roles here; the repeater and the sensor +// need an environment of their own, because both of them also define `MyMesh`. + +#include + +#include +#include +#include +#include + +#include "MyMesh.h" + +namespace { + +// The delay the role gives the CLI to answer before the pending values are applied. +constexpr uint32_t APPLY_DELAY_MS = 2000; + +// The clock the role starts from in these tests. +constexpr uint32_t T0 = 1000; + +// Dispatcher::millisHasNowPassed() is `(long)(now - timestamp) > 0`, so a timer set +// for T fires at T + 1, not at T. That one millisecond is pinned deliberately: it is +// the observable edge of every scheduled action in the roles, and the signed +// difference is what makes the comparison survive the millis() rollover. + +class ServerBaseTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + + // Built the way the role's own main.cpp builds it. + std::unique_ptr mesh; + + float orig_freq = 0, orig_bw = 0; + uint8_t orig_sf = 0, orig_cr = 0; + + void SetUp() override { + g_mock_millis = T0; // not 0, so a scheduled time is distinguishable from "off" + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + mesh.reset(new MyMesh(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + + // begin() pushes the node's own settings to the radio; remember them as the + // values a revert has to restore, without naming the defaults here. + orig_freq = radio_driver.freq; + orig_bw = radio_driver.bw; + orig_sf = radio_driver.sf; + orig_cr = radio_driver.cr; + radio_driver.set_params_calls = 0; + } +}; + +TEST_F(ServerBaseTest, BeginPushesTheNodesOwnSettingsToTheRadio) { + EXPECT_GT(orig_freq, 0.0f); + EXPECT_GT(orig_bw, 0.0f); + EXPECT_GT(orig_sf, 0); +} + +TEST_F(ServerBaseTest, TempParamsAreNotAppliedBeforeTheReplyDelay) { + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, 3); + + mesh->loop(); + EXPECT_EQ(radio_driver.set_params_calls, 0); // still inside the delay + EXPECT_FLOAT_EQ(radio_driver.freq, orig_freq); + + // The scheduled moment itself is still too early. + g_mock_millis = T0 + APPLY_DELAY_MS; + mesh->loop(); + EXPECT_EQ(radio_driver.set_params_calls, 0); + EXPECT_FLOAT_EQ(radio_driver.freq, orig_freq); +} + +TEST_F(ServerBaseTest, TempParamsReachTheRadioOneMillisecondAfterTheDelay) { + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, 3); + + g_mock_millis = T0 + APPLY_DELAY_MS + 1; + mesh->loop(); + + EXPECT_EQ(radio_driver.set_params_calls, 1); + EXPECT_FLOAT_EQ(radio_driver.freq, 867.5f); + EXPECT_FLOAT_EQ(radio_driver.bw, 125.0f); + EXPECT_EQ(radio_driver.sf, 11); + EXPECT_EQ(radio_driver.cr, 6); +} + +TEST_F(ServerBaseTest, TempParamsAreAppliedOnlyOnce) { + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, 3); + + g_mock_millis = T0 + APPLY_DELAY_MS + 1; + mesh->loop(); + ASSERT_EQ(radio_driver.set_params_calls, 1); + + mesh->loop(); + mesh->loop(); + EXPECT_EQ(radio_driver.set_params_calls, 1); // the timer is cleared when it fires +} + +TEST_F(ServerBaseTest, TheNodeRevertsToItsOwnSettingsAfterTheTimeout) { + const uint32_t timeout_mins = 3; + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, (int)timeout_mins); + + g_mock_millis = T0 + APPLY_DELAY_MS + 1; + mesh->loop(); + ASSERT_FLOAT_EQ(radio_driver.freq, 867.5f); + + // The revert is scheduled from the time of the command, not from the apply, and + // it is the same strictly-greater comparison. + const uint32_t revert_at = T0 + APPLY_DELAY_MS + timeout_mins * 60 * 1000; + g_mock_millis = revert_at; + mesh->loop(); + EXPECT_FLOAT_EQ(radio_driver.freq, 867.5f); // the scheduled moment is still early + + g_mock_millis = revert_at + 1; + mesh->loop(); + EXPECT_FLOAT_EQ(radio_driver.freq, orig_freq); + EXPECT_FLOAT_EQ(radio_driver.bw, orig_bw); + EXPECT_EQ(radio_driver.sf, orig_sf); + EXPECT_EQ(radio_driver.cr, orig_cr); +} + +TEST_F(ServerBaseTest, RevertHappensOnlyOnce) { + mesh->applyTempRadioParams(867.5f, 125.0f, 11, 6, 1); + + g_mock_millis = T0 + APPLY_DELAY_MS + 60 * 1000 + 1; + mesh->loop(); + ASSERT_FLOAT_EQ(radio_driver.freq, orig_freq); // applied and reverted in one loop + const int after_revert = radio_driver.set_params_calls; + + mesh->loop(); + mesh->loop(); + EXPECT_EQ(radio_driver.set_params_calls, after_revert); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 5d28fa44cdb6ac651f1d9d4f7383d1913f0a2216 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 22:45:08 +0200 Subject: [PATCH 018/136] docs(refactoring): record that the server roles now run on the host The cut plan listed the characterization depth as an open decision, between leaning on the diff for the identical moves and building a harness for the real role classes. Probing the compiler settled it: the native build lacked three headers and a handful of mock methods, not a deep rework, so the harness exists and the question is closed. Also notes the state of review finding F6 for the server roles, since the harness is the first time any of that code runs on the host. --- docs/refactoring/0004-shared-server-base.md | 27 ++++++++++++------- docs/refactoring/architecture-review-v1.18.md | 7 +++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md index f58c89409b4..0e38c2bb826 100644 --- a/docs/refactoring/0004-shared-server-base.md +++ b/docs/refactoring/0004-shared-server-base.md @@ -157,13 +157,20 @@ small: revisited it. Aligning the sensor adopts that experiment. The alternative is to keep the slot scheme for the sensor as the one role parameter of the shared base. Marcel decides before 4f. -2. **How deep the characterization goes.** The three role classes cannot be - instantiated on the host today: they pull in `RTClib`, `target.h`, a file system - and `CommonCLI`, and `test/mocks/` covers only part of that. Two options, one - decision: - - Pin the moved behaviour forward: for 4a the move is byte identical in all - three roles and provable by diff, and the new host test then guards the shared - base from that point on. Cheap, and the diff is the proof for 4a. - - Build a host harness for the real role classes first (a file system mock on top - of the existing mocks), then pin the current behaviour of all three roles - before moving anything. Expensive, and every later step benefits. +2. **How deep the characterization goes: settled by measuring it.** The question was + whether the real role classes can be driven on the host at all, or whether the + steps have to lean on the diff alone. Probing the compiler rather than estimating + showed the gap was small: three headers the native build lacked (a file system, an + RTC, the LittleFS global) and a handful of methods and globals missing from the + existing mocks. That is now built, and all three role implementations compile + natively; `native_server` runs the real room server, and the first six + characterization tests pin the temporary radio parameters. + + What remains open is only how far to carry it: + - The repeater and the sensor each need an environment of their own, because the + repeater and the room server both define `MyMesh`. + - The deeper paths (`handleRequest`, `onPeerDataRecv`) need packets and a + populated ACL, which is more harness than the steps in this note require. + + This also answers review finding F6 ("Little of this can be tested on the host") + for the server roles, which was not the goal of this step but is the larger gain. diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md index 651274ee4da..c3bffe6e9e6 100644 --- a/docs/refactoring/architecture-review-v1.18.md +++ b/docs/refactoring/architecture-review-v1.18.md @@ -116,6 +116,13 @@ The native test environment builds four source files (`platformio.ini`, `[env:native]`). None of the role code, the persistence code or the radio CLI was covered before step 1. +Status: the radio CLI and the prefs are covered since steps 1 to 3, and the server +roles themselves since the harness of step 4: `test/mocks` now carries an in-memory +file system, an RTC and the globals a variant's `target.h` declares, so all three +role implementations compile natively and `native_server` drives the real room +server. The gap that is left is the deeper request handling, which needs packets and +a populated ACL. + ## Issues noticed while reading (not part of this series) Found by reading v1.18.0, not reproduced on hardware. Each should get its own From 07d9e7b3cd2689ce35fc97fcacb4609be24fbfca Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 22:54:35 +0200 Subject: [PATCH 019/136] test(chat): pin the text length that sendGroupMessage accepts Adds GroupMessageTest in a new native_chat environment: the lengths that must keep working (empty text, short text, one byte across a cipher block, the longest text that fits, and a length beyond that, which is clamped and not refused), and the two lengths a short companion frame produces (-1 and -4). Each case is observed through the payload length of the packet that reaches the packet manager, so "refused before sending" and "sent" are told apart by whether a packet was queued at all. The last case characterises sendGroupData(), the sibling that already refuses a negative length. The environment builds the real Utils.cpp, Packet.cpp, Dispatcher.cpp, Mesh.cpp, BaseChatMesh.cpp, AdvertDataHelpers.cpp and TxtDataHelpers.cpp on the host, with fakes for radio, clock, RNG and packet manager and with the same link-time identity stubs the board crypto would otherwise provide. test/mocks/Arduino.h gains cstdio, cstdlib, cstring and ltoa(), which the Arduino API provides and the host C library does not. The native environment ignores the new folder because it does not build the chat layer. This commit is red on purpose, and that is the point of it: $ pio test -e native_chat Testing... Program received signal SIGBUS (Bus error: 10) native_chat test_chat_group_msg ERRORED 00:00:07.591 $ pio test -e native_chat -a "--gtest_filter=-GroupMessageTest.Refuses*" GroupMessageTest.SendsEmptyText [PASSED] GroupMessageTest.SendsShortText [PASSED] GroupMessageTest.SendsTextAcrossABlockBoundary [PASSED] GroupMessageTest.ClampsTextThatIsTooLong [PASSED] GroupMessageTest.SendsTheLongestTextThatFits [PASSED] GroupMessageTest.GroupDataAlreadyRefusesNegativeLength [PASSED] 6 test cases: 6 succeeded in 00:00:03.471 The six cases that describe today's behaviour pass, so the expectations are right; only the two negative lengths bring the process down, in the memcpy of sendGroupMessage. The next commit makes them pass. Why: a companion frame shorter than 7 bytes makes MyMesh.cpp compute a negative text length for this call, and a T-Echo running dev stops answering for good when it arrives. Without a test at this level the fix would rest on the frame handler alone, and every other caller of sendGroupMessage would stay unguarded. --- platformio.ini | 24 +- test/mocks/Arduino.h | 10 + test/test_chat_group_msg/identity_stubs.cpp | 28 +++ .../test_group_msg_len.cpp | 206 ++++++++++++++++++ 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 test/test_chat_group_msg/identity_stubs.cpp create mode 100644 test/test_chat_group_msg/test_group_msg_len.cpp diff --git a/platformio.ini b/platformio.ini index f296f838213..99edcef8749 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,7 +167,9 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = + test_kiss_modem + test_chat_group_msg build_src_filter = -<*> +<../src/Utils.cpp> @@ -177,6 +179,26 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_chat] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks +test_build_src = yes +test_filter = test_chat_group_msg +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/BaseChatMesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/TxtDataHelpers.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_kiss_modem] platform = native test_framework = googletest diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe4145..8d01624456e 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -2,6 +2,9 @@ #include #include +#include +#include +#include #include "Stream.h" inline uint32_t g_mock_millis = 0; @@ -15,3 +18,10 @@ inline uint32_t millis() { inline void delay(uint32_t ms) { g_mock_millis += ms; } + +// ltoa() is part of the Arduino API and has no equivalent in the host C library, +// so code that formats numbers (TxtDataHelpers.cpp) needs it spelled out here. +inline char* ltoa(long value, char* dest, int base) { + snprintf(dest, 24, base == 16 ? "%lx" : "%ld", value); + return dest; +} diff --git a/test/test_chat_group_msg/identity_stubs.cpp b/test/test_chat_group_msg/identity_stubs.cpp new file mode 100644 index 00000000000..d387e421204 --- /dev/null +++ b/test/test_chat_group_msg/identity_stubs.cpp @@ -0,0 +1,28 @@ +// Link-time stand-ins for the parts of mesh::Identity that BaseChatMesh.cpp and +// Mesh.cpp reference. The real implementations need the board crypto libraries, +// which do not exist on the host. These tests never sign, verify or derive a +// shared secret, so any call is a test failure. + +#include +#include + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + ADD_FAILURE() << "Identity::verify must not be called by the group message tests"; + return false; +} + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + ADD_FAILURE() << "LocalIdentity::sign must not be called by the group message tests"; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + ADD_FAILURE() << "LocalIdentity::calcSharedSecret must not be called by the group message tests"; +} + +} // namespace mesh diff --git a/test/test_chat_group_msg/test_group_msg_len.cpp b/test/test_chat_group_msg/test_group_msg_len.cpp new file mode 100644 index 00000000000..2afd3605650 --- /dev/null +++ b/test/test_chat_group_msg/test_group_msg_len.cpp @@ -0,0 +1,206 @@ +// Tests for the text length that BaseChatMesh::sendGroupMessage() accepts. +// +// The companion firmware derives that length by subtracting a fixed header size +// from the length of the frame the app sent, so a frame that is too short turns +// the length negative. sendGroupMessage() hands the value to memcpy(), which +// reads it as a size_t, so a negative length asks for about 4 GB. The sibling +// sendGroupData(), added later for CMD_SEND_CHANNEL_DATA, already refuses a +// negative length; these tests pin the same behaviour for sendGroupMessage() +// and keep the lengths that must still work. +// +// They run the real BaseChatMesh.cpp, Mesh.cpp, Dispatcher.cpp and Packet.cpp +// against fakes for the radio, clock, RNG and packet manager, and against the +// link-time identity stubs in identity_stubs.cpp. + +#include + +#include +#include + +#include +#include + +namespace { + +class FakeRadio : public mesh::Radio { +public: + int recvRaw(uint8_t* bytes, int sz) override { return 0; } + uint32_t getEstAirtimeFor(int len_bytes) override { return 10; } + float packetScore(float snr, int packet_len) override { return 0; } + bool startSendRaw(const uint8_t* bytes, int len) override { return false; } + bool isSendComplete() override { return true; } + void onSendFinished() override { } + bool isInRecvMode() const override { return true; } +}; + +class FakeClock : public mesh::MillisecondClock { +public: + unsigned long getMillis() override { return 0; } +}; + +class FakeRTC : public mesh::RTCClock { +public: + uint32_t getCurrentTime() override { return 0; } + void setCurrentTime(uint32_t time) override { } +}; + +class ZeroRNG : public mesh::RNG { +public: + void random(uint8_t* dest, size_t sz) override { memset(dest, 0, sz); } +}; + +// Hands out packets from a small pool and keeps every packet that was queued for +// sending, so a test can tell "refused before sending" from "sent" and can read +// the payload length the chat layer produced. +class CapturingPacketManager : public mesh::PacketManager { + static const int POOL = 4; + mesh::Packet _pool[POOL]; + bool _in_use[POOL] = { false, false, false, false }; +public: + std::vector queued; + + mesh::Packet* allocNew() override { + for (int i = 0; i < POOL; i++) { + if (!_in_use[i]) { + _in_use[i] = true; + return &_pool[i]; + } + } + return nullptr; + } + void free(mesh::Packet* packet) override { + for (int i = 0; i < POOL; i++) { + if (&_pool[i] == packet) _in_use[i] = false; + } + } + void queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override { + queued.push_back(packet); + } + mesh::Packet* getNextOutbound(uint32_t now) override { return nullptr; } + int getOutboundCount(uint32_t now) const override { return (int)queued.size(); } + int getOutboundTotal() const override { return (int)queued.size(); } + int getFreeCount() const override { return POOL; } + mesh::Packet* getOutboundByIdx(int i) override { return nullptr; } + mesh::Packet* removeOutboundByIdx(int i) override { return nullptr; } + void queueInbound(mesh::Packet* packet, uint32_t scheduled_for) override { } + mesh::Packet* getNextInbound(uint32_t now) override { return nullptr; } +}; + +// The chat layer is abstract; the callbacks are not reached by these tests. +class TestChatMesh : public BaseChatMesh { +public: + TestChatMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, + mesh::PacketManager& mgr, mesh::MeshTables& tables) + : BaseChatMesh(radio, ms, rng, rtc, mgr, tables) { } + + void onDiscoveredContact(ContactInfo& c, bool is_new, uint8_t path_len, const uint8_t* path) override { } + ContactInfo* processAck(const uint8_t* data) override { return nullptr; } + void onContactPathUpdated(const ContactInfo& c) override { } + void onMessageRecv(const ContactInfo& c, mesh::Packet* p, uint32_t ts, const char* text) override { } + void onCommandDataRecv(const ContactInfo& c, mesh::Packet* p, uint32_t ts, const char* text) override { } + void onCLICommandRecv(const ContactInfo& c, mesh::Packet* p, uint32_t ts, const char* text, char* reply) override { } + void onSignedMessageRecv(const ContactInfo& c, mesh::Packet* p, uint32_t ts, const uint8_t* prefix, + const char* text) override { } + uint32_t calcFloodTimeoutMillisFor(uint32_t airtime) const override { return 0; } + uint32_t calcDirectTimeoutMillisFor(uint32_t airtime, uint8_t path_len) const override { return 0; } + void onSendTimeout() override { } + void onChannelMessageRecv(const mesh::GroupChannel& ch, mesh::Packet* p, uint32_t ts, const char* text) override { } + uint8_t onContactRequest(const ContactInfo& c, uint32_t ts, const uint8_t* data, uint8_t len, + uint8_t* reply) override { return 0; } + void onContactResponse(const ContactInfo& c, const uint8_t* data, uint8_t len) override { } +}; + +const char* SENDER = "node"; // the prefix becomes "node: ", so 6 bytes +const int PREFIX_LEN = 6; +const int HEADER_LEN = 5; // 4 byte timestamp plus the TXT_TYPE byte + +// A group datagram carries the channel hash, the MAC and the ciphertext, and the +// cipher pads to whole blocks. That makes the payload length a function of the +// text length alone, which is what these tests observe. +int expectedPayloadLen(int text_len) { + int plain = HEADER_LEN + PREFIX_LEN + text_len; + int blocks = (plain + CIPHER_BLOCK_SIZE - 1) / CIPHER_BLOCK_SIZE; + return PATH_HASH_SIZE + CIPHER_MAC_SIZE + blocks * CIPHER_BLOCK_SIZE; +} + +class GroupMessageTest : public ::testing::Test { +protected: + FakeRadio radio; + FakeClock ms; + ZeroRNG rng; + FakeRTC rtc; + SimpleMeshTables tables; + CapturingPacketManager mgr; + TestChatMesh mesh_node { radio, ms, rng, rtc, mgr, tables }; + mesh::GroupChannel channel; + // Long enough that a clamped length still reads inside this buffer. + char text[256]; + + void SetUp() override { + memset(&channel, 0, sizeof(channel)); + memset(text, 'x', sizeof(text)); + text[sizeof(text) - 1] = 0; + } +}; + +// A frame of 3 bytes makes the companion compute 3 - 7 = -4 (MyMesh.cpp, +// CMD_SEND_CHANNEL_TXT_MSG). Measured on a T-Echo running dev 428a95c6: the node +// stopped answering and never came back. +TEST_F(GroupMessageTest, RefusesTheLengthAThreeByteFrameProduces) { + EXPECT_FALSE(mesh_node.sendGroupMessage(0, channel, SENDER, text, -4)); + EXPECT_TRUE(mgr.queued.empty()) << "nothing may be sent for a negative text length"; +} + +TEST_F(GroupMessageTest, RefusesMinusOne) { + EXPECT_FALSE(mesh_node.sendGroupMessage(0, channel, SENDER, text, -1)); + EXPECT_TRUE(mgr.queued.empty()); +} + +TEST_F(GroupMessageTest, SendsEmptyText) { + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, 0)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(0), mgr.queued[0]->payload_len); +} + +TEST_F(GroupMessageTest, SendsShortText) { + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, 5)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(5), mgr.queued[0]->payload_len); +} + +// One byte past a cipher block, to catch an off-by-one in the length arithmetic. +TEST_F(GroupMessageTest, SendsTextAcrossABlockBoundary) { + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, 6)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(6), mgr.queued[0]->payload_len); +} + +// Too long is clamped, not refused: that is the behaviour the app relies on. +TEST_F(GroupMessageTest, ClampsTextThatIsTooLong) { + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, 200)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(MAX_TEXT_LEN - PREFIX_LEN), mgr.queued[0]->payload_len); +} + +// The exact boundary of the clamp still goes out unchanged. +TEST_F(GroupMessageTest, SendsTheLongestTextThatFits) { + const int longest = MAX_TEXT_LEN - PREFIX_LEN; + EXPECT_TRUE(mesh_node.sendGroupMessage(0, channel, SENDER, text, longest)); + ASSERT_EQ(1u, mgr.queued.size()); + EXPECT_EQ(expectedPayloadLen(longest), mgr.queued[0]->payload_len); +} + +// Characterisation of the sibling this fix follows, so the symmetry stays visible. +TEST_F(GroupMessageTest, GroupDataAlreadyRefusesNegativeLength) { + uint8_t path[1] = { 0 }; + uint8_t data[4] = { 1, 2, 3, 4 }; + EXPECT_FALSE(mesh_node.sendGroupData(channel, path, OUT_PATH_UNKNOWN, 0x0102, data, -1)); + EXPECT_TRUE(mgr.queued.empty()); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From bc5708f7e17c72f59bc9ebe3c673d3a7e593c440 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 22:52:03 +0200 Subject: [PATCH 020/136] fix(prefs): check the ranges after loading /prefs.json RCC-SKIPPED: defect and cause established by reading the source; the root cause chain was waived for this fix. What changes - The sanitising that the binary prefs loader has always done moves out of loadPrefsInt() into CommonCLI::sanitisePrefs(), and the /prefs.json path calls it too. The fields are the ones the binary loader already covered. - One limit is corrected while moving it: airtime_factor is clamped to 0..99, not 0..9. "set dutycycle 1" stores 100/1 - 1 = 99, so 9 would throw away any duty cycle below 10 percent at the next boot. The limit of 9 was written in 01d84d5d (2025-03-05) and "set dutycycle" was added in 0aa0ec1f (2026-03-07), so the limit was never adjusted for it. Every other limit is unchanged. "set tx" and "set multi.acks" have no check in the CLI at all, so for those the loader's limit is the only guard and it stays as it was. - ConfigSerializer no longer stores a float or double that is not a finite number. A token such as nan, inf or 1e40 leaves the field at the value it already had, which is the default the firmware set up before reading the file. The check looks at the bits, not at a comparison, and for floats it runs after the narrowing, because a value that is finite as a double can overflow to infinity as a float. - loadPrefs() logs it when loadSerial() reports a parse error. The result was discarded before. Behaviour is otherwise unchanged: a partially parsed file is still applied, and now sanitised. Why - Server roles read /prefs.json through _prefs->loadSerial(file) and used whatever was in it. The file is plain text on a filesystem that a user can write, and values out of range reach the radio setup directly: freq, bw, sf, cr, tx_power_dbm, the delay factors, the bridge settings. - The binary loader sanitised all of this and had done so before /prefs.json existed, with the comment "sanitise bad pref values". The JSON path lost that, so the protection depended on which file a node happened to boot from. - Non-finite values need their own handling because constrain() cannot catch them: constrain(NaN, 0, 9) returns NaN, since both of its comparisons are false for a NaN. Infinity is clamped, NaN is not. - Clamping a NaN into range has no sensible answer, which is why it is rejected at the parser instead. The firmware sets its defaults before reading the file (for the repeater at MyMesh.cpp:891 onwards, with loadPrefs at :949), so leaving the field untouched keeps the build default. Clamping to the nearest bound would put a node whose file says freq:nan on 150 MHz. Compatibility - The binary loader keeps its behaviour except for airtime_factor, which it now leaves alone up to 99 instead of cutting it to 9. - On the /prefs.json path a value outside the limits is now clamped at boot where it used to be taken as is. The limits match what the CLI accepts, with one looser spot: the CLI takes a bandwidth from 7.0, while the loader clamps to 7.8, which is the smallest bandwidth the radios offer. - A file holding a non-finite number now leaves that one field at its default instead of poisoning it. Every other key in the file is still applied. - The change to ConfigSerializer also covers the companion NodePrefs, which uses the same serializer. Evidence - Five new cases in test_config_serializer. Three are red on upstream/dev 428a95c6 and green with the change: a file with nan, a file with inf and -inf, and a float that overflows to infinity. Two pin behaviour that must not change: finite values are stored, and garbage still parses as zero. - pio test -e native -e native_kiss_modem: 59 of 59. - Five deliberate mutations of the parser check were each caught: dropping the check in either path, checking before the narrowing to float, inverting the bit comparison, and using the float mask for doubles. - sanitisePrefs() itself has no host test: no native environment compiles CommonCLI.cpp on this branch. It is covered by the build of every role and by a board test with a hand written /prefs.json holding out of range values. --- src/helpers/CommonCLI.cpp | 80 +++++++++++-------- src/helpers/CommonCLI.h | 1 + src/helpers/ConfigSerializer.cpp | 33 +++++++- .../test_config_serializer.cpp | 69 ++++++++++++++++ 4 files changed, 149 insertions(+), 34 deletions(-) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 04199e24d1d..95a56bbf66a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -27,6 +27,49 @@ static bool isValidName(const char *n) { return true; } +// The binary loader has sanitised these values since before /prefs.json existed. +// Both loaders call it now, so a file that was edited by hand or written by +// another tool cannot leave the node on values its own CLI would refuse. +// Non-finite floats never reach this point: ConfigSerializer keeps the default +// in place for those, and constrain() would pass a NaN through unchanged. +void CommonCLI::sanitisePrefs() { + // sanitise bad pref values + _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); + _prefs->tx_delay_factor = constrain(_prefs->tx_delay_factor, 0, 2.0f); + _prefs->direct_tx_delay_factor = constrain(_prefs->direct_tx_delay_factor, 0, 2.0f); + // 99 is what the CLI can produce, not the 9 of "set af": "set dutycycle 1" + // stores 100/1 - 1. The old limit of 9 predates that command by a year + // (01d84d5d 2025-03-05 against 0aa0ec1f 2026-03-07) and would throw away a + // duty cycle below 10 percent at the next boot. + _prefs->airtime_factor = constrain(_prefs->airtime_factor, 0, 99.0f); + _prefs->freq = constrain(_prefs->freq, 150.0f, 2500.0f); + _prefs->bw = constrain(_prefs->bw, 7.8f, 500.0f); + _prefs->sf = constrain(_prefs->sf, 5, 12); + _prefs->cr = constrain(_prefs->cr, 5, 8); + _prefs->tx_power_dbm = constrain(_prefs->tx_power_dbm, -9, 30); + _prefs->multi_acks = constrain(_prefs->multi_acks, 0, 1); + _prefs->adc_multiplier = constrain(_prefs->adc_multiplier, 0.0f, 10.0f); + _prefs->path_hash_mode = constrain(_prefs->path_hash_mode, 0, 2); // NOTE: mode 3 reserved for future + + // sanitise bad bridge pref values + _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); + _prefs->bridge_delay = constrain(_prefs->bridge_delay, 0, 10000); + _prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1); + _prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD); + _prefs->bridge_channel = constrain(_prefs->bridge_channel, 0, 14); + + _prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1); + + _prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1); + _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2); + + // sanitise settings + _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean + _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean + _prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 0, 1); // boolean + _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean +} + void CommonCLI::loadPrefs(FILESYSTEM* fs) { if (fs->exists("/prefs.json")) { #if defined(RP2040_PLATFORM) @@ -35,8 +78,11 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { File file = fs->open("/prefs.json"); #endif if (file) { - _prefs->loadSerial(file); // new Serial prefs + if (!_prefs->loadSerial(file)) { + MESH_DEBUG_PRINTLN("loadPrefs: /prefs.json did not parse cleanly"); + } file.close(); + sanitisePrefs(); } } else if (fs->exists("/com_prefs")) { loadPrefsInt(fs, "/com_prefs"); @@ -104,39 +150,9 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294 // next: 295 - // sanitise bad pref values - _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f); - _prefs->tx_delay_factor = constrain(_prefs->tx_delay_factor, 0, 2.0f); - _prefs->direct_tx_delay_factor = constrain(_prefs->direct_tx_delay_factor, 0, 2.0f); - _prefs->airtime_factor = constrain(_prefs->airtime_factor, 0, 9.0f); - _prefs->freq = constrain(_prefs->freq, 150.0f, 2500.0f); - _prefs->bw = constrain(_prefs->bw, 7.8f, 500.0f); - _prefs->sf = constrain(_prefs->sf, 5, 12); - _prefs->cr = constrain(_prefs->cr, 5, 8); - _prefs->tx_power_dbm = constrain(_prefs->tx_power_dbm, -9, 30); - _prefs->multi_acks = constrain(_prefs->multi_acks, 0, 1); - _prefs->adc_multiplier = constrain(_prefs->adc_multiplier, 0.0f, 10.0f); - _prefs->path_hash_mode = constrain(_prefs->path_hash_mode, 0, 2); // NOTE: mode 3 reserved for future - - // sanitise bad bridge pref values - _prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1); - _prefs->bridge_delay = constrain(_prefs->bridge_delay, 0, 10000); - _prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1); - _prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD); - _prefs->bridge_channel = constrain(_prefs->bridge_channel, 0, 14); - - _prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1); - - _prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1); - _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2); - - // sanitise settings - _prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean - _prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean - _prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 0, 1); // boolean - _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean file.close(); + sanitisePrefs(); } } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2759b0c4f0e..a8b28d13d7d 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -308,6 +308,7 @@ class CommonCLI { mesh::RTCClock* getRTCClock() { return _rtc; } void savePrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); + void sanitisePrefs(); void handleRegionCmd(char* command, char* reply); void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply); diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index daf834b2b10..4e1913b4690 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -1,5 +1,27 @@ #include "ConfigSerializer.h" #include // atoi/atol/atof (Arduino.h pulls this in on-device, native builds do not) +#include // memcpy, for the bit check below +#include + +namespace { + +// True if the value is a finite number. NaN and infinity are recognised by +// their bits, not by a comparison: nRF52 builds compile with -Ofast, whose +// -ffast-math lets float comparisons assume that NaN and infinity never +// occur, so a comparison alone may let them through. +bool isFinite(float v) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + return (bits & 0x7F800000u) != 0x7F800000u; +} + +bool isFinite(double v) { + uint64_t bits; + memcpy(&bits, &v, sizeof(bits)); + return (bits & 0x7FF0000000000000ull) != 0x7FF0000000000000ull; +} + +} // namespace bool ConfigSerializer::saveSerial(Stream& s) { Context context(&s, OP::WRITE); @@ -294,7 +316,11 @@ void ConfigSerializer::def(const char* key, double& value) { } } else { if (_context->keyMatch(_depth, key)) { - value = atof(_context->getToken()); + // A token that is not a finite number is no value: leave what is already + // in the field, which is the default the firmware set up before reading + // the file. + double parsed = atof(_context->getToken()); + if (isFinite(parsed)) value = parsed; } } } @@ -311,7 +337,10 @@ void ConfigSerializer::def(const char* key, float& value) { } } else { if (_context->keyMatch(_depth, key)) { - value = (float) atof(_context->getToken()); + // Checked after the narrowing to float, since a value that is finite as + // a double can overflow to infinity as a float. + float parsed = (float) atof(_context->getToken()); + if (isFinite(parsed)) value = parsed; } } } diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index 80d5e78086d..0cfc4cfe244 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -74,6 +74,17 @@ class TestStruct : public ConfigSerializer { uint8_t flags; }; +class TestNumbers : public ConfigSerializer { + protected: + void structure() override { + def("ratio", ratio); + def("lat", lat); + } + public: + float ratio = 1.5f; + double lat = 2.5; +}; + // ── saveSerial: basic ─────────────────────────────────────────────────────── TEST(ConfigSerializer, SaveSerial_Basic) { @@ -313,6 +324,64 @@ TEST(DynamicConfigSerializer, LoadCustom_Basic) { // ── main ─────────────────────────────────────────────────────── +// ── loadSerial: numbers that are not finite ──────────────────────────────── + +// The values a node runs on are set up before /prefs.json is read, so a token +// that is not a finite number has to leave the value that is already there. +// Clamping it into range is no help: there is no sensible nearest value for a +// NaN, and for a frequency the nearest bound would put the radio on 150 MHz. + +TEST(ConfigSerializer, LoadSerial_KeepsDefaultOnNotANumber) { + MockInputStream s("{ratio:nan,lat:nan}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(1.5f, data.ratio); + EXPECT_DOUBLE_EQ(2.5, data.lat); +} + +TEST(ConfigSerializer, LoadSerial_KeepsDefaultOnInfinity) { + MockInputStream s("{ratio:inf,lat:-inf}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(1.5f, data.ratio); + EXPECT_DOUBLE_EQ(2.5, data.lat); +} + +// 1e40 is finite as a double and overflows to infinity as a float, so the +// check has to look at the value that is about to be stored. +TEST(ConfigSerializer, LoadSerial_KeepsDefaultWhenFloatOverflows) { + MockInputStream s("{ratio:1e40}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(1.5f, data.ratio); +} + +TEST(ConfigSerializer, LoadSerial_AcceptsFiniteNumbers) { + MockInputStream s("{ratio:-6.125,lat:12.5}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(-6.125f, data.ratio); + EXPECT_DOUBLE_EQ(12.5, data.lat); +} + +// Garbage parses as zero and is stored, which is what the old code did. +TEST(ConfigSerializer, LoadSerial_StoresZeroForGarbage) { + MockInputStream s("{ratio:abc}"); + + TestNumbers data; + EXPECT_TRUE(data.loadSerial(s)); + + EXPECT_FLOAT_EQ(0.0f, data.ratio); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From e281f0ea0ffe898031943d5f46b2d266799f21b7 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 22:32:03 +0200 Subject: [PATCH 021/136] fix(identity): report a failed identity write What changes - IdentityStore::save() returns the result of writing the identity instead of always returning true. Both overloads were affected: the two argument form computed the result, logged it and then discarded it, and the form that also stores a display name ignored the result of both writes. - In that second form the display name write is now part of the result, so a medium that fills up between the keypair and the name is a failure, and it gets the debug line the other form already had. - New native environment native_identity_store covers IdentityStore against a mock filesystem whose file opens and then runs out of write budget, which is how a full or worn flash behaves. The unit test workflow runs it. - test/mocks gains that filesystem (Adafruit_LittleFS.h) and the serialisation of mesh::Identity and mesh::LocalIdentity, copied from src/Identity.cpp so that a short write is a failure in the double as well. test/mocks/Utils.h gains the no-op MESH_DEBUG macros that src/MeshCore.h defines for firmware builds, since the mock stands in for that header's constants. Why - Every role generates a keypair when the load at boot finds no file and then saves it: simple_repeater/main.cpp:94, simple_room_server/main.cpp:75, simple_sensor/main.cpp:101, kiss_modem/main.cpp:56, simple_secure_chat/main.cpp:327, companion_radio/MyMesh.cpp:955. When that write fails the node keeps running under an identity that never reached the medium and comes back with a different public key after the next boot. - One caller already acts on the result and is written for an honest one: CMD_IMPORT_PRIVATE_KEY in companion_radio/MyMesh.cpp:1544 adopts the imported key and answers OK when save() reports success, and answers ERR_CODE_FILE_IO_ERROR when it does not. While save() always reported success, a failed import was answered with OK and the old key came back at the next boot. Compatibility - The success path is unchanged, so nothing differs while the filesystem accepts the write. - All nine call sites were checked for what a false now triggers. Eight discard the result and are unchanged; three of those reach save() through void wrappers (simple_repeater and simple_room_server MyMesh::saveIdentity, SensorMesh::saveIdentity) that cannot propagate it anyway. The only consumer is CMD_IMPORT_PRIVATE_KEY above, which already has its error path. No boot path reads the result, so a failing write adds no error cascade at startup. - The overload that stores a display name has no caller in this repository. Evidence - Red before the change, green after: ReportsFailureWhenNoByteCanBeWritten, ReportsFailureWhenThePrivateKeyIsCutOff, ReportsFailureWhenTheKeypairIsCutOff and ReportsFailureWhenOnlyTheNameIsCutOff each reported "Actual: true, Expected: false" on the old code; 9 of 9 pass with the change. - pio test -e native -e native_kiss_modem -e native_identity_store: 63 of 63. - Five deliberate mutations of the new code were each caught by the suite: returning true again in either overload, dropping the check of the name write, not reading the result of writeTo(), and joining the two writes with OR instead of AND. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 19 ++- src/helpers/IdentityStore.cpp | 10 +- test/mocks/Adafruit_LittleFS.h | 116 ++++++++++++++++++ test/mocks/Identity.h | 29 ++++- test/mocks/Utils.h | 5 + .../test_identity_store.cpp | 114 +++++++++++++++++ 7 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 test/mocks/Adafruit_LittleFS.h create mode 100644 test/test_identity_store/test_identity_store.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..3af804f3c10 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_identity_store -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index f296f838213..1e993076071 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,7 +167,9 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = + test_kiss_modem + test_identity_store build_src_filter = -<*> +<../src/Utils.cpp> @@ -177,6 +179,21 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_identity_store] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks + -I src + -D NRF52_PLATFORM +test_build_src = yes +test_filter = test_identity_store +build_src_filter = + -<*> + +<../src/helpers/IdentityStore.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_kiss_modem] platform = native test_framework = googletest diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index dc85d69cddc..ddd7a452608 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -58,7 +58,7 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { bool success = id.writeTo(file); file.close(); MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err"); - return true; + return success; } MESH_DEBUG_PRINTLN("IdentityStore::save() failed"); return false; @@ -77,17 +77,19 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const File file = _fs->open(filename, "w", true); #endif if (file) { - id.writeTo(file); + bool success = id.writeTo(file); uint8_t tmp[32]; memset(tmp, 0, sizeof(tmp)); int n = strlen(display_name); if (n > sizeof(tmp)-1) n = sizeof(tmp)-1; memcpy(tmp, display_name, n); - file.write(tmp, sizeof(tmp)); + success = success && (file.write(tmp, sizeof(tmp)) == sizeof(tmp)); file.close(); - return true; + MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err"); + return success; } + MESH_DEBUG_PRINTLN("IdentityStore::save() failed"); return false; } diff --git a/test/mocks/Adafruit_LittleFS.h b/test/mocks/Adafruit_LittleFS.h new file mode 100644 index 00000000000..6d5368e5c6b --- /dev/null +++ b/test/mocks/Adafruit_LittleFS.h @@ -0,0 +1,116 @@ +#pragma once + +// Mock of the nRF52 filesystem API for native tests. Only what IdentityStore +// touches is modelled: open, exists, remove, mkdir, and a File that is a +// Stream. +// +// The write budget is the point of this mock. A real flash lets a file open +// and then refuses the write once the medium is full or worn out, so a test +// needs a file that is valid but whose writes come up short. Print::write() +// loops over write(uint8_t), so a budget that runs out mid buffer returns a +// short count, which is how the real driver reports a partial write. + +#include +#include +#include +#include +#include +#include + +#include "Stream.h" + +#define FILE_O_READ 0 +#define FILE_O_WRITE 1 + +namespace Adafruit_LittleFS_Namespace { + +struct FileData { + std::vector bytes; + size_t write_budget = std::numeric_limits::max(); +}; + +class File : public Stream { + FileData* _data; + size_t _read_pos; + bool _open; + +public: + File() : _data(nullptr), _read_pos(0), _open(false) { } + explicit File(FileData* data) : _data(data), _read_pos(0), _open(data != nullptr) { } + + // The driver signals "could not open" through the bool conversion, not an + // exception or a null return. + operator bool() const { return _open; } + + void close() { _open = false; } + + // Overriding the single byte write would otherwise hide the buffer overloads + // of Print, which is what the callers use. + using Print::write; + + size_t write(uint8_t b) override { + if (!_open || _data == nullptr || _data->write_budget == 0) return 0; + _data->bytes.push_back(b); + _data->write_budget--; + return 1; + } + + int available() override { + if (!_open || _data == nullptr) return 0; + return (int) (_data->bytes.size() - _read_pos); + } + + int read() override { + if (available() <= 0) return -1; + return _data->bytes[_read_pos++]; + } + + int peek() override { + if (available() <= 0) return -1; + return _data->bytes[_read_pos]; + } + + // The driver's File offers a buffer read next to the Stream interface, and + // IdentityStore uses it to pick up the display name. + size_t read(uint8_t* buffer, size_t size) { + size_t n = 0; + while (n < size && available() > 0) buffer[n++] = (uint8_t) read(); + return n; + } +}; + +} // namespace Adafruit_LittleFS_Namespace + +class Adafruit_LittleFS { + std::map _files; + +public: + // Test knobs. + size_t write_budget = std::numeric_limits::max(); + bool fail_open = false; + + bool exists(const char* path) { return _files.count(path) > 0; } + bool remove(const char* path) { return _files.erase(path) > 0; } + bool mkdir(const char* path) { return true; } + + Adafruit_LittleFS_Namespace::File open(const char* path, uint8_t mode = FILE_O_READ) { + if (fail_open) return Adafruit_LittleFS_Namespace::File(); + if (mode == FILE_O_WRITE) { + // std::map keeps its values at a stable address, so handing out a + // pointer stays valid while other files are added. + Adafruit_LittleFS_Namespace::FileData& data = _files[path]; + data.bytes.clear(); + data.write_budget = write_budget; + return Adafruit_LittleFS_Namespace::File(&data); + } + auto found = _files.find(path); + if (found == _files.end()) return Adafruit_LittleFS_Namespace::File(); + return Adafruit_LittleFS_Namespace::File(&found->second); + } + + // Lets a test see what actually reached the medium. + const std::vector* contents(const char* path) { + auto found = _files.find(path); + return found == _files.end() ? nullptr : &found->second.bytes; + } +}; diff --git a/test/mocks/Identity.h b/test/mocks/Identity.h index 5c3d25e94e2..4ee269d8897 100644 --- a/test/mocks/Identity.h +++ b/test/mocks/Identity.h @@ -2,6 +2,7 @@ #include #include +#include "Stream.h" #include "Utils.h" namespace mesh { @@ -21,11 +22,25 @@ class Identity { bool verify(const uint8_t*, const uint8_t*, int) const { return true; } + + // Copies of the real serialisation in src/Identity.cpp: a short read or a + // short write is a failure, which is what the file based tests rely on. + bool readFrom(Stream& s) { + return s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + } + + bool writeTo(Stream& s) const { + return s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + } }; class LocalIdentity : public Identity { public: - LocalIdentity() : Identity() {} + uint8_t prv_key[PRV_KEY_SIZE]; + + LocalIdentity() : Identity() { + std::memset(prv_key, 0, sizeof(prv_key)); + } void sign(uint8_t* sig, const uint8_t*, int) const { std::memset(sig, 0x5A, SIGNATURE_SIZE); @@ -34,6 +49,18 @@ class LocalIdentity : public Identity { void calcSharedSecret(uint8_t* secret, const uint8_t*) const { std::memset(secret, 0x11, PUB_KEY_SIZE); } + + bool readFrom(Stream& s) { + bool success = s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + success = success && s.readBytes(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE; + return success; + } + + bool writeTo(Stream& s) const { + bool success = s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE; + success = success && s.write(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE; + return success; + } }; } diff --git a/test/mocks/Utils.h b/test/mocks/Utils.h index 9bb6b060b03..260ee505238 100644 --- a/test/mocks/Utils.h +++ b/test/mocks/Utils.h @@ -10,6 +10,11 @@ #define SIGNATURE_SIZE 64 #define CIPHER_MAC_SIZE 16 +// Stands in for the same no-op definitions in src/MeshCore.h, which is the +// header the real Utils.h pulls these constants from. +#define MESH_DEBUG_PRINT(...) {} +#define MESH_DEBUG_PRINTLN(...) {} + namespace mesh { class RNG { diff --git a/test/test_identity_store/test_identity_store.cpp b/test/test_identity_store/test_identity_store.cpp new file mode 100644 index 00000000000..bac82bfe8de --- /dev/null +++ b/test/test_identity_store/test_identity_store.cpp @@ -0,0 +1,114 @@ +#include + +#include + +// IdentityStore is the only place that persists a node's keypair. Every role +// generates a new identity when the load at boot comes up empty and then saves +// it, so a save that reports success without having written anything costs the +// node its identity at the next boot. +// +// The mock filesystem lets a file open and then runs out of write budget, which +// is how a full or worn flash behaves. + +namespace { + +const size_t KEYPAIR_BYTES = PUB_KEY_SIZE + PRV_KEY_SIZE; // 96 +const size_t DISPLAY_NAME_BYTES = 32; + +mesh::LocalIdentity makeIdentity() { + mesh::LocalIdentity id; + for (size_t i = 0; i < PUB_KEY_SIZE; i++) id.pub_key[i] = (uint8_t) (0xA0 + i); + for (size_t i = 0; i < PRV_KEY_SIZE; i++) id.prv_key[i] = (uint8_t) (0x10 + i); + return id; +} + +class IdentityStoreSave : public ::testing::Test { +protected: + Adafruit_LittleFS fs; + mesh::LocalIdentity id = makeIdentity(); +}; + +TEST_F(IdentityStoreSave, ReportsSuccessWhenTheKeypairFits) { + fs.write_budget = KEYPAIR_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id)); + ASSERT_NE(fs.contents("/_main.id"), nullptr); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES); +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenTheFileCannotBeOpened) { + fs.fail_open = true; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); +} + +// The defect: the write fails, the identity is not on the medium, and the +// caller is told the save worked. +TEST_F(IdentityStoreSave, ReportsFailureWhenNoByteCanBeWritten) { + fs.write_budget = 0; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_TRUE(fs.contents("/_main.id")->empty()); +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenThePrivateKeyIsCutOff) { + fs.write_budget = PUB_KEY_SIZE + 1; // public key fits, private key does not + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_EQ(fs.contents("/_main.id")->size(), (size_t) PUB_KEY_SIZE + 1); +} + +TEST_F(IdentityStoreSave, RoundTripsAnIdentityThroughTheFilesystem) { + IdentityStore store(fs, ""); + ASSERT_TRUE(store.save("_main", id)); + + mesh::LocalIdentity loaded; + ASSERT_TRUE(store.load("_main", loaded)); + EXPECT_EQ(memcmp(loaded.pub_key, id.pub_key, PUB_KEY_SIZE), 0); + EXPECT_EQ(memcmp(loaded.prv_key, id.prv_key, PRV_KEY_SIZE), 0); +} + +class IdentityStoreSaveWithName : public IdentityStoreSave { }; + +TEST_F(IdentityStoreSaveWithName, ReportsSuccessWhenKeypairAndNameFit) { + fs.write_budget = KEYPAIR_BYTES + DISPLAY_NAME_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id, "room")); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES + DISPLAY_NAME_BYTES); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenTheKeypairIsCutOff) { + fs.write_budget = 0; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); +} + +// The display name is written after the keypair, so a medium that fills up in +// between leaves a file that loads as an identity but carries no name. +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenOnlyTheNameIsCutOff) { + fs.write_budget = KEYPAIR_BYTES; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenTheFileCannotBeOpened) { + fs.fail_open = true; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 8c59820019b83c9d85731b01c20f7a2b203653c9 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:01:41 +0200 Subject: [PATCH 022/136] fix(sensors): let BSEC claim an address only if a BME68x answered What changes - init_bme680_bsec() now returns 0 unless bsec_iaq.bme68xStatus is BME68X_OK. The check sits before the existing bsecStatus check, so a device that is not a BME68x is rejected before BSEC is configured, subscribed or handed a saved state blob. Why - Bsec::beginCommon() fills two independent status fields. bsecStatus comes from bsec_init(), which is a pure software init of the Bosch library and reports BSEC_OK even with an empty I2C bus. bme68xStatus comes from bme68x_init(), which is the only part that talks to the chip: it reads the chip id and returns BME68X_E_DEV_NOT_FOUND when the id is not 0x61. - init_bme680_bsec() looked at bsecStatus only, so it reported success for any Bosch sensor that shares the address: BME280 and BMP280 default to 0x76 just like BME680, and the SDO pin only moves them to 0x77. - Until 1.17.1 that false positive was cosmetic. The detection loop kept walking the table after a hit, so BME280 was still initialized and only an empty LPP channel was left over. 1.18.0 marks the address as consumed after a successful init ("detected[def.address] = false"), and it probes the alternate Bosch address as well, so the false positive now takes both 0x76 and 0x77 away from the real driver. A RAK4631 with a BME280 reports no environment telemetry at all: query_bme680_bsec() returns early forever, because bsec_data_ready is never set without a BME68x. Issue #3634. - ENV_INCLUDE_BME680_BSEC is set by variants/rak4631/platformio.ini only, so RAK4631 builds are the ones affected. - There is no configuration workaround for a user: moving the sensor to 0x77 does not help now that both addresses are probed. Compatibility - A real BME680 is unaffected: bme68x_init() returns BME68X_OK for chip id 0x61, and nothing else in the function changed. - On a board with no BME68x, BSEC stays inactive, which is what the code already intended. bsec_active keeps its value of false, so the loop() hook does not run BSEC and no bsec_state.bin is written. - The same defect was reported upstream in #2746, which fixes it the same way and additionally checks the INA226 die id. That PR is still open; this change is the minimal part that the issue needs. Evidence - Bosch sources, as installed by the pinned dependency "BSEC Software Library @ ^1.8.1492" (resolved to 1.8.1492): src/bsec.cpp Bsec::beginCommon() sets bsecStatus from bsec_init() and bme68xStatus from bme68x_init(); src/bme68x/bme68x.c bme68x_init() returns BME68X_E_DEV_NOT_FOUND unless the chip id equals BME68X_CHIP_ID. - The regression window was established by diffing repeater-v1.17.1 against repeater-v1.18.0: the variant flags are identical in both, and the sensor manager gained the consumed-address line and the alternate Bosch addresses. - pio run -e RAK_4631_repeater: SUCCESS, flash 440952 bytes. - Mutation check that the new line is really compiled in that env: renaming bme68xStatus to a non-existent member fails the build at EnvironmentSensorManager.cpp:506, and restoring it builds green with the identical flash figure. - Not verified on hardware: no RAK4631 with a BME280 was run for this change. EnvironmentSensorManager.cpp is in no native environment's build_src_filter, so there is no host test for it either. --- src/helpers/sensors/EnvironmentSensorManager.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 0dc892c3a52..f6c50d84185 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -499,6 +499,11 @@ static void bsec_save_state() { static uint8_t init_bme680_bsec(TwoWire* wire, uint8_t addr) { bsec_iaq.begin(addr, *wire); + // bsec_init() is a pure software init and reports BSEC_OK even when nothing + // answers on the bus. Only bme68xStatus tells us a BME68x replied, so without + // this check BSEC claims any Bosch sensor sharing 0x76/0x77 (BME280, BMP280) + // and that sensor never reaches its own driver. Issue #3634. + if (bsec_iaq.bme68xStatus != BME68X_OK) return 0; if (bsec_iaq.bsecStatus != BSEC_OK) return 0; bsec_iaq.setConfig(bsec_config_iaq); From 24664a63066a7ae95de4af130dd05b690ca75566 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:02:09 +0200 Subject: [PATCH 023/136] fix(chat): refuse a negative text length for a group message CMD_SEND_CHANNEL_TXT_MSG reads txt_type, channel_idx and a 4 byte timestamp, so the frame needs 7 bytes before the text starts, and the branch had no length guard. For a shorter frame "len - i" is computed in size_t, wraps, and reaches sendGroupMessage() as a negative int. The clamp there tests "text_len + prefix_len > MAX_TEXT_LEN", which a negative value passes, and the following memcpy() converts the length back to a size_t: a 3 byte frame asks for 4294967292 bytes into a 197 byte stack buffer. Two guards, because there are two mistakes. The branch now states its minimum like the 35 other branches of handleCmdFrame do, so a frame below 7 bytes is answered through the existing path instead of being parsed. And sendGroupMessage() refuses a negative length the way sendGroupData() in the same file already does, which protects every other caller and is the reason the fix can be tested at all. A frame of exactly 7 bytes keeps working and sends an empty message, as before. Evidence. On a T-Echo (techo-3dcf) running dev 428a95c6, built with FIRMWARE_BUILD_DATE "f1-dev" and confirmed by "ver => v1.18.0 (Build: f1-dev)": CH_TXT len=7 -> RESP_CODE_OK GET_STATS core -> uptime=78s CH_TXT len=3 -> NO REPLY DEVICE_QUERY -> NO REPLY (three times over 8 s, node never came back) On the host, the same sources in the native_chat environment: before: Program received signal SIGBUS (Bus error: 10) after: 8 test cases: 8 succeeded native test_mesh_tables PASSED native test_config_serializer PASSED native test_utils PASSED native test_utf8_helpers PASSED native test_routing_policy PASSED native_chat test_chat_group_msg PASSED native_kiss_modem test_kiss_modem PASSED 62 test cases: 62 succeeded in 00:00:29.696 test_companion_node_prefs is reported SKIPPED, which it also is on unmodified dev (46 of 46 there), so that is not a change from this commit. --- examples/companion_radio/MyMesh.cpp | 4 +++- src/helpers/BaseChatMesh.cpp | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 99a9aa30d6e..d74050be7e0 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1175,7 +1175,9 @@ void MyMesh::handleCmdFrame(size_t len) { ? ERR_CODE_NOT_FOUND : ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsupported TXT_TYPE_* } - } else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG) { // send GroupChannel text msg + } else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG && len >= 7) { // send GroupChannel text msg + // 7 = code, txt_type, channel_idx and the 4 byte timestamp; the text may be empty. + // Without this the text length below is computed as len - 7 and turns negative. int i = 1; uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN uint8_t channel_idx = cmd_frame[i++]; diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 28592415ca9..2637bc327ab 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -516,6 +516,11 @@ int BaseChatMesh::sendCommandData(const ContactInfo& recipient, uint32_t timest } bool BaseChatMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len) { + if (text_len < 0) { // memcpy() below would read this as a size_t, so clamping cannot catch it + MESH_DEBUG_PRINTLN("sendGroupMessage: invalid negative text_len=%d", text_len); + return false; + } + uint8_t temp[5+MAX_TEXT_LEN+32]; memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique temp[4] = 0; // TXT_TYPE_PLAIN From 031428fbf934b3c26b623b911da022c92de045fb Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:03:09 +0200 Subject: [PATCH 024/136] refactor(server): share the region and reply scope plumbing What changes - New class ServerMesh (src/helpers/ServerMesh.h, .cpp) between mesh::Mesh and the three server roles. It holds the fields all three declared separately (the transport key store, the Region map and its temp instance, the Region load stack, the Region a packet arrived on, the node's default scope, the load flag and the file system pointer) and six methods: onRecvPacket(), sendFloodReply(), sendFloodScoped(), startRegionsLoad(), saveRegions() and onDefaultRegionChanged(). - Repeater, room server and sensor derive from it and drop their copies. Each role gains exactly three lines: the include, the base in the class declaration and the constructor forwarding to it. 266 lines deleted, 10 inserted. - The shared constructor initialisation moved too: recv_pkt_region to NULL, region_load_active to false and the default scope zeroed, which all three did identically at the end of their own constructor. Why - The six methods were byte for byte identical in all three roles, and the Region and scope fields were declared three times with the same types. A change to the reply scope rules had to be made in three places, which is how the roles drifted apart in the first place (see the ledger). This is the first of the steps in docs/refactoring/0004-shared-server-base.md. Compatibility - Behaviour is unchanged. The bodies moved unaltered: each one was extracted from all three role implementations and compared after normalising only the class name and the pointer star spacing, and all six were identical, 60 lines per role. Nothing in the move depends on the open sensor questions. - The members are protected rather than private, because the role code reaches them directly as it did when they were its own fields. Verification - pio test -e native -e native_core -e native_kiss_modem -e native_ofast -e native_server: 88 of 88 test cases pass, unchanged from before the move. - All three role implementations compile for the host harness with no errors, and the preprocessor structure of each header is intact (the #endif count per file is unchanged). - NOT YET VERIFIED: the firmware build matrix, and the moved methods have no direct host test of their own yet; for this step the diff proof and the compilation carry it. A characterization test for them follows. --- examples/simple_repeater/MyMesh.cpp | 73 +------------------------- examples/simple_repeater/MyMesh.h | 20 +------ examples/simple_room_server/MyMesh.cpp | 73 +------------------------- examples/simple_room_server/MyMesh.h | 20 +------ examples/simple_sensor/SensorMesh.cpp | 72 +------------------------ examples/simple_sensor/SensorMesh.h | 17 +----- platformio.ini | 1 + src/helpers/ServerMesh.cpp | 72 +++++++++++++++++++++++++ src/helpers/ServerMesh.h | 68 ++++++++++++++++++++++++ 9 files changed, 150 insertions(+), 266 deletions(-) create mode 100644 src/helpers/ServerMesh.cpp create mode 100644 src/helpers/ServerMesh.h diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 71d517d8bcc..aa26ca41c1f 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -410,27 +410,6 @@ bool MyMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[]) return n >= max_counters[hash_size]; } -void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - TransportKey req_scope; - bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); - bool req_scope_known = recv_pkt_region != NULL && !is_wildcard - && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; - - switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { - case mesh::REPLY_SCOPE_REQUEST: - sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request - break; - case mesh::REPLY_SCOPE_DEFAULT: - // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. - // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 - sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); - break; - case mesh::REPLY_SCOPE_NONE: - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - break; - } -} - bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; if (packet->isRouteFlood() @@ -539,22 +518,6 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { } } - -mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { - if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); - } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { - if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { - recv_pkt_region = NULL; - } else { - recv_pkt_region = ®ion_map.getWildcard(); - } - } else { - recv_pkt_region = NULL; - } - return Mesh::onRecvPacket(pkt); -} - void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, uint8_t *data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin @@ -848,8 +811,7 @@ void MyMesh::sendNodeDiscoverReq() { MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng, mesh::RTCClock &rtc, mesh::MeshTables &tables) - : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), - region_map(key_store), temp_map(key_store), + : ServerMesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4), discover_limiter(4, 120), // max 4 every 2 minutes @@ -868,8 +830,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc dirty_contacts_expiry = 0; set_radio_at = revert_radio_at = 0; _logging = false; - region_load_active = false; - recv_pkt_region = NULL; #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); @@ -927,7 +887,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc pending_discover_tag = 0; pending_discover_until = 0; - memset(default_scope.key, 0, sizeof(default_scope.key)); } void MyMesh::begin(FILESYSTEM *fs) { @@ -984,17 +943,6 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif } -void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { - if (scope.isNull()) { - sendFlood(pkt, delay_millis, path_hash_size); - } else { - uint16_t codes[2]; - codes[0] = scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, path_hash_size); - } -} - void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params pending_freq = freq; @@ -1132,25 +1080,6 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) { #endif } -void MyMesh::startRegionsLoad() { - temp_map.resetFrom(region_map); // rebuild regions in a temp instance - memset(load_stack, 0, sizeof(load_stack)); - load_stack[0] = &temp_map.getWildcard(); - region_load_active = true; -} - -bool MyMesh::saveRegions() { - return region_map.save(_fs); -} - -void MyMesh::onDefaultRegionChanged(const RegionEntry* r) { - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { - memset(default_scope.key, 0, sizeof(default_scope.key)); - } -} - void MyMesh::formatStatsReply(char *reply) { StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index cd8b5bf4249..06b11492899 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -34,6 +34,7 @@ #include #include #include +#include #include #include "RateLimiter.h" @@ -78,8 +79,7 @@ struct NeighbourInfo { #define PACKET_LOG_FILE "/packet_log" -class MyMesh : public mesh::Mesh, public CommonCLICallbacks { - FILESYSTEM* _fs; +class MyMesh : public ServerMesh { uint32_t last_millis; uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; @@ -90,15 +90,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t reply_data[MAX_PACKET_PAYLOAD]; uint8_t reply_path[MAX_PATH_SIZE]; uint8_t reply_path_len; - TransportKeyStore key_store; - RegionMap region_map, temp_map; - RegionEntry* load_stack[8]; - RegionEntry* recv_pkt_region; - TransportKey default_scope; RateLimiter discover_limiter, anon_limiter; uint32_t pending_discover_tag; unsigned long pending_discover_until; - bool region_load_active; unsigned long dirty_contacts_expiry; #if MAX_NEIGHBOURS NeighbourInfo neighbours[MAX_NEIGHBOURS]; @@ -136,15 +130,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void logTx(mesh::Packet* pkt, int len) override; void logTxFail(mesh::Packet* pkt, int len) override; - #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); } #endif - mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; - void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; @@ -153,8 +144,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; - void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); - public: MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); @@ -175,8 +164,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { _cli.savePrefs(_fs); } - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; @@ -197,9 +184,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; - void startRegionsLoad() override; - bool saveRegions() override; - void onDefaultRegionChanged(const RegionEntry* r) override; mesh::LocalIdentity& getSelfId() override { return self_id; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 10cca41c8f3..e36a51ae236 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -283,7 +283,6 @@ const char *MyMesh::getLogDateTime() { return tmp; } - bool MyMesh::allowPacketForward(const mesh::Packet *packet) { if (_prefs.disable_fwd) return false; if (packet->isRouteFlood() @@ -293,21 +292,6 @@ bool MyMesh::allowPacketForward(const mesh::Packet *packet) { return true; } -mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { - if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); - } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { - if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { - recv_pkt_region = NULL; - } else { - recv_pkt_region = ®ion_map.getWildcard(); - } - } else { - recv_pkt_region = NULL; - } - return Mesh::onRecvPacket(pkt); -} - void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, uint8_t *data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin @@ -616,8 +600,7 @@ void MyMesh::onAckRecv(mesh::Packet *packet, uint32_t ack_crc) { MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondClock &ms, mesh::RNG &rng, mesh::RTCClock &rtc, mesh::MeshTables &tables) - : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), - region_map(key_store), temp_map(key_store), + : ServerMesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4) { @@ -627,9 +610,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; _logging = false; - region_load_active = false; set_radio_at = revert_radio_at = 0; - recv_pkt_region = NULL; // defaults _prefs.airtime_factor = 1.0; @@ -678,7 +659,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc memset(posts, 0, sizeof(posts)); _num_posted = _num_post_pushes = 0; - memset(default_scope.key, 0, sizeof(default_scope.key)); } void MyMesh::begin(FILESYSTEM *fs) { @@ -726,38 +706,6 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif } -void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { - if (scope.isNull()) { - sendFlood(pkt, delay_millis, path_hash_size); - } else { - uint16_t codes[2]; - codes[0] = scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, path_hash_size); - } -} - -void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - TransportKey req_scope; - bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); - bool req_scope_known = recv_pkt_region != NULL && !is_wildcard - && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; - - switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { - case mesh::REPLY_SCOPE_REQUEST: - sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request - break; - case mesh::REPLY_SCOPE_DEFAULT: - // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. - // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 - sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); - break; - case mesh::REPLY_SCOPE_NONE: - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - break; - } -} - void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params pending_freq = freq; @@ -846,25 +794,6 @@ void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { store.save("_main", new_id); } -void MyMesh::startRegionsLoad() { - temp_map.resetFrom(region_map); // rebuild regions in a temp instance - memset(load_stack, 0, sizeof(load_stack)); - load_stack[0] = &temp_map.getWildcard(); - region_load_active = true; -} - -bool MyMesh::saveRegions() { - return region_map.save(_fs); -} - -void MyMesh::onDefaultRegionChanged(const RegionEntry* r) { - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { - memset(default_scope.key, 0, sizeof(default_scope.key)); - } -} - void MyMesh::clearStats() { radio_driver.resetStats(); resetStats(); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 00fee6333b3..42166d34d58 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -89,16 +90,12 @@ struct PostInfo { char text[MAX_POST_TEXT_LEN+1]; }; -class MyMesh : public mesh::Mesh, public CommonCLICallbacks { - FILESYSTEM* _fs; +class MyMesh : public ServerMesh { uint32_t last_millis; uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; bool _logging; - bool region_load_active; NodePrefs _prefs; - TransportKeyStore key_store; - RegionMap region_map, temp_map; ClientACL acl; CommonCLI _cli; unsigned long dirty_contacts_expiry; @@ -109,9 +106,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { int next_post_idx; PostInfo posts[MAX_UNSYNCED_POSTS]; // cyclic queue CayenneLPP telemetry; - RegionEntry* load_stack[8]; - RegionEntry* recv_pkt_region; - TransportKey default_scope; unsigned long set_radio_at, revert_radio_at; float pending_freq; float pending_bw; @@ -136,9 +130,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { const char* getLogDateTime() override; - - mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; - bool allowPacketForward(const mesh::Packet* packet) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override ; @@ -153,8 +144,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { } #endif - void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); - public: MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); @@ -173,8 +162,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { _cli.savePrefs(_fs); } - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; @@ -198,9 +185,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void formatStatsReply(char *reply) override; void formatRadioStatsReply(char *reply) override; void formatPacketStatsReply(char *reply) override; - void startRegionsLoad() override; - bool saveRegions() override; - void onDefaultRegionChanged(const RegionEntry* r) override; mesh::LocalIdentity& getSelfId() override { return self_id; } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 7215031e2dd..37b1466a4a5 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -330,25 +330,6 @@ uint8_t SensorMesh::getExtraAckTransmitCount() const { return 0; // the sensor has never sent extra ACKs, whatever multi.acks says } -void SensorMesh::startRegionsLoad() { - temp_map.resetFrom(region_map); // rebuild regions in a temp instance - memset(load_stack, 0, sizeof(load_stack)); - load_stack[0] = &temp_map.getWildcard(); - region_load_active = true; -} - -bool SensorMesh::saveRegions() { - return region_map.save(_fs); -} - -void SensorMesh::onDefaultRegionChanged(const RegionEntry* r) { - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { - memset(default_scope.key, 0, sizeof(default_scope.key)); - } -} - uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) { ClientInfo* client; if (data[0] == 0) { // blank password, just check if sender is in ACL @@ -505,21 +486,6 @@ void SensorMesh::handleCommand(ClientInfo* from, uint32_t sender_timestamp, char } } -mesh::DispatcherAction SensorMesh::onRecvPacket(mesh::Packet* pkt) { - if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); - } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { - if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { - recv_pkt_region = NULL; - } else { - recv_pkt_region = ®ion_map.getWildcard(); - } - } else { - recv_pkt_region = NULL; - } - return Mesh::onRecvPacket(pkt); -} - void SensorMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin client (unknown at this stage) uint32_t timestamp; @@ -767,8 +733,7 @@ void SensorMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { } SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables) - : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), - region_map(key_store), temp_map(key_store), + : ServerMesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(32), tables), _cli(board, rtc, sensors, region_map, acl, &_prefs, this), telemetry(MAX_PACKET_PAYLOAD - 4) { @@ -778,8 +743,6 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise last_read_time = 0; num_alert_tasks = 0; set_radio_at = revert_radio_at = 0; - recv_pkt_region = NULL; - region_load_active = false; // defaults _prefs.airtime_factor = 1.0; @@ -809,7 +772,6 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.radio_fem_rxgain = 1; _prefs.radio_fem_txgain = 0; - memset(default_scope.key, 0, sizeof(default_scope.key)); } void SensorMesh::begin(FILESYSTEM* fs) { @@ -892,38 +854,6 @@ void SensorMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t revert_radio_at = futureMillis(2000 + timeout_mins*60*1000); // schedule when to revert radio params } -void SensorMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { - if (scope.isNull()) { - sendFlood(pkt, delay_millis, path_hash_size); - } else { - uint16_t codes[2]; - codes[0] = scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, path_hash_size); - } -} - -void SensorMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - TransportKey req_scope; - bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); - bool req_scope_known = recv_pkt_region != NULL && !is_wildcard - && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; - - switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { - case mesh::REPLY_SCOPE_REQUEST: - sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request - break; - case mesh::REPLY_SCOPE_DEFAULT: - // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. - // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 - sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); - break; - case mesh::REPLY_SCOPE_NONE: - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - break; - } -} - void SensorMesh::sendSelfAdvertisement(int delay_millis, bool flood) { mesh::Packet* pkt = createSelfAdvert(); if (pkt) { diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 82b940f9761..0f9ac3f1586 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -46,7 +47,7 @@ #define MAX_SEARCH_RESULTS 24 #define MAX_CONCURRENT_ALERTS 4 -class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { +class SensorMesh : public ServerMesh { public: SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); void begin(FILESYSTEM* fs); @@ -78,15 +79,9 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override { } void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; - void startRegionsLoad() override; - bool saveRegions() override; - void onDefaultRegionChanged(const RegionEntry* r) override; float getTelemValue(uint8_t channel, uint8_t type); - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); - protected: // current telemetry data queries float getVoltage(uint8_t channel) { return getTelemValue(channel, LPP_VOLTAGE); } @@ -126,7 +121,6 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { uint32_t getRetransmitDelay(const mesh::Packet* packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; uint8_t getExtraAckTransmitCount() const override; - mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; @@ -138,7 +132,6 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { virtual bool handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t* data, uint8_t flags, size_t len); void sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size=1); private: - FILESYSTEM* _fs; unsigned long next_local_advert, next_flood_advert; NodePrefs _prefs; ClientACL acl; @@ -146,11 +139,6 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long dirty_contacts_expiry; CayenneLPP telemetry; - TransportKeyStore key_store; - RegionMap region_map, temp_map; - RegionEntry* recv_pkt_region; - RegionEntry* load_stack[8]; - TransportKey default_scope; uint32_t last_read_time; int matching_peer_indexes[MAX_SEARCH_RESULTS]; int num_alert_tasks; @@ -160,7 +148,6 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { float pending_bw; uint8_t pending_sf; uint8_t pending_cr; - bool region_load_active; bool telemHasChanged(ClientInfo* c); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); diff --git a/platformio.ini b/platformio.ini index 335167fb855..4b56c2649e9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -226,6 +226,7 @@ build_src_filter = +<../src/helpers/DynamicConfigSerializer.cpp> +<../src/helpers/IdentityStore.cpp> +<../src/helpers/RegionMap.cpp> + +<../src/helpers/ServerMesh.cpp> +<../src/helpers/StaticPoolPacketManager.cpp> +<../src/helpers/TransportKeyStore.cpp> +<../src/helpers/TxtDataHelpers.cpp> diff --git a/src/helpers/ServerMesh.cpp b/src/helpers/ServerMesh.cpp new file mode 100644 index 00000000000..e31a3640180 --- /dev/null +++ b/src/helpers/ServerMesh.cpp @@ -0,0 +1,72 @@ +#include "ServerMesh.h" + +// The bodies below moved here unchanged from the three roles, where they were +// identical. Verified by extracting each one from all three role implementations and +// comparing after normalising only the class name and the pointer star spacing. + +mesh::DispatcherAction ServerMesh::onRecvPacket(mesh::Packet* pkt) { + if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); + } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { + recv_pkt_region = NULL; + } else { + recv_pkt_region = ®ion_map.getWildcard(); + } + } else { + recv_pkt_region = NULL; + } + return Mesh::onRecvPacket(pkt); +} + +void ServerMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { + TransportKey req_scope; + bool is_wildcard = recv_pkt_region != NULL && recv_pkt_region->isWildcard(); + bool req_scope_known = recv_pkt_region != NULL && !is_wildcard + && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; + + switch (mesh::chooseReplyScope(req_scope_known, is_wildcard, !default_scope.isNull())) { + case mesh::REPLY_SCOPE_REQUEST: + sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // reply with same scope as request + break; + case mesh::REPLY_SCOPE_DEFAULT: + // requester's scope is unknown: DIRECT request (no transport codes), or code matched no Region. + // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 + sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_NONE: + sendFlood(packet, delay_millis, path_hash_size); // send un-scoped + break; + } +} + +void ServerMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { + if (scope.isNull()) { + sendFlood(pkt, delay_millis, path_hash_size); + } else { + uint16_t codes[2]; + codes[0] = scope.calcTransportCode(pkt); + codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? + sendFlood(pkt, codes, delay_millis, path_hash_size); + } +} + +void ServerMesh::startRegionsLoad() { + temp_map.resetFrom(region_map); // rebuild regions in a temp instance + memset(load_stack, 0, sizeof(load_stack)); + load_stack[0] = &temp_map.getWildcard(); + region_load_active = true; +} + +bool ServerMesh::saveRegions() { + return region_map.save(_fs); +} + +void ServerMesh::onDefaultRegionChanged(const RegionEntry* r) { + if (r) { + region_map.getTransportKeysFor(*r, &default_scope, 1); + } else { + memset(default_scope.key, 0, sizeof(default_scope.key)); + } +} + diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h new file mode 100644 index 00000000000..ef63ea78bc5 --- /dev/null +++ b/src/helpers/ServerMesh.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +#include +#include +#include +#include + +/** + * \brief The underpinnings every server role shares: the repeater, the room server + * and the sensor. + * + * The three roles grew as separate sketches and ended up carrying the same code + * three times. What lives here is the part that was byte for byte identical in all + * three: resolving the Region a packet arrived on, choosing the scope of a flooded + * reply, and loading, saving and applying the Region table. A role keeps whatever is + * genuinely its own. + * + * The members are protected rather than private because the role code reaches them + * directly, as it did when they were its own fields. + * + * See docs/refactoring/0004-shared-server-base.md for the measurement and the order + * of the steps. + */ +class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { +protected: + FILESYSTEM* _fs; + + // Declared before the Region maps, which are constructed with it. + TransportKeyStore key_store; + RegionMap region_map, temp_map; + + RegionEntry* load_stack[8]; + RegionEntry* recv_pkt_region; // the Region the packet being handled arrived on + TransportKey default_scope; // this node's own scope, for replies it cannot scope otherwise + bool region_load_active; + + ServerMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, + mesh::PacketManager& mgr, mesh::MeshTables& tables) + : mesh::Mesh(radio, ms, rng, rtc, mgr, tables), + region_map(key_store), temp_map(key_store) + { + _fs = NULL; + recv_pkt_region = NULL; + region_load_active = false; + memset(default_scope.key, 0, sizeof(default_scope.key)); + } + + /** + * \brief Send a flooded reply to the request being handled, scoped the way the + * request was, or with this node's own scope when that is unknowable. + */ + void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); + + /** + * \brief Flood a packet inside a transport scope, or un-scoped if the scope is null. + */ + void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); + + // Mesh + mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; + + // CommonCLICallbacks + void startRegionsLoad() override; + bool saveRegions() override; + void onDefaultRegionChanged(const RegionEntry* r) override; +}; From d63f67c63f4051ec6b612da2753609e724be7cbd Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:09:35 +0200 Subject: [PATCH 025/136] test(server): pin the Region a packet is taken to have arrived on What this adds - Ten characterization tests for ServerMesh::onRecvPacket(), the resolution that step 4a moved into the shared base. It decides which Region the packet being handled arrived on, and that field drives the scope of every flooded reply, so nothing else in the firmware reports it. The tests reach it through a subclass of the real room server role that opens up the base's state. - Covered: a direct packet leaves no Region; an un-scoped flood arrives on the wildcard, or on none when the wildcard denies flood; a scoped flood with transport code 0 arrives on the wildcard; one with an unknown code arrives on none; one carrying a Region's own code arrives on that Region, and on none when that Region denies flood; and the Region does not survive into the next packet. Measured while writing this - RegionMap::putRegion() sets REGION_DENY_FLOOD on a new Region, so a freshly added Region is invisible to the flood lookup until the flag is cleared. Pinned in its own test, since the others depend on it. - The two flood route types disagree about a denied Region: the un-scoped branch applies the flag itself, the scoped branch delegates to findMatch(), whose transport-code-0 shortcut returns the wildcard before the mask is applied. So denying flood on the wildcard stops an un-scoped flood but not a scoped one carrying no code. Recorded as observed behaviour and added to the ledger as issue 10; not changed here, because step 4a is a move. Verification - pio test -e native -e native_core -e native_kiss_modem -e native_ofast -e native_server: 98 of 98 test cases pass (88 before, 10 new). - Six deliberately introduced faults in the moved method each turned the suite red: the Region not being cleared for a non-flood packet, the wildcard's flood denial ignored and inverted, a scoped flood resolving to the wildcard instead of going through the lookup, the two flood route types swapped, and the lookup mask dropped. Two of them were not caught by the first version of these tests, which is what added the cases carrying a real transport code. The file was restored afterwards and the suite is green again. - NOT YET VERIFIED: the firmware build matrix for step 4a. --- docs/refactoring/architecture-review-v1.18.md | 1 + test/test_server_base/test_server_base.cpp | 174 ++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md index c3bffe6e9e6..561e59539da 100644 --- a/docs/refactoring/architecture-review-v1.18.md +++ b/docs/refactoring/architecture-review-v1.18.md @@ -139,3 +139,4 @@ issue or fix. | 7 | The server roles load `/prefs.json` without range checks (only the legacy loader clamps) | `src/helpers/CommonCLI.cpp:38` | | 8 | Role fallback radio defaults (915 / 250 / SF10) differ from the root `platformio.ini` (869.618 / 62.5 / SF8) | `examples/simple_repeater/MyMesh.cpp:7-13`, `platformio.ini` | | 9 | nRF52 firmware is built with `-Ofast` (`-ffast-math`), so float comparisons may assume no NaN; whether a range check rejects `nan` then depends on how it is written. `set af nan` is accepted on the boards. (Found during step 3, measured on a T-Echo) | `src/helpers/CommonRadioPrefs.cpp` | +| 10 | The two flood route types disagree about a denied Region. `onRecvPacket()` checks `REGION_DENY_FLOOD` itself for an un-scoped flood, while a scoped flood goes through `RegionMap::findMatch()`, whose transport-code-0 shortcut returns the wildcard before the mask is applied. So denying flood on the wildcard stops an un-scoped flood but not a scoped one carrying no code. (Found during step 4a, pinned in test_server_base) | `src/helpers/ServerMesh.cpp`, `src/helpers/RegionMap.cpp:findMatch()` | diff --git a/test/test_server_base/test_server_base.cpp b/test/test_server_base/test_server_base.cpp index 17d652d0ea0..0a35601ddbd 100644 --- a/test/test_server_base/test_server_base.cpp +++ b/test/test_server_base/test_server_base.cpp @@ -6,6 +6,8 @@ // This file pins the temporary radio parameters, which step 4c moves into the // shared base: what `set radio ...` schedules, when the pending values reach the // radio, that they reach it once, and when the node reverts to its own settings. +// It also pins the Region a packet is taken to have arrived on, which step 4a +// already moved into ServerMesh::onRecvPacket(). // The room server stands in for the three roles here; the repeater and the sensor // need an environment of their own, because both of them also define `MyMesh`. @@ -146,6 +148,178 @@ TEST_F(ServerBaseTest, RevertHappensOnlyOnce) { EXPECT_EQ(radio_driver.set_params_calls, after_revert); } + +// A role with the shared base's state opened up, so a test can see which Region a +// packet was taken to have arrived on. That field drives the scope of every flooded +// reply, and nothing else in the firmware reports it. +class RegionProbe : public MyMesh { +public: + using MyMesh::MyMesh; + + RegionEntry* seenRegion() const { return recv_pkt_region; } + RegionMap& regions() { return region_map; } + mesh::DispatcherAction recv(mesh::Packet* pkt) { return onRecvPacket(pkt); } +}; + +class RegionResolutionTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + mesh.reset(new RegionProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + } + + // A packet carrying nothing but a route type, which is all the resolution reads. + static mesh::Packet packetOfRouteType(uint8_t route_type) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = route_type; + return pkt; + } +}; + +TEST_F(RegionResolutionTest, ADirectPacketHasNoRegion) { + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_DIRECT); + mesh->recv(&pkt); + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + +TEST_F(RegionResolutionTest, AnUnscopedFloodPacketArrivesOnTheWildcardRegion) { + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_FLOOD); + mesh->recv(&pkt); + + ASSERT_NE(mesh->seenRegion(), nullptr); + EXPECT_TRUE(mesh->seenRegion()->isWildcard()); + EXPECT_EQ(mesh->seenRegion(), &mesh->regions().getWildcard()); +} + +TEST_F(RegionResolutionTest, AnUnscopedFloodPacketHasNoRegionWhenTheWildcardDeniesFlood) { + mesh->regions().getWildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_FLOOD); + mesh->recv(&pkt); + + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + +TEST_F(RegionResolutionTest, AScopedFloodPacketWithTransportCodeZeroArrivesOnTheWildcard) { + // RegionMap::findMatch() returns the wildcard for transport code 0 before it looks + // at any Region, so a scoped flood with no code is handled like an un-scoped one. + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + mesh->recv(&pkt); + + ASSERT_NE(mesh->seenRegion(), nullptr); + EXPECT_TRUE(mesh->seenRegion()->isWildcard()); +} + +TEST_F(RegionResolutionTest, TransportCodeZeroIgnoresTheWildcardsFloodDenial) { + // Pinned because the two flood route types disagree here. The un-scoped branch in + // onRecvPacket() checks REGION_DENY_FLOOD itself, while the scoped branch delegates + // to findMatch(), whose transport-code-0 shortcut returns the wildcard before the + // mask is applied. So denying flood on the wildcard stops an un-scoped flood but + // not a scoped one carrying no code. Recorded as observed behaviour, not endorsed. + mesh->regions().getWildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet unscoped = packetOfRouteType(ROUTE_TYPE_FLOOD); + mesh->recv(&unscoped); + EXPECT_EQ(mesh->seenRegion(), nullptr) << "un-scoped flood respects the denial"; + + mesh::Packet scoped = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + mesh->recv(&scoped); + EXPECT_NE(mesh->seenRegion(), nullptr) << "scoped flood with code 0 does not"; +} + +TEST_F(RegionResolutionTest, AScopedFloodPacketWithAnUnknownCodeHasNoRegion) { + // A non-zero code goes through the Region lookup, and nothing but the wildcard is + // loaded, so no Region claims it. This is the case that tells the lookup apart + // from simply handing back the wildcard. + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + pkt.transport_codes[0] = 0x1234; + mesh->recv(&pkt); + + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + +TEST_F(RegionResolutionTest, ANewRegionDeniesFloodUntilItIsAllowed) { + // RegionMap::putRegion() sets REGION_DENY_FLOOD on a new Region ("DENY by + // default"), so a freshly added Region is invisible to the flood lookup until the + // flag is cleared. Pinned because the tests below depend on it, and because the + // default itself is what the lookup's mask acts on. + RegionEntry* region = mesh->regions().putRegion("#probe", 0); + ASSERT_NE(region, nullptr); + EXPECT_TRUE(region->flags & REGION_DENY_FLOOD); +} + +TEST_F(RegionResolutionTest, AScopedFloodPacketArrivesOnTheRegionWhoseCodeItCarries) { + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + RegionEntry* region = mesh->regions().putRegion("#probe", 0); + ASSERT_NE(region, nullptr); + region->flags &= ~REGION_DENY_FLOOD; // allow flood, so the lookup considers it + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + TransportKey keys[4]; + ASSERT_GT(mesh->regions().getTransportKeysFor(*region, keys, 4), 0); + pkt.transport_codes[0] = keys[0].calcTransportCode(&pkt); + + mesh->recv(&pkt); + + ASSERT_NE(mesh->seenRegion(), nullptr); + EXPECT_EQ(mesh->seenRegion()->id, region->id); +} + +TEST_F(RegionResolutionTest, ARegionThatDeniesFloodIsSkippedEvenForItsOwnCode) { + // The lookup is passed REGION_DENY_FLOOD as its mask, so a Region carrying that + // flag is passed over and the packet ends up with no Region at all. This is the + // case that tells the mask apart from passing 0. + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + RegionEntry* region = mesh->regions().putRegion("#probe", 0); + ASSERT_NE(region, nullptr); + region->flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = packetOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + TransportKey keys[4]; + ASSERT_GT(mesh->regions().getTransportKeysFor(*region, keys, 4), 0); + pkt.transport_codes[0] = keys[0].calcTransportCode(&pkt); + + // Same packet, same code, only the Region's flag differs. + region->flags |= REGION_DENY_FLOOD; + mesh->recv(&pkt); + + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + +TEST_F(RegionResolutionTest, TheRegionIsClearedBetweenPackets) { + mesh->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet flood = packetOfRouteType(ROUTE_TYPE_FLOOD); + mesh->recv(&flood); + ASSERT_NE(mesh->seenRegion(), nullptr); + + // A direct packet must not leave the previous packet's Region standing, or a + // reply would be scoped by whatever arrived before it. + mesh::Packet direct = packetOfRouteType(ROUTE_TYPE_DIRECT); + mesh->recv(&direct); + EXPECT_EQ(mesh->seenRegion(), nullptr); +} + } // namespace int main(int argc, char** argv) { From bd1fcf77851242420741bfe1261c8e2cbd50a1c4 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:11:56 +0200 Subject: [PATCH 026/136] test(variants): pin the millis() deadline question across the counter wrap Adds src/helpers/MillisDeadline.h with mesh::millisHasPassed(now, deadline) and test_millis_deadline covering it: the strict comparison, agreement with the (int32_t)(now - deadline) > 0 idiom of Dispatcher::millisHasNowPassed over 70 pairs including differences of 0, 1, 2^31 - 1, 2^31 and 2^32 - 1, a deadline that has already wrapped while millis() has not, the period over three windows (quiet, across 2^31, across the 2^32 wrap), and the documented horizon of half the counter. The helper takes uint32_t and not long on purpose. long is 32 bits on every firmware target and 64 bits on the test host, and the two widths do not behave the same at the wrap: 32 bits over-fires briefly, 64 bits stalls for a whole counter period. A test written with long would measure the host instead of the target. The helper compares the wrapped difference against half the counter rather than casting a difference above LONG_MAX to long, which the standard leaves to the implementation before C++20. Both spellings agree on every input and compile to identical instructions on nRF52 with -Ofast and on ESP32-S3 with -Os. Written against the shape the variants ship: with "return now > deadline" as the body, four of the five tests fail. Six mutations of the helper were each caught (4, 3, 2, 5, 4 and 2 failing tests), including computing in unsigned long instead of uint32_t, which is invisible on a 32-bit target and wrong on a 64-bit host. Compiled on its own with -Wall -Wextra -Wconversion -Wsign-conversion -Wshadow -Wold-style-cast -Wpedantic, the header is warning free on arm-none-eabi gcc 7.2.1, 12.3.1, 12.3.1 with -Ofast and 14.2.1 and on xtensa-esp32 and xtensa-esp32s3 gcc 8.4.0; the firmware builds with -w, where a warning would not surface. Why: the next change points seven variants at this function. Seven hand-written copies of the idiom are what produced the defect, and variants/*/target.cpp does not build on the host, so only a shared function can be covered by a test. --- src/helpers/MillisDeadline.h | 40 ++++++ .../test_millis_deadline.cpp | 121 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/helpers/MillisDeadline.h create mode 100644 test/test_millis_deadline/test_millis_deadline.cpp diff --git a/src/helpers/MillisDeadline.h b/src/helpers/MillisDeadline.h new file mode 100644 index 00000000000..0100eaec027 --- /dev/null +++ b/src/helpers/MillisDeadline.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +namespace mesh { + +/** + * \brief Has a scheduled millis() deadline passed? + * \param now the current millis() reading + * \param deadline a deadline previously formed as 'millis() + interval' + * \returns true once now is strictly past deadline + * + * Periodic work is scheduled by storing 'millis() + interval' and asking once per loop + * pass whether that moment has arrived. Asking with a plain 'now > deadline' breaks in + * the last interval before the 32-bit counter reaches 2^32: the stored deadline has + * already wrapped to a small number while now is still large, so the compare is true on + * every pass and the block runs on each pass instead of once per interval, until the + * counter wraps as well. Comparing the wrapped difference instead stays correct, because + * for two readings less than 2^31 apart the modular subtraction is their real distance. + * + * The horizon is therefore half the counter: a deadline up to 2^31 - 1 ahead reads as + * future, and beyond that future and past are no longer distinguishable. Every interval + * scheduled in this firmware is orders of magnitude inside that horizon. + * + * The widths are fixed deliberately rather than written as long. long is 32 bits on the + * firmware targets but 64 bits on the test host, and a 64-bit deadline does not preserve + * the behaviour at the wrap, it changes it into a stall for a whole counter period. Fixed + * widths give the host test the same arithmetic the target performs. + * + * This is the question Dispatcher::millisHasNowPassed answers for code that holds a + * Dispatcher, written here for code that does not, and spelled without that version's + * conversion of an out-of-range unsigned value to a signed one, which the standard leaves + * to the implementation. test_millis_deadline pins the two forms to each other. + */ +inline bool millisHasPassed(uint32_t now, uint32_t deadline) { + const uint32_t elapsed = now - deadline; + return elapsed != 0 && elapsed < 0x80000000u; +} + +} // namespace mesh diff --git a/test/test_millis_deadline/test_millis_deadline.cpp b/test/test_millis_deadline/test_millis_deadline.cpp new file mode 100644 index 00000000000..fd1259d0dd1 --- /dev/null +++ b/test/test_millis_deadline/test_millis_deadline.cpp @@ -0,0 +1,121 @@ +#include + +#include + +// The firmware schedules periodic work by storing 'millis() + interval' and asking +// once per loop pass whether that deadline has passed. These tests drive exactly that +// loop, including over the two points where a 32-bit millisecond counter is awkward: +// where it passes 2^31 and where it wraps back to zero. +// +// Every value here is uint32_t on purpose. The firmware targets have a 32-bit long, +// the test host a 64-bit one, so a test written with long would measure the host +// rather than the target. + +namespace { + +const uint32_t SIGN_FLIP = 0x80000000u; // 24.8 days +const uint32_t INTERVAL = 1000; +const uint32_t SPAN = 3000000; // 3000 s of loop time, 1 ms per pass + +// Reference form of the same question, as Dispatcher::millisHasNowPassed spells it. +bool signedDifferenceIdiom(uint32_t now, uint32_t deadline) { + return (int32_t)(now - deadline) > 0; +} + +// One periodic block, driven pass by pass. Records what a caller actually cares about: +// how often it ran, the longest wait between two runs, and the longest run of +// consecutive passes that all fired (which must stay 1 for a periodic block). +struct PeriodicBlock { + uint32_t deadline; + uint32_t fires = 0; + uint32_t longest_gap = 0; + uint32_t longest_burst = 0; + uint32_t gap = 0; + uint32_t burst = 0; + + explicit PeriodicBlock(uint32_t start) : deadline(start) {} + + void pass(uint32_t now) { + gap++; + if (mesh::millisHasPassed(now, deadline)) { + if (gap > longest_gap) longest_gap = gap; + gap = 0; + fires++; + burst++; + if (burst > longest_burst) longest_burst = burst; + deadline = now + INTERVAL; + } else { + burst = 0; + } + } +}; + +PeriodicBlock runFrom(uint32_t start) { + PeriodicBlock block(start); + for (uint32_t i = 0; i < SPAN; i++) block.pass(start + i); + return block; +} + +} // namespace + +TEST(MillisDeadline, FiresOnlyStrictlyAfterTheDeadline) { + EXPECT_FALSE(mesh::millisHasPassed(999, 1000)); + EXPECT_FALSE(mesh::millisHasPassed(1000, 1000)); // strict, as the shipped loops were + EXPECT_TRUE(mesh::millisHasPassed(1001, 1000)); +} + +TEST(MillisDeadline, MatchesTheSignedDifferenceIdiom) { + const uint32_t deadlines[] = {0, 1, 1000, SIGN_FLIP - 1, SIGN_FLIP, SIGN_FLIP + 1, 0xFFFFFFFFu}; + const uint32_t offsets[] = {0, 1, 2, 1000, 0x7FFFFFFEu, 0x7FFFFFFFu, SIGN_FLIP, SIGN_FLIP + 1, + 0xFFFFFFFEu, 0xFFFFFFFFu}; + for (uint32_t deadline : deadlines) { + for (uint32_t offset : offsets) { + const uint32_t now = deadline + offset; + EXPECT_EQ(signedDifferenceIdiom(now, deadline), mesh::millisHasPassed(now, deadline)) + << "now=" << now << " deadline=" << deadline; + } + } +} + +TEST(MillisDeadline, TreatsADeadlineThatWrappedAsStillAhead) { + // The last scheduling before the counter wraps: millis() is 256 short of 2^32 and + // the stored deadline has already wrapped to 500, so it lies 756 ms in the future. + const uint32_t now = 0xFFFFFF00u; + const uint32_t wrapped_deadline = 500; + + EXPECT_FALSE(mesh::millisHasPassed(now, wrapped_deadline)); + EXPECT_EQ(-756, (int32_t)(now - wrapped_deadline)); // 756 ms still to wait + EXPECT_TRUE(mesh::millisHasPassed(now + 757, wrapped_deadline)); +} + +TEST(MillisDeadline, PeriodIsUnchangedByTheSignFlipAndTheWrap) { + const PeriodicBlock quiet = runFrom(0x10000000u); // nowhere near a boundary + const PeriodicBlock sign = runFrom(SIGN_FLIP - SPAN / 2); // across 2^31 + const PeriodicBlock wrap = runFrom(0u - SPAN / 2); // across the counter wrap + + EXPECT_EQ(INTERVAL + 1, quiet.longest_gap); // strict compare, so one pass later + EXPECT_EQ(1u, quiet.longest_burst); // a periodic block never fires twice in a row + + EXPECT_EQ(quiet.fires, sign.fires); + EXPECT_EQ(quiet.longest_gap, sign.longest_gap); + EXPECT_EQ(quiet.longest_burst, sign.longest_burst); + + EXPECT_EQ(quiet.fires, wrap.fires); + EXPECT_EQ(quiet.longest_gap, wrap.longest_gap); + EXPECT_EQ(quiet.longest_burst, wrap.longest_burst); +} + +TEST(MillisDeadline, LooksAheadHalfTheCounterAtMost) { + // Documented horizon: a deadline up to 2^31 - 1 ahead reads as future, beyond that + // the difference can no longer tell future from past. Every interval the firmware + // schedules is far inside that horizon. + const uint32_t now = 0; + EXPECT_TRUE(mesh::millisHasPassed(now, now - 0x7FFFFFFFu)); // 2^31 - 1 in the past + EXPECT_FALSE(mesh::millisHasPassed(now, now - SIGN_FLIP)); // exactly 2^31, reads as future + EXPECT_FALSE(mesh::millisHasPassed(now, now + 1)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 09b303cf2f0914087ecfc0001f2f14aad77bab74 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:11:56 +0200 Subject: [PATCH 027/136] fix(variants): make the GPS update deadline wrap-safe in seven variants What changes - variants/heltec_mesh_solar, heltec_tracker, meshadventurer, meshtracker_x1, nano_g2_ultra, t1000-e and thinknode_m1 store next_gps_update as uint32_t and ask mesh::millisHasPassed(millis(), next_gps_update) instead of millis() > next_gps_update. Each variant keeps its own extra conditions and its own spacing. - thinknode_m1 also stores last_switch_check as uint32_t. That timer uses the elapsed form, millis() - last_switch_check > 1000, which is already wrap-safe. This is a type cleanup so that no millis() reading sits in a signed variable, not a behaviour fix: driven pass by pass over both boundaries, every single comparison is identical before and after (2997 runs, longest gap 1002 ms, longest run of consecutive passes 1, in both windows). Why - A deadline formed as millis() + 1000 wraps to a small number in the last interval before the counter reaches 2^32, while millis() is still large, so the comparison is true on every loop pass. The block then runs on each pass instead of once per second for up to 1000 ms, and returns to its normal period once the counter wraps too. - 63731d3f fixed this shape in the shared EnvironmentSensorManager and left these seven copies. Its message describes the defect as a sign flip after about 24.8 days after which the block never runs again and the GPS time sync stalls permanently. That is not what happens on these targets, and the difference matters for anyone reading the code. millis() is unsigned, so the signed deadline is converted to unsigned and the comparison is performed unsigned. Checked on arm-none-eabi gcc 7.2.1, 12.3.1 and 14.2.1 and on xtensa-esp32 and xtensa-esp32s3 gcc 8.4.0, each with a positive and a negative control: sizeof(long) is 4, (unsigned long)1 > (long)-1 is false, and storing millis() + 1000 in a long and reading it back preserves the bits. - Driving the loop pass by pass over 3000 s of loop time with those widths: across 2^31 the shipped code, a 64-bit variant and the wrap-safe form all run 2998 times with a longest gap of 1001 ms and never fire twice in a row, so nothing happens at that boundary at all. Across the 2^32 wrap the shipped code runs 3497 times with up to 501 consecutive passes firing, while the wrap-safe form keeps 2998 runs and a longest gap of 1001 ms. The permanent stall does occur, but only where long is 64 bits wide, which is the test host and no firmware target. The fix in 63731d3f is right, its stated reason is not. - What the burst costs is small: the guarded block only copies the cached latitude, longitude and altitude out of the location provider and, in debug builds, prints them. That is read from the source of all seven blocks, not measured at runtime, because reaching the wrap takes 49.7 days of uptime. Compatibility - No stored format, no CLI reply and no protocol field changes. - The period and behaviour of all seven loops are unchanged except in the last interval before the millis() counter wraps. - All 45 environments of the seven variants were built. 42 build. The three that do not (Heltec_mesh_solar_companion_radio_ble and _usb, where the meshsolar dependency defines NONE as a macro and breaks the InterfaceType enum, and MeshTracker_X1_kiss_modem, which does not inherit stevemarple/MicroNMEA) fail the same way on unchanged dev 428a95c6, each checked in a separate worktree that git reports as clean at that commit. Neither cause is in a file this change touches. - The 18 environments of the regression matrix were built from this change and from dev with the same toolchain: 36 of 36 build, and flash and RAM are identical in every one of the 18. None of those targets includes the new header, so that is the expected result. - t1000e_repeater built from this change and from dev shows the change in the firmware: in T1000SensorManager::loop the deadline test goes from a register compare with an unsigned branch to the difference form, register compares followed by an unsigned branch drop from 2 to 1, instruction count rises by 1, and the section sizes are unchanged, so it costs no flash there. - No hardware run. The counter wrap is 49.7 days of uptime away and cannot be reached on a board; a board run could only have shown that normal GPS operation is unaffected, and no board with this hardware was connected. --- variants/heltec_mesh_solar/target.cpp | 5 +++-- variants/heltec_tracker/target.cpp | 5 +++-- variants/meshadventurer/target.cpp | 5 +++-- variants/meshtracker_x1/target.cpp | 5 +++-- variants/nano_g2_ultra/target.cpp | 5 +++-- variants/t1000-e/target.cpp | 5 +++-- variants/thinknode_m1/target.cpp | 7 ++++--- 7 files changed, 22 insertions(+), 15 deletions(-) diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index 94e2684c12a..e12dd186256 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -1,6 +1,7 @@ #include #include "target.h" #include +#include #include MeshSolarBoard board; @@ -66,11 +67,11 @@ bool SolarSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& } void SolarSensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _location->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (_location->isValid()) { node_lat = ((double)_location->getLatitude())/1000000.; node_lon = ((double)_location->getLongitude())/1000000.; diff --git a/variants/heltec_tracker/target.cpp b/variants/heltec_tracker/target.cpp index f32c41ff477..af8319923c5 100644 --- a/variants/heltec_tracker/target.cpp +++ b/variants/heltec_tracker/target.cpp @@ -1,6 +1,7 @@ #include #include "target.h" +#include #include HeltecV3Board board; @@ -71,11 +72,11 @@ bool HWTSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& t } void HWTSensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _location->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (gps_active && _location->isValid()) { node_lat = ((double)_location->getLatitude())/1000000.; node_lon = ((double)_location->getLongitude())/1000000.; diff --git a/variants/meshadventurer/target.cpp b/variants/meshadventurer/target.cpp index 8795a4cbe73..9ce1a0d8ddb 100644 --- a/variants/meshadventurer/target.cpp +++ b/variants/meshadventurer/target.cpp @@ -1,6 +1,7 @@ #include #include "target.h" +#include #include MeshadventurerBoard board; @@ -65,9 +66,9 @@ bool MASensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& te } void MASensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _location->loop(); - if(millis() > next_gps_update && gps_active) { + if(mesh::millisHasPassed(millis(), next_gps_update) && gps_active) { if(_location->isValid()) { node_lat = ((double)_location->getLatitude()) / 1000000.; node_lon = ((double)_location->getLongitude()) / 1000000.; diff --git a/variants/meshtracker_x1/target.cpp b/variants/meshtracker_x1/target.cpp index 5b90d660934..366e6bdddcc 100644 --- a/variants/meshtracker_x1/target.cpp +++ b/variants/meshtracker_x1/target.cpp @@ -1,5 +1,6 @@ #include #include "target.h" +#include #include #include @@ -75,12 +76,12 @@ bool MeshTrackerX1SensorManager::querySensors(uint8_t requester_permissions, Cay } void MeshTrackerX1SensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _nmea->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (gps_active && _nmea->isValid()) { node_lat = ((double)_nmea->getLatitude())/1000000.; node_lon = ((double)_nmea->getLongitude())/1000000.; diff --git a/variants/nano_g2_ultra/target.cpp b/variants/nano_g2_ultra/target.cpp index 69a2772ccb1..4bd88fb008c 100644 --- a/variants/nano_g2_ultra/target.cpp +++ b/variants/nano_g2_ultra/target.cpp @@ -2,6 +2,7 @@ #include #include +#include #include NanoG2Ultra board; @@ -73,7 +74,7 @@ bool NanoG2UltraSensorManager::querySensors(uint8_t requester_permissions, Cayen } void NanoG2UltraSensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; if (!gps_active) { return; // GPS is not active, skip further processing @@ -81,7 +82,7 @@ void NanoG2UltraSensorManager::loop() { _location->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (_location->isValid()) { node_lat = ((double)_location->getLatitude()) / 1000000.; node_lon = ((double)_location->getLongitude()) / 1000000.; diff --git a/variants/t1000-e/target.cpp b/variants/t1000-e/target.cpp index 70a64359fd1..c48644f9f35 100644 --- a/variants/t1000-e/target.cpp +++ b/variants/t1000-e/target.cpp @@ -1,6 +1,7 @@ #include #include "t1000e_sensors.h" #include "target.h" +#include #include #include @@ -120,11 +121,11 @@ bool T1000SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& } void T1000SensorManager::loop() { - static long next_gps_update = 0; + static uint32_t next_gps_update = 0; _nmea->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (gps_active && _nmea->isValid()) { node_lat = ((double)_nmea->getLatitude())/1000000.; node_lon = ((double)_nmea->getLongitude())/1000000.; diff --git a/variants/thinknode_m1/target.cpp b/variants/thinknode_m1/target.cpp index 69306fc0e1e..0c832447e46 100644 --- a/variants/thinknode_m1/target.cpp +++ b/variants/thinknode_m1/target.cpp @@ -1,6 +1,7 @@ #include #include "target.h" #include +#include #include ThinkNodeM1Board board; @@ -69,8 +70,8 @@ bool ThinkNodeM1SensorManager::querySensors(uint8_t requester_permissions, Cayen } void ThinkNodeM1SensorManager::loop() { - static long next_gps_update = 0; - static long last_switch_check = 0; + static uint32_t next_gps_update = 0; + static uint32_t last_switch_check = 0; // Check GPS switch state every second if (millis() - last_switch_check > 1000) { @@ -98,7 +99,7 @@ void ThinkNodeM1SensorManager::loop() { _location->loop(); - if (millis() > next_gps_update) { + if (mesh::millisHasPassed(millis(), next_gps_update)) { if (_location->isValid()) { node_lat = ((double)_location->getLatitude())/1000000.; node_lon = ((double)_location->getLongitude())/1000000.; From f9b369b4155bbf4cd5a72a0ebdb1fd889d23c1fe Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:11:56 +0200 Subject: [PATCH 028/136] docs(fixes): note for the GPS millis deadline wrap What the seven variants did, what measurement shows they actually do as opposed to what 63731d3f claims, the shared function and why its widths are fixed, the alternatives that were rejected, and the host tests, mutations, warning and code generation checks, builds and flash and RAM figures behind it. Can be left out of an upstream pull request; the two commits before it carry the reasoning. --- docs/fixes/gps-millis-deadline-wrap.md | 271 +++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 docs/fixes/gps-millis-deadline-wrap.md diff --git a/docs/fixes/gps-millis-deadline-wrap.md b/docs/fixes/gps-millis-deadline-wrap.md new file mode 100644 index 00000000000..1d13275fc36 --- /dev/null +++ b/docs/fixes/gps-millis-deadline-wrap.md @@ -0,0 +1,271 @@ +# GPS update deadline is not wrap-safe in seven variants + +Status: ready for review. Behaviour change: only at the millis() wrap, see below. + +## Context + +Seven variants schedule their GPS cache refresh the same way: + +```c++ +static long next_gps_update = 0; +... +if (millis() > next_gps_update) { + ... + next_gps_update = millis() + 1000; +} +``` + +`variants/heltec_mesh_solar`, `heltec_tracker`, `meshadventurer`, `meshtracker_x1`, +`nano_g2_ultra`, `t1000-e` and `thinknode_m1`, each in `target.cpp`. + +The shared `EnvironmentSensorManager` had the same shape and was changed in 63731d3f +to an unsigned deadline with `(long)(millis() - deadline) > 0`. These seven copies +were not part of that change. + +### What the old code actually does + +63731d3f describes the defect as a sign flip after about 24.8 days, after which the +block "never runs again" and the GPS time sync stalls "permanently until reboot". +Measured, that is not what happens on these targets. + +`millis()` returns an unsigned 32-bit value on every platform here (`unsigned long` +on ESP32, `uint32_t` on nRF52). In `millis() > next_gps_update` the signed operand is +converted to `unsigned long`, so the comparison is performed unsigned, and storing +`millis() + 1000` into a `long` and reading it back is bit-preserving. Checked with +every installed toolchain that can build these targets (arm-none-eabi gcc 7.2.1, +12.3.1 and 14.2.1, xtensa-esp32 and xtensa-esp32s3 gcc 8.4.0), each with a +positive and a negative control: + +``` +sizeof(long) == 4 && sizeof(unsigned long) == 4 holds +((unsigned long)1 > (long)-1) == false holds, so unsigned +(unsigned long)(long)(unsigned long)0x800003E8 == 0x800003E8 holds +``` + +So there is no sign flip to trip over at 2^31. The counter wrap at 2^32 is a real +problem, but a different one. Driving the loop pass by pass over 3000 s of loop time, +1 ms per pass, with the target's widths: + +| Window | Shape | Runs | Longest gap | Longest run of consecutive passes | +|---|---|---:|---:|---:| +| across 2^31, 24.8 days | shipped, 32-bit `long` | 2998 | 1001 ms | 1 | +| across 2^31, 24.8 days | 64-bit `long` | 2998 | 1001 ms | 1 | +| across 2^31, 24.8 days | wrap-safe | 2998 | 1001 ms | 1 | +| across 2^32, 49.7 days | shipped, 32-bit `long` | 3497 | 1001 ms | 501 | +| across 2^32, 49.7 days | 64-bit `long` | 1499 | 1500500 ms | 1 | +| across 2^32, 49.7 days | wrap-safe | 2998 | 1001 ms | 1 | + +In the last interval before the counter reaches 2^32, `millis() + 1000` wraps to a +small number while `millis()` is still large, so the comparison is true on every pass. +The block then runs on each loop pass instead of once per second for up to 1000 ms, +and resumes its normal period once the counter wraps as well. The longest gap does not +grow, so nothing stalls. + +The permanent stall 63731d3f describes does occur, but only where `long` is 64 bits +wide, which is the test host and none of the firmware targets. The fix in 63731d3f is +right; its stated reason is not. + +What the burst costs is small. The guarded block only copies the cached latitude, +longitude and altitude out of the location provider and, in debug builds, prints them; +it is idempotent. This is read from the source of all seven blocks, not measured at +runtime: reaching the wrap takes 49.7 days of uptime. + +## Decision + +A new header, `src/helpers/MillisDeadline.h`, holds the question as one inline +function: + +```c++ +inline bool millisHasPassed(uint32_t now, uint32_t deadline) { + const uint32_t elapsed = now - deadline; + return elapsed != 0 && elapsed < 0x80000000u; +} +``` + +All seven variants call it. Three properties are deliberate: + +- **Fixed widths, not `long`.** `long` is 32 bits on the firmware targets and 64 bits + on the test host. A host test written with `long` would exercise the 64-bit shape, + which does not merely keep the behaviour at the wrap but turns the burst into a + stall, as the table above shows. With `uint32_t` the host performs the arithmetic + the target performs, so the test means something. +- **Strict comparison.** The shipped loops fired strictly after the deadline, so the + period is unchanged away from the wrap (2998 runs, 1001 ms, in the table above). +- **No out-of-range conversion.** `Dispatcher::millisHasNowPassed` casts a difference + above `LONG_MAX` to `long`, which the standard leaves to the implementation before + C++20. The two spellings agree on every input, which `test_millis_deadline` pins, and + they compile to the same instructions, so the well-defined spelling costs nothing. + +`thinknode_m1` has a second timer in the same function, `last_switch_check`. It uses +the elapsed form, `millis() - last_switch_check > 1000`, which is already wrap-safe. +Its type changes from `long` to `uint32_t` so that no millis() reading is stored in a +signed variable, and that is all: driven over both boundaries, every single comparison +is identical before and after (2997 runs, longest gap 1002 ms, longest run 1, in both +windows). It is a type cleanup, not a behaviour fix. + +## Alternatives considered + +- **Write the idiom inline at each of the seven places, as 63731d3f did.** Rejected. + Seven hand-written copies are what produced this defect, and `variants/*/target.cpp` + does not build on the host, so an inline form cannot be covered by a test. A shared + function can, and the embedded builds then show the seven call sites compile. +- **Reuse `Dispatcher::millisHasNowPassed`.** Rejected: it is a member function and + reads the clock through `_ms`, and a `SensorManager` in a variant has no + `Dispatcher`. +- **Put the helper on `SensorManager`.** Rejected: all seven callers happen to be + `SensorManager` subclasses, but a deadline comparison is not a sensor concern. +- **Also change `Dispatcher::millisHasNowPassed` to call the new helper.** Left out of + this change: it is separable, it touches the core that every role uses, and the two + forms are pinned to each other by a test either way. +- **Fix the remaining naive deadline comparisons too.** Left out. 28 further + `millis() >` and `millis() >=` comparisons exist in the UI tasks, the simple + examples and `SerialBLEInterface`. They use `unsigned long` deadlines, so they are a + different shape from this one, and each would need its own reading before being + called a defect. Not classified here. + +## Compatibility + +- No stored format, no CLI reply and no protocol field is touched. +- The period and the behaviour of all seven loops are unchanged except in the last + interval before the millis() counter wraps. +- The new header is self-contained and includes only `stdint.h`. + +## Verification + +### Host tests + +`test/test_millis_deadline/` runs in the `native` environment and covers: + +| Test | What it pins | +|---|---| +| `FiresOnlyStrictlyAfterTheDeadline` | the strict comparison the shipped loops used | +| `MatchesTheSignedDifferenceIdiom` | agreement with `(int32_t)(now - deadline) > 0` over 70 pairs, including differences of 0, 1, 2^31 - 1, 2^31 and 2^32 - 1 | +| `TreatsADeadlineThatWrappedAsStillAhead` | a deadline that wrapped to 500 while `millis()` is 0xFFFFFF00 reads as 756 ms in the future, where the old form read it as long past | +| `PeriodIsUnchangedByTheSignFlipAndTheWrap` | runs, longest gap and longest consecutive run are identical in a quiet window, across 2^31 and across 2^32 | +| `LooksAheadHalfTheCounterAtMost` | the documented horizon of 2^31 - 1 | + +Written against the shipped shape first: with the naive body in the header, four of +the five tests fail. With the wrap-safe body all five pass. + +Mutation tests, each applied to the helper alone, with the header restored and +re-checked by its sha256 afterwards: + +| Mutation | Failing tests | +|---|---:| +| the naive comparison, as shipped | 4 | +| zero guard dropped, fires on the deadline itself | 3 | +| half-plane inclusive, `<=` instead of `<` | 2 | +| half-plane inverted, `>` instead of `<` | 5 | +| arithmetic in `unsigned long` instead of `uint32_t` | 4 | +| horizon off by one, `0x7FFFFFFF` instead of `0x80000000` | 2 | + +The fifth row is the reason for the fixed widths: that mutation is invisible on a +32-bit target and wrong on a 64-bit host. + +All native suites pass: `pio test -e native -e native_kiss_modem`, 59 of 59. + +### Warnings and generated code + +The firmware builds with `-w`, so a warning in this header would never surface there. +Compiled on its own with `-Wall -Wextra -Wconversion -Wsign-conversion -Wshadow +-Wold-style-cast -Wpedantic`, driven both with an `unsigned long` and with a `uint32_t` +reading to cover the implicit conversion at the call sites: + +| Toolchain | Warnings | +|---|---:| +| arm-none-eabi gcc 7.2.1 | 0 | +| arm-none-eabi gcc 12.3.1 | 0 | +| arm-none-eabi gcc 12.3.1, `-Ofast` | 0 | +| arm-none-eabi gcc 14.2.1 | 0 | +| xtensa-esp32 gcc 8.4.0 | 0 | +| xtensa-esp32s3 gcc 8.4.0 | 0 | + +`-Ofast` is included because the nRF52 firmware is built with it and it has caught a +difference between host and target before. + +The two spellings also generate identical code: + +| Target | `millisHasPassed` | `(int32_t)(now - deadline) > 0` | +|---|---|---| +| nRF52, `-Ofast`, gcc 12.3.1 | `sub r0,r0,r1; cmp r0,#0; movle r0,#0; movgt r0,#1; bx lr` | identical | +| ESP32-S3, `-Os`, gcc 8.4.0 | `entry sp,32; sub a2,a2,a3; srai a8,a2,31; sub a2,a8,a2; extui a2,a2,31,1; retw.n` | identical | + +### Builds + +All 45 environments of the seven variants were built from this change. 42 build. The +three that do not, fail the same way on unchanged `dev` 428a95c6, each checked in a +separate worktree that `git status` reports as clean at that commit: + +| Environment | Error | Cause | On unchanged `dev` | +|---|---|---|---| +| `Heltec_mesh_solar_companion_radio_usb` | `'USB' is not a member of 'InterfaceType'` | the `meshsolar` dependency defines `NONE` as a macro in `src/utils/logger.h:18`, which replaces the `NONE` enumerator of `InterfaceType` in `src/helpers/MultiSerialInterface.h:11` and makes the enum body invalid | fails, 57 s | +| `Heltec_mesh_solar_companion_radio_ble` | `'Bluetooth' is not a member of 'InterfaceType'` | the same macro collision | fails, 89 s | +| `MeshTracker_X1_kiss_modem` | `MicroNMEA.h: No such file or directory` | the `kiss_modem` environment of this variant does not inherit `stevemarple/MicroNMEA`, while the four other environments of the variant list it and `target.cpp` includes the provider unconditionally | fails, 85 s | + +Neither cause is in the files this change touches, and neither is related to it. Both +are handled separately. + +The 18 environments of the regression matrix were built from this change and from +`dev` in two worktrees with the same toolchain. All 36 builds succeed, and flash and +RAM are identical in every one of the 18: + +| Environment | Platform | Flash | Flash vs `dev` | RAM | RAM vs `dev` | +|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32-S3 | 1174449 | +0 | 62512 | +0 | +| `Heltec_v3_room_server` | ESP32-S3 | 1169705 | +0 | 66200 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32-S3 | 1666437 | +0 | 194836 | +0 | +| `Heltec_v3_companion_radio_wifi` | ESP32-S3 | 1102677 | +0 | 172376 | +0 | +| `Heltec_v3_sensor` | ESP32-S3 | 1180593 | +0 | 60560 | +0 | +| `RAK_4631_repeater` | nRF52840 | 440952 | +0 | 37240 | +0 | +| `RAK_4631_room_server` | nRF52840 | 434088 | +0 | 40928 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52840 | 462548 | +0 | 149760 | +0 | +| `RAK_4631_sensor` | nRF52840 | 437544 | +0 | 35288 | +0 | +| `PicoW_repeater` | RP2040 | 491972 | +0 | 85368 | +0 | +| `wio-e5-mini_repeater` | STM32WL | 203552 | +0 | 17172 | +0 | +| `wio-e5-mini_sensor` | STM32WL | 202904 | +0 | 15236 | +0 | +| `RAK_3112_repeater_bridge_espnow` | ESP32-S3 | 1175717 | +0 | 64080 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | 240368 | +0 | 26704 | +0 | +| `ProMicro_repeater` | nRF52840 | 345424 | +0 | 33892 | +0 | +| `ProMicro_room_server` | nRF52840 | 338456 | +0 | 37588 | +0 | +| `ProMicro_companion_radio_usb` | nRF52840 | 357300 | +0 | 99460 | +0 | +| `ProMicro_sensor` | nRF52840 | 341900 | +0 | 31948 | +0 | + +That they are identical is expected: none of these targets includes the new header. +A difference would have been a finding. + +### In the built firmware + +`t1000e_repeater` was built from this change and from unchanged `dev`, and +`T1000SensorManager::loop` disassembled in both. The deadline test changes from a +register compare with an unsigned branch to the difference form: + +``` +dev cmp r0, r3 fix subs r0, r0, r3 + bls ... cmp r0, #0 +``` + +Counted over the whole function, register-to-register compares followed by an +unsigned branch drop from 2 to 1, and the instruction count rises by 1 (279 to 280). +The compare that remains comes from other inlined code, not from a deadline. The +`subs`, `cmp #0`, signed-branch sequence that appears unchanged earlier in the same +function is the already corrected test in `MicroNMEALocationProvider::loop`, inlined +through `_nmea->loop()`. Section sizes are unchanged (text 292956, data 932, bss +31944 in both), so the extra instruction fits in existing alignment padding and costs +no flash here. + +### Hardware + +Not done. The counter wrap is 49.7 days of uptime away, so it cannot be reached on a +board at all; what a board run could show is that normal GPS operation is unaffected. +The T1000-E available for this work was not connected while the change was made, so +there is no runtime evidence either way. The host tests and the 42 builds are the +whole of the verification. + +## Follow-ups + +- Decide whether `Dispatcher::millisHasNowPassed` should call `millisHasPassed`, so + the idiom exists once. +- Classify the 28 remaining naive deadline comparisons listed above. +- None of the 15 environments the pull request check builds covers any of these seven + variants, so a compile error in them would not be caught by it. Consider adding one + variant with a GPS loop to that matrix. From 9c2fecbde030f88c740b9e99cc11f80c58ac195a Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:12:33 +0200 Subject: [PATCH 029/136] chore: stop tracking the Python bytecode cache tools/refactoring/__pycache__/nodetool.cpython-315.pyc went into the tree with the hardware check tool in 63ea0afc. It is generated output that changes with every interpreter version, so it has no business being tracked. Removed from the index, and __pycache__ and *.pyc added to .gitignore so it does not come back. Checked by regenerating the file with py_compile: git check-ignore matches it against .gitignore:19, and git status leaves it out. --- .gitignore | 2 ++ .../__pycache__/nodetool.cpython-315.pyc | Bin 13759 -> 0 bytes 2 files changed, 2 insertions(+) delete mode 100644 tools/refactoring/__pycache__/nodetool.cpython-315.pyc diff --git a/.gitignore b/.gitignore index a0ad5f6ea94..2afa40ec290 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ cmake-* compile_commands.json .venv/ venv/ +__pycache__/ +*.pyc platformio.local.ini diff --git a/tools/refactoring/__pycache__/nodetool.cpython-315.pyc b/tools/refactoring/__pycache__/nodetool.cpython-315.pyc deleted file mode 100644 index a30bc1f2dcd531853208f0b60d16a62a7d8a6e6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13759 zcmc(GYj6`+x?s1|ddqJaKd^8+5J2Du2J#W-KyLD07pFb2GWa?oMi%TRS_Md#9#yxAq}Nc2GLrnrzjrn&lq}RNc%} zZQcF8)9O}RaYF8|J)raGbIx~O-}@Z*7ZzA42&s-wp6jorr~np}V9aJ7{*I=o7!*8| zKn+uUrfx={Nop2!B()2Ak`@aLNgaZLq?Lk^q&0$xq^w{j={CV~o_0HwfpV$4B9~Wk z9(pf~k3q0QuWC83jonUdNzQw8|B4ax$_RUPa_{qY53@@Hv|c9^oiDDXHXa{Z8?>!G z#{F41LW!r$V=AXE(n9HZlY6@cWH#GOd(5QX;jxrcLb=D`v6Av8Pr1iNaupss)Kq#3 zAg=NhLR{@Bf_Ss17~&dF3B=CxTRf!)&cO-7c~(>Bowd~7>oxXl=udn0uNneNi z%$fGyPIl*k11@$F_8rw&k8hWH6Q-p0AmWOo0X_wL!#FyfyI0)kBi#_REPBY;W%QGO~U zHbnT*DKQkr?LtiIJmIy@)&FHv<7C(`0@6%`qD_}1oaffJp2hTp$pUnLkvC)kt&SbYA%<_k)D^gy>y?&Ln}-ts`u*M3iDPPLOM(l25|GmYiQPabiiWF7wL#$$f7ib z@3cpc--m#i$iZz_a=EmYf^iCSWhJLF)9eSO9A&)Bli6=FoAQFV*rj3Ub=sXRR^a8* zTFS#H(417yyOhkbV_7{+~rV!V@J4fbedEnBg=lA z4vFLNEhH%TD08+(z=y(se-c1)K7cVfC4LPj_Q9+kehyKqj-{MLJPy!@X=Qz*<+#gE z%1gPk!Yj856r{>3`t=98DJn{P)t&(mi*P=)mpTYt0tHy|LRq^{(^L#j>l>l#>M2E# zod6h}Fqf#VpQ)wJ)~VkCr~6DHz^G|D1{ikAXa#sZAbu@W4cS!_b;`O=KTf;!b0xTC z12V<-65cNropg7|8{A3uSE@Ma?&rorg2>NyMIh=J4~0Q`L|`lRW1Q$RNDOjs$ry?V zAxP$y+R}8V zWo663O!byj?Vi=@=Cq?ZQ{}u}d80D*#F4xG_o|+Xw|`o@HQD~t7w$P4R~(HG^;C5` z{i&nzPXY^r{FSx6k$$xXqW6pt{f9<~e#_GB_153k*&!d$o-Y7aR>6Gcrvp`3dUoIajYPgU-`$XU_7?@}&C(lzdp7!XUML{E~v z5?~-|JIls+q*uw(HpWG`nMwYrRI|Y*sW&Jcj5hEw?4nP& zED|GzCOF9|OpQ$P(Ew;2lAfQ6NLHQ@c7UJ`2v(5`O6JKh;8{2{A{oL_e^8K&f*1rc zl?+0d<0f539+{P7LWuZT&qaa~%}WME6+zw)WRIh;A?#Dx9Z|P|-vL!y;3xbLqCdSz z{gbuus_TmDN4pcIRcmdmV@)mVSFD@Y?3=FkUg`aKvpaPmxMJt#>5R>OwQZp-(Y#`- z`DdM>py>ex(IY!mzImnk?9VHIvE^^J+@*hG{*@VaE?s>#?KnH%^@k!yrlKa{`SDD= zXw7WDYQ18O?|ZfAQ(MtPm<6?t{U8zn`BD%62cT9AG8(a>3Jdt04S-787KjOXLDL`i zB`@p()Kh|{rCIrwOSLT(Mgv0caY96=FheIbq>2;whV0*~wnZT~tUC<H9|UNA z(9~{VduV6BOH(iEMcp<4U?q*J)Z*e!YcPzZXvu0x)x3 z9p$3$;|aP7BwbXHnCK)Ik&JF%f9Lo6C9~Vt>+TpFd`7ZjuJ`z9cgHD7FN(830G!DC zrvc?TRG|dPA_joz`bCkKj2+Of{j_B0KwTPZpfRa24R#E)_qx9i41+<^kQmh4B;9CG zGGGHi<{CgHGL4vVIzAYOZ-Y5D!%z4TL@!cn7TeW|D;06ab$)U7+U#3<-#PN;k=2UE zUzYwS=f8KZRvb-Rj>V3zF{YP$e%Le5ztp>CFS}=Ver$IpM$-1WSXYLz%-6l#eciNJ zc&+fQig%oEI#-JuSDD7Y>PEiIY>9Qv*S*+1KNa^Z%&alCdrbMqOnH3d=8oG&l zlvz*LqY6jw&C=y%m~E7CQc*)rSsI0J)l|=MimIZ9vh6_i+<;b*);&6v_KaSms*pgx zYUqzxc6R$N6rkA<)%8RHyW#s5YDlGOBNYHXgDT8ss^*N2h^ zSIc+JZ_Q)UrIm{fuQen~lT31C>4~Lax^&-yX`Y^Mp7;O3W?yK2pwkyrtyNavHoaj= z>XM!5N>|+Q&qm5ol_@U&+*TAj^*~P*l>EChS2{CT`s$I5a%_D>(FIjfapeO&q<xvU_44J#jK+x>%*7?=afjkgXWeTQ*Jy25!Jz4j=CM3Jv4CN9 z(FmBz6FA?U2x^uEEusnZ1@!R+*>MgkpefjI6O&Pqi-@j99#Ky+^4tiR8-!)c*@8bC zcY-fWbp3H33F%`JUGPU)YW(V$WWsU%0q$K|GIA&vggFD*#n$`C<9d5t#YD>>+BjkN zvQRNh^WeCVjQ&Z`ML&s#6@)SI_$3plNU#lHUW`nQ68R-D zcwiDk#z7NgdscdBBpCV-ec)~615n=sKVbsU??vj7m9myzcO`n#r90A=ov1C@=lkPj zD@=LDYL9jNucC^1ea2Q8-}%b1HGARJ?knB#{>0`L`;!@a(bb-Xo`gPeF8;!beaC0^ zl1#B<-mr$|#NteRX8!3ly8{}Rt=OGv*Ccam*S)Ix<*NF0RpW}iDN|8(v*q^THxB>w z$Wq1X<~{MUwbD(CuGd_z?@sDhO1CZ<{-yw>>LI{rNbPDXU1-T|e}xqb5^R`!@YTA}-+V0gH zjdx}$HZQ(#?S&t_w(?$Mn+F}%*> znh%dw{bK9=j-a7@huL^d-!QlLK?a{#KlA*N*Y~Br-hLfyl-HNKz2-b=`%93eaDk?k zwqBy;Wu@`&us(a$W1M?-05~LEuz-L9ZrN}Y?q5Ptb`%`@z}j?MWrs0OQs625{6d^n8px%uKBX{c0|T(du1k}OLU}**i|zN9J#O*xZPxgQ&b#per4*WKGE@0bFw*k>Fxb1l{-HzE?+C&v{>+3K_Zy!SZaUYu;jl} z_db`}){-vXf5*RCd}zLXt*A7M4zkN7W z)vzSox%9#QyUiaQzSFw&LVO=Mj~1p9<%vtTDwBhsRJp*D6t{!xNH&MFdM4}xMWJj3 zjz&G~hA0NV?`V0@8r_x#@3m@qcxiN6@FxKjScd2q$VqIMj)+rov;y|np0(pFSY-?fx%K4irzrTtqk7ps>in;O7jO^Zk?%rA z2q(&}225%OK;#8<;s96A>7E-}P#)c|s_|>N#E@!94VTrc++N$lvyS6wc2@6Jt*YFV zVCG-5!cGT9{Hw7x5gJOk%ey<0-GxJYFEze;z3Xy<^ zB&(l1BKY6kN8Io2LAzRvrKFnboS^6}vlM@ssQ&;Eay*H}@x>NIF1| zEBi(DyVdX2tZv?ahfmvE!82{P$;#7zX-6t{$O4Y#+x24LS{>0q< zMFFT(j|wS+NoJTYohGW&*I9`PRzpay58@aUHY7b- z1k^PE;d*rnx^>X#+XIBt4X^Y1f-^++(CUDgw1|jv%nJ(2Lm%SF&3U}s*1V=rgbPOl zkiSt2RUy&KFqcbRK#-i_Ytm3e?tj@9|JFC)c zT9!>6B-MG!wY@fCd7e$43WasRsw=VIH(=H79K#!~P_YP{DhdV#K+OQvQ)S`baOQCgH9$G&84f3x!(m5okL!#;Qk1dvajY`wn+As<4> z_x}$NK3Lg2cS+KXOiMapRMG`tag3j4WR&*crwqD81&-j8|*gq>3`a?Wk z)cNH1KcESsN+2`f%aD`wcoCxojIKZ= z(W0c|XC$3}R6u%_HAghf*J&lB`y$l;1b)JgL4ask_L{jgzW4f6!k;WjoPGWIW$5*VO0&V=Vy%RDpx?6SEs!`3CYy={8O_NMKx3*eh?+3Z{^ zt6KctYu`(jFVRa+zHd&KH7}S!o!q*8sqF2;OMyEb?}t*LU^4UG1zUVD@#M9kjPF9q zA6WGTQ=?<4@#j`YFQz6UspzHE2|guGr7q8`inA%-?6Ubh^g6$6u1<8O>ROg-_TLfj zo?1TG{n56M{>^gF`3(nVGDnVmxb4?RKGJO-zQqK;h&i}>gv%b_3 znDZfkdv?Jd4<=f!J(t{;6yMyn6ui^&?sJ)n%0$_ZFE2iS?fIpX@#oVO2jrH@W{o5U zU;jb!c-p)p)(#uAR#LI(e9f7dO8S@dZ;z%+8WxQ6y3D@)cbN|k-yQj|<=3$EJpcmt zF0n0D+q7K0XH8MyQ67N6dT_iy zhGS@5kD435Vg-6G1&N|M_Tj!06%-P@)NqmJ2@f-*x{WJlz_GkAaYRHZ6HMlo$w|eYo zq(YtQV$Fe->y+ICj%IMPxmqPh9JF|9B!Gh!#+L7!PW?=E&lV8=1udQeD`P99a~6Dt zhS!y?@U+A*8lDoyB|Ut@%O`wO22Tnm;Wr`~o16E;W3)MYLxTYKPhoDvAGtWkG)!I` z15*VT3XcxJ9f$7&E{B%>@4x(ut3)}IE8t1 zne(_wk}3N-O|mJ^)Vg}l@^x5+uN1)5$>U>H<&Ar;|@ERo-d2TlrzjN0q4o ze<~14T@+Gv;%bGM*}m(J<3AsHq^B+2bgccK@CNtl&V`-vmiYeprbP4YLpKg3JCj|B zV=K(=?30KTQ}NZK62&X`so4$}XXuba7xJrnI?AYjD6@3b>Hon=W6WZ_?)3}`JRkz* z;g&3kgI!0Q6QT;lf8r>6y{vRhc`Nc)Z2h)qyZh*v*hw3`vMnl9-wif zL_fugFq%YL;ZpY;hbF<#Pf}te;j;lhKPC`6NTU4`9Tt$PP_W82kP)63 z!O;BQ!q;EJPe42CMe1{OXs;ENG)iBeaA1odgcoYRapAKN<6=R`qD%> zPS8Zke^7PYYW;0lIm7`NTn%_A$WI^YAdW%i+g-chRz#xDhR3vBhe(%-bFPzb@3;Pm zpMoHd>$45!gm?brvKI&mv|AP7G)_&G1QfXPt8nP4Cq0jno~j+=r5`u0c0UwOPlyDr zFNtNeH8&5h&JK=auij4WQ4xRrV?&sSK{F54@$vs*9!4^c17tsPZWVR^zRiuYZv9=F z^)s-3lh^RA>o=2eK@7kNDu_n8OVzBJ0r?JlEQiPK4>+YYc&nV#dK|#N4gj?q?Tyy^ zNE6^NL1`rE!Fw^su-`k%N(_24B)k`!L;s8!?nGiakgub#I zMT#Tt4;~e2cn=5{X3&(I35jxNBXOVbKgD*4d)XHt7bW^Kk$b3|E6jwMfcsSb@1WI3 z@Dr@SKtb@8mM`wSwlmTEYciz~U+K7$L^Ux(-SYa_+c3v?O89=*uK)&N|!LCZfTqFn5&y9aNxb71kj7LQm85 z7mS5w9y%!6`WaR98C5|3mwZM+vG6m>1{t_zEGoG=wlH?DkX!-6(oQndrhV9d^3-(UvFZqDMV6)PA{L w4 Date: Mon, 19 Oct 2026 23:15:50 +0200 Subject: [PATCH 030/136] test(server): pin the advertisement schedule before it moves What this adds - Seven characterization tests for the advertisement schedule, which step 4b moves into the shared base: that nothing is advertised while both intervals are zero, that the local interval counts two minutes per unit and the flood interval one hour per unit, that setting either back to zero clears a timer that was already pending rather than only stopping the rescheduling, that a flood advert keeps firing on its own schedule afterwards, and that an advertisement asked for directly is transmitted on both send paths. - The tests drive the role through its own public surface (getNodePrefs(), updateAdvertTimer(), updateFloodAdvertTimer(), sendSelfAdvertisement()), so they read the same before and after the move and can show the behaviour did not change. - The radio mock records transmissions and accepts the frame, so a test can see that the node actually sent something. It refuses on request, for a test that wants a radio that will not take the frame. What is deliberately not pinned - The millisecond a frame leaves the radio. The duty cycle budget in Dispatcher decides that, and it is not what moves in this step. The tests assert that nothing goes out before an interval has passed and that something goes out after it. Verification - pio test -e native -e native_core -e native_kiss_modem -e native_ofast -e native_server: 105 of 105 test cases pass (98 before, 7 new), on the code as it stands, with the schedule still implemented three times in the roles. --- docs/refactoring/0004-shared-server-base.md | 8 ++ test/mocks/target.h | 16 ++- test/test_server_base/test_server_base.cpp | 106 ++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md index 0e38c2bb826..6010fec9b25 100644 --- a/docs/refactoring/0004-shared-server-base.md +++ b/docs/refactoring/0004-shared-server-base.md @@ -150,6 +150,14 @@ small: - RX boosted gain: initialise the pref under the same guards as the other roles and apply it in `begin()`, mirroring upstream's own room server fix. +## Verification of the steps + +Marcel's decision of 19 October: the 18 environment build matrix and the flash and +RAM comparison against `dev` run **once, at the end of the 4a to 4f series**, not per +step. Every step is a move inside the same three roles, so one matrix at the end +covers what six would. Each step still carries its host tests, its mutation table and +its diff proof, and a step is not called verified until the matrix has run. + ## Open points 1. **The retransmit formula.** Upstream moved the repeater and the room server off diff --git a/test/mocks/target.h b/test/mocks/target.h index 50cba6f7f18..092321a9782 100644 --- a/test/mocks/target.h +++ b/test/mocks/target.h @@ -47,7 +47,20 @@ struct MockRadioDriver : public mesh::Radio { int recvRaw(uint8_t* bytes, int sz) override { return 0; } uint32_t getEstAirtimeFor(int len_bytes) override { return 11 + 37 * (uint32_t)len_bytes; } float packetScore(float snr, int packet_len) override { return 0; } - bool startSendRaw(const uint8_t* bytes, int len) override { return false; } + // A radio that accepts the frame, so a test can see that something was actually + // transmitted and when. Set send_succeeds to false to model a refusing radio. + bool send_succeeds = true; + int send_calls = 0; + int last_send_len = 0; + uint8_t last_send[256] = {0}; + + bool startSendRaw(const uint8_t* bytes, int len) override { + if (!send_succeeds) return false; + send_calls++; + last_send_len = len; + if (len > 0 && len <= (int)sizeof(last_send)) memcpy(last_send, bytes, len); + return true; + } bool isSendComplete() override { return true; } void onSendFinished() override { } bool isInRecvMode() const override { return true; } @@ -56,6 +69,7 @@ struct MockRadioDriver : public mesh::Radio { tx_power = 0; tx_power_calls = 0; rx_boosted = false; rx_boost_calls = 0; rx_boost_supported = true; freq = bw = 0; sf = cr = 0; set_params_calls = 0; + send_succeeds = true; send_calls = 0; last_send_len = 0; last_rssi = -90.0f; last_snr = 5.0f; packets_recv = packets_sent = packets_recv_errors = 0; } diff --git a/test/test_server_base/test_server_base.cpp b/test/test_server_base/test_server_base.cpp index 0a35601ddbd..1eafc26a9de 100644 --- a/test/test_server_base/test_server_base.cpp +++ b/test/test_server_base/test_server_base.cpp @@ -320,6 +320,112 @@ TEST_F(RegionResolutionTest, TheRegionIsClearedBetweenPackets) { EXPECT_EQ(mesh->seenRegion(), nullptr); } + +// The advertisement schedule, which step 4b moves into the shared base. Driven +// through the role's own public surface, so these tests read the same before and +// after the move. They pin the interval arithmetic and which send path each kind of +// advertisement takes, not the millisecond the transmitter happens to pick: the +// duty cycle budget in Dispatcher decides that, and it is not what moved. +class AdvertScheduleTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + mesh.reset(new MyMesh(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + + // Start from "no advertisements scheduled", whatever begin() left behind. + mesh->getNodePrefs()->advert_interval = 0; + mesh->getNodePrefs()->flood_advert_interval = 0; + mesh->updateAdvertTimer(); + mesh->updateFloodAdvertTimer(); + radio_driver.send_calls = 0; + } + + // Run the node forward, and report whether it transmitted anything. + bool runFor(uint32_t millis_span, uint32_t step = 250) { + const int before = radio_driver.send_calls; + for (uint32_t t = 0; t < millis_span; t += step) { + g_mock_millis += step; + mesh->loop(); + } + return radio_driver.send_calls > before; + } +}; + +TEST_F(AdvertScheduleTest, NothingIsAdvertisedWhenBothIntervalsAreZero) { + EXPECT_FALSE(runFor(3 * 60 * 60 * 1000)); // three hours of quiet +} + +TEST_F(AdvertScheduleTest, TheLocalIntervalCountsTwoMinutesPerUnit) { + mesh->getNodePrefs()->advert_interval = 1; // the field is minutes / 2 + mesh->updateAdvertTimer(); + + EXPECT_FALSE(runFor(2 * 60 * 1000 - 1000)) << "nothing before the two minutes"; + EXPECT_TRUE(runFor(2000)) << "advertised once the two minutes passed"; +} + +TEST_F(AdvertScheduleTest, AZeroLocalIntervalStopsAScheduledAdvert) { + mesh->getNodePrefs()->advert_interval = 1; + mesh->updateAdvertTimer(); + + // Turning it off has to clear the pending timer, not just stop rescheduling. + mesh->getNodePrefs()->advert_interval = 0; + mesh->updateAdvertTimer(); + + EXPECT_FALSE(runFor(10 * 60 * 1000)); +} + +TEST_F(AdvertScheduleTest, TheFloodIntervalCountsOneHourPerUnit) { + mesh->getNodePrefs()->flood_advert_interval = 1; // the field is hours + mesh->updateFloodAdvertTimer(); + + EXPECT_FALSE(runFor(60 * 60 * 1000 - 60 * 1000)) << "nothing before the hour"; + EXPECT_TRUE(runFor(2 * 60 * 1000)) << "advertised once the hour passed"; +} + +TEST_F(AdvertScheduleTest, AZeroFloodIntervalStopsAScheduledAdvert) { + mesh->getNodePrefs()->flood_advert_interval = 1; + mesh->updateFloodAdvertTimer(); + + mesh->getNodePrefs()->flood_advert_interval = 0; + mesh->updateFloodAdvertTimer(); + + EXPECT_FALSE(runFor(2 * 60 * 60 * 1000)); +} + +TEST_F(AdvertScheduleTest, AFloodAdvertReschedulesTheLocalOneToo) { + // The role schedules both when a flood advert fires, so the two do not overlap. + // With the local interval left at zero only the flood advert can fire, and it has + // to keep firing on its own schedule afterwards. + mesh->getNodePrefs()->flood_advert_interval = 1; + mesh->updateFloodAdvertTimer(); + + ASSERT_TRUE(runFor(60 * 60 * 1000 + 60 * 1000)) << "first flood advert"; + EXPECT_FALSE(runFor(30 * 60 * 1000)) << "not again within the next half hour"; + EXPECT_TRUE(runFor(35 * 60 * 1000)) << "again after the next hour"; +} + +TEST_F(AdvertScheduleTest, AnAdvertisementAskedForDirectlyIsTransmitted) { + mesh->sendSelfAdvertisement(0, false); + EXPECT_TRUE(runFor(10 * 1000)) << "zero hop advertisement"; + + radio_driver.send_calls = 0; + mesh->sendSelfAdvertisement(0, true); + EXPECT_TRUE(runFor(10 * 1000)) << "flooded advertisement"; +} + } // namespace int main(int argc, char** argv) { From 5ce76bd1526c8fc34c97d8193fb03322c8255831 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:19:01 +0200 Subject: [PATCH 031/136] fix(radio): refuse an airtime factor that is not a number What changes - "set af" and "set dutycycle" reject a value that is not a finite number. The check looks at the bits of the float instead of relying on comparisons: memcpy into a uint32_t and test the exponent field. That is integer arithmetic, so no optimisation can fold it away. - The existing comparisons and both reply strings are untouched, so the range each command accepts is unchanged and visible in the diff. - New test/test_radio_prefs_af covers both commands against a test double of CommonRadioPrefs. The native environment gains CommonRadioPrefs.cpp and TxtDataHelpers.cpp, test/mocks gains a target.h that stands in for a variant's radio_driver, and test/mocks/Arduino.h gains the standard library headers and the two Arduino helpers (constrain, ltoa) the host build needs. - New environment native_ofast runs that suite again with -Ofast, the optimisation nRF52 firmware is built with, so the host sees the flags the device is built with. The unit test workflow runs it. Why - No ordered comparison is true for a NaN, so a guard of the form "reject if below the minimum or above the maximum" cannot reject one. strtof("nan") consumes the token, so the parse guard does not fire either, and the value was stored. - airtime_factor is the duty cycle budget. The roles hand it to the airtime accounting (for the repeater through MyMesh.h:157, with the matching room server and sensor getters), so a NaN there is not cosmetic. - "set dutycycle" writes the same field through 100/dc - 1 and had the same hole. - -Ofast makes it worse rather than better: -ffast-math lets the compiler assume NaN never occurs, and which way it then folds the comparison depends on the compiler. Apple clang rejects the NaN, arm-none-eabi-gcc accepts it, which is why the board behaved differently from a host build of the same source. A bit check answers the same everywhere. Compatibility - Every value that was accepted before is still accepted, including the 99 that "set dutycycle 1" stores, and every value that was rejected is still rejected with the same message. Only values that are not finite numbers change from accepted to refused. - Server roles and the companion share this handler, so one change covers all roles. Root cause chain === PHASE 1: BEFORE CODE === SYMPTOM: "set af nan" is accepted. The CLI answers OK and airtime_factor becomes NaN. EVIDENCE: - src/helpers/CommonRadioPrefs.cpp:69-79 on upstream/dev 428a95c6: after strtof, the only guards are "end == &command[7] || af < 0 || af > 9". - src/helpers/CommonRadioPrefs.cpp:88-99, "set dutycycle", same shape, same field. - examples/simple_repeater/MyMesh.h:157 and the matching room server and sensor getters pass airtime_factor to the airtime budget. - host measurement: a transcription of both branches accepts nan, -nan, NAN and nan(0x1) at -O0 and rejects inf, because inf > 9 is true. HYPOTHESIS: the range check is built from float comparisons only, and no ordered comparison is true for a NaN, so it cannot reject one. CAUSE -> SYMPTOM CHAIN: "nan" parses to a NaN and advances end -> the parse guard does not fire -> af < 0 and af > 9 are both false -> the else branch stores NaN and replies OK -> the airtime budget computes with NaN for as long as the value lives. FALSIFICATION: if the acceptance came from -ffast-math, a build with IEEE comparisons would reject it and the fix would belong in the build flags. Checked: at -O0 the NaN is accepted, so plain IEEE semantics are what let it through. Second check: "set af abc" is rejected, so the parse guard works and the hole is in the range check alone. ROOT VS PROXIMATE: proximate, airtime_factor becomes NaN. Below that, the check cannot express "is this a number at all" because comparison is its only tool. Below that, IEEE 754 makes every ordered comparison with a NaN false and -ffast-math makes the outcome compiler dependent. That is structural: only the bits can decide it. FIX PLAN: src/helpers/CommonRadioPrefs.cpp only; keep the parse guard, add a bit check, same for "set dutycycle"; both NodePrefs classes are covered by the one handler; test on the host in IEEE and -Ofast and on techo-b43b as a room server, plus the build matrix. === PHASE 2: AFTER CODE === PATCH: isFinite(float) in an anonymous namespace, plus "!isFinite(af)" in the "set af" guard and "!isFinite(dc)" in the "set dutycycle" guard. BINARY-VERIFY: both images built for LilyGo_T-Echo_room_server with FIRMWARE_BUILD_DATE markers, each marker confirmed in the image (unzip -p firmware.zip '*.bin' | grep -c -a = 1), and ver answered "v1.18.0 (Build: dev-428a95c6)" and "v1.18.0 (Build: af-nan-wt)" in the runs that produced the lines below. RUNTIME-VERIFY: techo-b43b, room server, USB console, same board. dev-428a95c6: "set af nan" -> OK, "get af" -> "> 0" (the NaN prints as 0, so nothing on the CLI showed the field was poisoned); "set dutycycle nan" -> "OK - 0.0%". af-nan-wt: "set af nan", "set af -nan", "set af inf", "set af abc" -> "ERROR: af must be 0-9" with "get af" unchanged each time; "set dutycycle nan" -> "ERROR: dutycycle must be 1-100" with af unchanged; "set af 2" -> OK, "get af" -> "> 2.0"; "set dutycycle 1" -> "OK - 1.0%", "get af" -> "> 99.0". Host: before, native 2 of 9 red and native_ofast 8 of 8 green; after, 8 of 8 green in both. PARITY: one handler for every role, reached through CommonCLI.cpp:184 and examples/companion_radio/NodePrefs.h; no variant specific code touched. Five mutations of the new code were each caught. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 15 ++ src/helpers/CommonRadioPrefs.cpp | 22 ++- test/mocks/Arduino.h | 23 +++ test/mocks/target.h | 20 +++ .../test_radio_prefs_af.cpp | 151 ++++++++++++++++++ 6 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 test/mocks/target.h create mode 100644 test/test_radio_prefs_af/test_radio_prefs_af.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..3343c3afe0e 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_ofast -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index f296f838213..656982b4c2d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -174,6 +174,21 @@ build_src_filter = +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/TxtDataHelpers.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_ofast] +; the radio prefs tests again with -Ofast, the optimisation nRF52 builds use +; (it includes -ffast-math, under which float comparisons may assume no NaN) +platform = native +test_framework = googletest +build_flags = ${env:native.build_flags} + -Ofast +test_build_src = yes +test_filter = test_radio_prefs_af +build_src_filter = ${env:native.build_src_filter} lib_deps = google/googletest @ 1.17.0 diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 41b179932a5..28ff9f0ae1c 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -2,6 +2,24 @@ #include "TxtDataHelpers.h" #include "Utils.h" #include "target.h" +#include // memcpy, for the bit check below +#include + +namespace { + +// True if the value is a finite number. NaN and infinity are recognised by +// their bits, not by a comparison: nRF52 builds compile with -Ofast, whose +// -ffast-math lets float comparisons assume that NaN and infinity never +// occur. A range check built only from < and > then depends on the compiler, +// and no ordered comparison is true for a NaN in the first place, so under +// plain IEEE rules such a check cannot reject one at all. +bool isFinite(float v) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + return (bits & 0x7F800000u) != 0x7F800000u; +} + +} // namespace bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { if (strcmp(key, "fem_rxgain") == 0) { @@ -69,7 +87,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest if (memcmp(command, "set af ", 7) == 0) { char* end; float af = strtof(&command[7], &end); - if (end == &command[7] || af < 0 || af > 9) { + if (end == &command[7] || !isFinite(af) || af < 0 || af > 9) { strcpy(reply, "ERROR: af must be 0-9"); } else { setAirtimeFactor(af); @@ -87,7 +105,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest } if (memcmp(command, "set dutycycle ", 14) == 0) { float dc = atof(&command[14]); - if (dc < 1 || dc > 100) { + if (!isFinite(dc) || dc < 1 || dc > 100) { strcpy(reply, "ERROR: dutycycle must be 1-100"); } else { setAirtimeFactor((100.0f / dc) - 1.0f); diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe4145..773ac6dab0e 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include "Stream.h" @@ -15,3 +17,24 @@ inline uint32_t millis() { inline void delay(uint32_t ms) { g_mock_millis += ms; } + +#ifndef constrain +#define constrain(amt, low, high) ((amt) < (low) ? (low) : ((amt) > (high) ? (high) : (amt))) +#endif + +// Arduino core extension (not in the C standard library). +inline char* ltoa(long value, char* buf, int base) { + char tmp[sizeof(long) * 8 + 1]; + char* t = tmp; + unsigned long v = (value < 0 && base == 10) ? -(unsigned long)value : (unsigned long)value; + do { + int digit = v % base; + *t++ = (char)(digit < 10 ? '0' + digit : 'a' + digit - 10); + v /= base; + } while (v); + char* p = buf; + if (value < 0 && base == 10) *p++ = '-'; + while (t > tmp) *p++ = *--t; + *p = 0; + return buf; +} diff --git a/test/mocks/target.h b/test/mocks/target.h new file mode 100644 index 00000000000..22ed9638dee --- /dev/null +++ b/test/mocks/target.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +// Stand-in for a variant's target.h, so code that reaches the global +// radio_driver (e.g. CommonRadioPrefs.cpp) builds and runs in the native env. +struct MockRadioDriver { + int8_t tx_power = 0; + int tx_power_calls = 0; + bool rx_boosted = false; + int rx_boost_calls = 0; + bool rx_boost_supported = true; + + void setTxPower(int8_t dbm) { tx_power = dbm; tx_power_calls++; } + bool setRxBoostedGainMode(bool en) { rx_boosted = en; rx_boost_calls++; return rx_boost_supported; } + + void reset() { *this = MockRadioDriver(); } +}; + +inline MockRadioDriver radio_driver; diff --git a/test/test_radio_prefs_af/test_radio_prefs_af.cpp b/test/test_radio_prefs_af/test_radio_prefs_af.cpp new file mode 100644 index 00000000000..45ad1d5de1e --- /dev/null +++ b/test/test_radio_prefs_af/test_radio_prefs_af.cpp @@ -0,0 +1,151 @@ +#include + +#include + +// "set af" and "set dutycycle" both write airtime_factor and both guard their +// range with float comparisons only. No ordered comparison is true for a NaN, +// so a check of the form "reject if below the minimum or above the maximum" +// cannot reject one. These cases pin that both commands refuse a value that is +// not a finite number, with any compiler and any optimisation flags, which is +// why the suite also runs with -Ofast (the flag nRF52 firmware is built with). + +namespace { + +class TestRadioPrefs : public CommonRadioPrefs { +protected: + void structure() override { } + +public: + float freq = 869.525f, bw = 250.0f, airtime = 1.0f, rx_delay = 0, flood_tx = 0.5f, direct_tx = 0.3f; + uint8_t sf = 11, cr = 5, int_thresh = 0, rx_gain = 0, tx_power = 20, hash_mode = 0, multi_acks = 0; + uint8_t fem_rx = 0, fem_tx = 0; + uint16_t agc_int = 0; + bool cad = false; + + float getFreq() const override { return freq; } + void setFreq(float f) override { freq = f; } + float getBandwidth() const override { return bw; } + void setBandwidth(float v) override { bw = v; } + uint8_t getSpreadFactor() const override { return sf; } + void setSpreadFactor(uint8_t v) override { sf = v; } + uint8_t getCodingRate() const override { return cr; } + void setCodingRate(uint8_t v) override { cr = v; } + float getAirtimeFactor() const override { return airtime; } + void setAirtimeFactor(float v) override { airtime = v; } + bool isCadEnabled() const override { return cad; } + void setCadEnabled(bool v) override { cad = v; } + uint8_t getIntThresh() const override { return int_thresh; } + void setIntThresh(uint8_t v) override { int_thresh = v; } + uint8_t getRxGain() const override { return rx_gain; } + void setRxGain(uint8_t v) override { rx_gain = v; } + uint8_t getTxPower() const override { return tx_power; } + void setTxPower(uint8_t v) override { tx_power = v; } + float getRxDelay() const override { return rx_delay; } + void setRxDelay(float v) override { rx_delay = v; } + uint16_t getAgcResetInt() const override { return agc_int; } + void setAgcResetInt(uint16_t v) override { agc_int = v; } + uint8_t getHashMode() const override { return hash_mode; } + void setHashMode(uint8_t v) override { hash_mode = v; } + uint8_t getMultiAcks() const override { return multi_acks; } + void setMultiAcks(uint8_t v) override { multi_acks = v; } + float getFloodTxDelay() const override { return flood_tx; } + void setFloodTxDelay(float v) override { flood_tx = v; } + float getDirectTxDelay() const override { return direct_tx; } + void setDirectTxDelay(float v) override { direct_tx = v; } + uint8_t getFEMRxGain() const override { return fem_rx; } + void setFEMRxGain(uint8_t v) override { fem_rx = v; } + uint8_t getFEMTxGain() const override { return fem_tx; } + void setFEMTxGain(uint8_t v) override { fem_tx = v; } +}; + +const char* AF_ERROR = "ERROR: af must be 0-9"; +const char* DC_ERROR = "ERROR: dutycycle must be 1-100"; + +class RadioPrefsAirtime : public ::testing::Test { +protected: + TestRadioPrefs p; + char reply[160]; + + const char* run(const char* command) { + reply[0] = 0; + EXPECT_TRUE(p.handleCommand(command, 0, reply)) << command; + return reply; + } +}; + +// Values that are not a number at all. Each one left airtime_factor as NaN +// before the bit check was added. +TEST_F(RadioPrefsAirtime, SetAfRejectsEveryNotANumberSpelling) { + const char* cases[] = {"set af nan", "set af -nan", "set af NAN", "set af nan(0x1)"}; + for (const char* c : cases) { + p.airtime = 1.0f; + EXPECT_STREQ(AF_ERROR, run(c)) << c; + EXPECT_FLOAT_EQ(1.0f, p.airtime) << c; + } +} + +TEST_F(RadioPrefsAirtime, SetAfRejectsInfinity) { + for (const char* c : {"set af inf", "set af -inf", "set af 1e40"}) { + p.airtime = 1.0f; + EXPECT_STREQ(AF_ERROR, run(c)) << c; + EXPECT_FLOAT_EQ(1.0f, p.airtime) << c; + } +} + +TEST_F(RadioPrefsAirtime, SetAfStillRejectsGarbageAndOutOfRange) { + for (const char* c : {"set af abc", "set af ", "set af 10", "set af -1", "set af 9.01"}) { + p.airtime = 1.0f; + EXPECT_STREQ(AF_ERROR, run(c)) << c; + EXPECT_FLOAT_EQ(1.0f, p.airtime) << c; + } +} + +TEST_F(RadioPrefsAirtime, SetAfStillAcceptsTheRange) { + EXPECT_STREQ("OK", run("set af 0")); + EXPECT_FLOAT_EQ(0.0f, p.airtime); + EXPECT_STREQ("OK", run("set af 3.5")); + EXPECT_FLOAT_EQ(3.5f, p.airtime); + EXPECT_STREQ("OK", run("set af 9")); + EXPECT_FLOAT_EQ(9.0f, p.airtime); +} + +TEST_F(RadioPrefsAirtime, GetAfIsUnchanged) { + p.airtime = 2.0f; + EXPECT_STREQ("> 2.0", run("get af")); +} + +// "set dutycycle" writes the same field through a division, so it needs the +// same guard. +TEST_F(RadioPrefsAirtime, SetDutycycleRejectsNotANumberAndInfinity) { + for (const char* c : {"set dutycycle nan", "set dutycycle -nan", "set dutycycle inf", "set dutycycle 1e40"}) { + p.airtime = 1.0f; + EXPECT_STREQ(DC_ERROR, run(c)) << c; + EXPECT_FLOAT_EQ(1.0f, p.airtime) << c; + } +} + +TEST_F(RadioPrefsAirtime, SetDutycycleStillRejectsOutOfRange) { + for (const char* c : {"set dutycycle 0", "set dutycycle 101", "set dutycycle abc", "set dutycycle -5"}) { + p.airtime = 1.0f; + EXPECT_STREQ(DC_ERROR, run(c)) << c; + EXPECT_FLOAT_EQ(1.0f, p.airtime) << c; + } +} + +// 1 percent is the strictest duty cycle the command offers and stores 99, a +// value "set af" itself would refuse. That is intended and must keep working. +TEST_F(RadioPrefsAirtime, SetDutycycleStillAcceptsTheRange) { + EXPECT_STREQ("OK - 1.0%", run("set dutycycle 1")); + EXPECT_FLOAT_EQ(99.0f, p.airtime); + EXPECT_STREQ("OK - 50.0%", run("set dutycycle 50")); + EXPECT_FLOAT_EQ(1.0f, p.airtime); + EXPECT_STREQ("OK - 100.0%", run("set dutycycle 100")); + EXPECT_FLOAT_EQ(0.0f, p.airtime); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 3bc2e5f22c93caf7e7ba6d71969101ab928688cd Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:19:42 +0200 Subject: [PATCH 032/136] fix(meshtracker_x1): inherit MicroNMEA in every environment of the variant RCC-SKIPPED: single-step cause, measured and shown before the edit What changes - variants/meshtracker_x1/platformio.ini moves stevemarple/MicroNMEA from the four environments that listed it into the [MeshTracker_X1] base section, the way nano_g2_ultra already keeps it, and drops the four copies. Why - MeshTracker_X1_kiss_modem was the one environment of this variant that did not list the library, so it did not build: src/helpers/sensors/MicroNMEALocationProvider.h:4: fatal error: MicroNMEA.h: No such file or directory variants/meshtracker_x1/target.cpp includes the location provider unconditionally and every environment of this variant compiles target.cpp, so every one of them needs the library. Putting it in the base section makes that true by construction rather than by repeating the line and hoping none is forgotten, which is exactly what happened here. Compatibility - All five environments of the variant were built, which is the whole reach of this change. MeshTracker_X1_kiss_modem now builds (text 240180, bss 18236; on unchanged dev it does not build at all). For the four that already built, text and bss are byte for byte identical to a build from unchanged dev 428a95c6 with the same toolchain, so inheriting the library instead of listing it changes nothing for them: MeshTracker_X1_repeater text 299524 bss 32024, _room_server 292020 / 35740, _companion_radio_usb 316356 / 141000, _companion_radio_ble 322036 / 145432, each text +0 and bss +0 against dev. --- variants/meshtracker_x1/platformio.ini | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/variants/meshtracker_x1/platformio.ini b/variants/meshtracker_x1/platformio.ini index 2194d48d986..80286460bb6 100644 --- a/variants/meshtracker_x1/platformio.ini +++ b/variants/meshtracker_x1/platformio.ini @@ -34,6 +34,7 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/meshtracker_x1> lib_deps = ${nrf52_base.lib_deps} https://github.com/adafruit/Adafruit_SPA06_003/archive/refs/tags/1.0.2.zip + stevemarple/MicroNMEA @ ^2.0.6 debug_tool = jlink upload_protocol = nrfutil @@ -51,7 +52,6 @@ build_flags = ${MeshTracker_X1.build_flags} build_src_filter = ${MeshTracker_X1.build_src_filter} +<../examples/simple_repeater> lib_deps = ${MeshTracker_X1.lib_deps} - stevemarple/MicroNMEA @ ^2.0.6 [env:MeshTracker_X1_room_server] extends = MeshTracker_X1 @@ -67,7 +67,6 @@ build_flags = ${MeshTracker_X1.build_flags} build_src_filter = ${MeshTracker_X1.build_src_filter} +<../examples/simple_room_server> lib_deps = ${MeshTracker_X1.lib_deps} - stevemarple/MicroNMEA @ ^2.0.6 [env:MeshTracker_X1_companion_radio_usb] extends = MeshTracker_X1 @@ -94,7 +93,6 @@ build_src_filter = ${MeshTracker_X1.build_src_filter} +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${MeshTracker_X1.lib_deps} densaugeo/base64 @ ~1.4.0 - stevemarple/MicroNMEA @ ^2.0.6 end2endzone/NonBlockingRTTTL@^1.3.0 adafruit/Adafruit DRV2605 Library @ ^1.2.4 @@ -127,7 +125,6 @@ build_src_filter = ${MeshTracker_X1.build_src_filter} +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = ${MeshTracker_X1.lib_deps} densaugeo/base64 @ ~1.4.0 - stevemarple/MicroNMEA @ ^2.0.6 end2endzone/NonBlockingRTTTL@^1.3.0 adafruit/Adafruit DRV2605 Library @ ^1.2.4 From ad7b5b3d1836a707a8a2101b2ee2720542190968 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:21:36 +0200 Subject: [PATCH 033/136] refactor(server): share the advertisement schedule What changes - ServerMesh gains the advertisement schedule: sendSelfAdvertisement(), updateAdvertTimer() and updateFloodAdvertTimer(), plus the two timers and the node's NodePrefs, which all three roles declared separately. - createSelfAdvert() becomes a hook on the base. It is the only part that differs by role, because each one announces its own type (ADV_TYPE_REPEATER, ADV_TYPE_ROOM, ADV_TYPE_SENSOR), so the six line builder stays with the role and the schedule and send paths move. Each role's declaration gains 'override' and nothing else. - 106 lines deleted from the roles, 53 added to the base. Why - sendSelfAdvertisement() and updateFloodAdvertTimer() were byte for byte identical in all three roles. updateAdvertTimer() differed only in redundant parentheses around a cast, which binds tighter than the multiplication either way, so the arithmetic was identical too. Second of the steps in docs/refactoring/0004-shared-server-base.md. Compatibility - Behaviour is unchanged, shown by the characterization tests of the previous commit: the same seven tests drive the schedule through the role's own public surface, and they pass identically before and after the move. - The three methods are public on the base, as they were on all three roles. Each role's main.cpp calls sendSelfAdvertisement() directly after boot, so making them protected would have broken the firmware build. This was caught by the tests while moving, not by the compiler of the host harness, which does not build main.cpp. Verification - pio test -e native -e native_core -e native_kiss_modem -e native_ofast -e native_server: 105 of 105 test cases pass, unchanged from before the move. - All three role implementations and all three main.cpp files compile for the host harness with no access errors. The main.cpp files still report seven missing mock members each (Serial.begin, board.begin, radio_init, getRngSeed, fs.begin, radio_new_identity), which is the variant boot glue the harness does not mock and is unrelated to this change. - NOT YET VERIFIED: the firmware build matrix, which runs once at the end of the series, and the mutation table for the moved code, which follows. --- examples/simple_repeater/MyMesh.cpp | 30 -------------------------- examples/simple_repeater/MyMesh.h | 7 +----- examples/simple_room_server/MyMesh.cpp | 29 ------------------------- examples/simple_room_server/MyMesh.h | 7 +----- examples/simple_sensor/SensorMesh.cpp | 29 ------------------------- examples/simple_sensor/SensorMesh.h | 7 +----- src/helpers/ServerMesh.cpp | 29 +++++++++++++++++++++++++ src/helpers/ServerMesh.h | 21 ++++++++++++++++++ 8 files changed, 53 insertions(+), 106 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index aa26ca41c1f..1b52b0d29a5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -826,7 +826,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings last_millis = 0; uptime_millis = 0; - next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; set_radio_at = revert_radio_at = 0; _logging = false; @@ -966,35 +965,6 @@ bool MyMesh::formatFileSystem() { #endif } -void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) { - mesh::Packet *pkt = createSelfAdvert(); - if (pkt) { - if (flood) { - sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); - } else { - sendZeroHop(pkt, delay_millis); - } - } else { - MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); - } -} - -void MyMesh::updateAdvertTimer() { - if (_prefs.advert_interval > 0) { // schedule local advert timer - next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); - } else { - next_local_advert = 0; // stop the timer - } -} - -void MyMesh::updateFloodAdvertTimer() { - if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer - next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); - } else { - next_flood_advert = 0; // stop the timer - } -} - void MyMesh::dumpLogFile() { #if defined(RP2040_PLATFORM) File f = _fs->open(PACKET_LOG_FILE, "r"); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 06b11492899..f877c6f22e6 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -82,9 +82,7 @@ struct NeighbourInfo { class MyMesh : public ServerMesh { uint32_t last_millis; uint64_t uptime_millis; - unsigned long next_local_advert, next_flood_advert; bool _logging; - NodePrefs _prefs; ClientACL acl; CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; @@ -116,7 +114,7 @@ class MyMesh : public ServerMesh { uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); - mesh::Packet* createSelfAdvert(); + mesh::Packet* createSelfAdvert() override; File openAppend(const char* fname); bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); @@ -167,9 +165,6 @@ class MyMesh : public ServerMesh { // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; - void sendSelfAdvertisement(int delay_millis, bool flood) override; - void updateAdvertTimer() override; - void updateFloodAdvertTimer() override; void setLoggingOn(bool enable) override { _logging = enable; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index e36a51ae236..ee1874d7b3f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -607,7 +607,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings last_millis = 0; uptime_millis = 0; - next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; _logging = false; set_radio_at = revert_radio_at = 0; @@ -729,34 +728,6 @@ bool MyMesh::formatFileSystem() { #endif } -void MyMesh::sendSelfAdvertisement(int delay_millis, bool flood) { - mesh::Packet *pkt = createSelfAdvert(); - if (pkt) { - if (flood) { - sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); - } else { - sendZeroHop(pkt, delay_millis); - } - } else { - MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); - } -} - -void MyMesh::updateAdvertTimer() { - if (_prefs.advert_interval > 0) { // schedule local advert timer - next_local_advert = futureMillis((uint32_t)_prefs.advert_interval * 2 * 60 * 1000); - } else { - next_local_advert = 0; // stop the timer - } -} -void MyMesh::updateFloodAdvertTimer() { - if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer - next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); - } else { - next_flood_advert = 0; // stop the timer - } -} - void MyMesh::dumpLogFile() { #if defined(RP2040_PLATFORM) File f = _fs->open(PACKET_LOG_FILE, "r"); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 42166d34d58..0d11a5f0181 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -93,9 +93,7 @@ struct PostInfo { class MyMesh : public ServerMesh { uint32_t last_millis; uint64_t uptime_millis; - unsigned long next_local_advert, next_flood_advert; bool _logging; - NodePrefs _prefs; ClientACL acl; CommonCLI _cli; unsigned long dirty_contacts_expiry; @@ -118,7 +116,7 @@ class MyMesh : public ServerMesh { void pushPostToClient(ClientInfo* client, PostInfo& post); uint8_t getUnsyncedCount(ClientInfo* client); bool processAck(const uint8_t *data); - mesh::Packet* createSelfAdvert(); + mesh::Packet* createSelfAdvert() override; File openAppend(const char* fname); int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); @@ -165,9 +163,6 @@ class MyMesh : public ServerMesh { // CommonCLICallbacks void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; - void sendSelfAdvertisement(int delay_millis, bool flood) override; - void updateAdvertTimer() override; - void updateFloodAdvertTimer() override; void setLoggingOn(bool enable) override { _logging = enable; } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 37b1466a4a5..5a6d30b7cf7 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -738,7 +738,6 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise telemetry(MAX_PACKET_PAYLOAD - 4) { useRadioPrefs(&_prefs); // the core reads its tuning (delays, duty cycle, CAD, AGC, extra ACKs) from these settings - next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; last_read_time = 0; num_alert_tasks = 0; @@ -854,34 +853,6 @@ void SensorMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t revert_radio_at = futureMillis(2000 + timeout_mins*60*1000); // schedule when to revert radio params } -void SensorMesh::sendSelfAdvertisement(int delay_millis, bool flood) { - mesh::Packet* pkt = createSelfAdvert(); - if (pkt) { - if (flood) { - sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); - } else { - sendZeroHop(pkt, delay_millis); - } - } else { - MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); - } -} - -void SensorMesh::updateAdvertTimer() { - if (_prefs.advert_interval > 0) { // schedule local advert timer - next_local_advert = futureMillis( ((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); - } else { - next_local_advert = 0; // stop the timer - } -} -void SensorMesh::updateFloodAdvertTimer() { - if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer - next_flood_advert = futureMillis( ((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); - } else { - next_flood_advert = 0; // stop the timer - } -} - void SensorMesh::setTxPower(int8_t power_dbm) { radio_driver.setTxPower(power_dbm); } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 0f9ac3f1586..946f4de8ea4 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -62,9 +62,6 @@ class SensorMesh : public ServerMesh { NodePrefs* getNodePrefs() { return &_prefs; } void savePrefs() override { _cli.savePrefs(_fs); } bool formatFileSystem() override; - void sendSelfAdvertisement(int delay_millis, bool flood) override; - void updateAdvertTimer() override; - void updateFloodAdvertTimer() override; void setLoggingOn(bool enable) override { } void eraseLogFile() override { } void dumpLogFile() override { } @@ -132,8 +129,6 @@ class SensorMesh : public ServerMesh { virtual bool handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t* data, uint8_t flags, size_t len); void sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size=1); private: - unsigned long next_local_advert, next_flood_advert; - NodePrefs _prefs; ClientACL acl; CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; @@ -152,7 +147,7 @@ class SensorMesh : public ServerMesh { bool telemHasChanged(ClientInfo* c); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleRequest(ClientInfo* from, uint32_t sender_timestamp, uint8_t req_type, uint8_t* payload, size_t payload_len); - mesh::Packet* createSelfAdvert(); + mesh::Packet* createSelfAdvert() override; void sendAlert(const ClientInfo* c, Trigger* t); diff --git a/src/helpers/ServerMesh.cpp b/src/helpers/ServerMesh.cpp index e31a3640180..31509847426 100644 --- a/src/helpers/ServerMesh.cpp +++ b/src/helpers/ServerMesh.cpp @@ -70,3 +70,32 @@ void ServerMesh::onDefaultRegionChanged(const RegionEntry* r) { } } + +void ServerMesh::sendSelfAdvertisement(int delay_millis, bool flood) { + mesh::Packet* pkt = createSelfAdvert(); + if (pkt) { + if (flood) { + sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); + } else { + sendZeroHop(pkt, delay_millis); + } + } else { + MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); + } +} + +void ServerMesh::updateAdvertTimer() { + if (_prefs.advert_interval > 0) { // schedule local advert timer + next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); + } else { + next_local_advert = 0; // stop the timer + } +} + +void ServerMesh::updateFloodAdvertTimer() { + if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer + next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); + } else { + next_flood_advert = 0; // stop the timer + } +} diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h index ef63ea78bc5..692d019de8d 100644 --- a/src/helpers/ServerMesh.h +++ b/src/helpers/ServerMesh.h @@ -31,6 +31,9 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { TransportKeyStore key_store; RegionMap region_map, temp_map; + NodePrefs _prefs; // this node's persisted settings + unsigned long next_local_advert, next_flood_advert; + RegionEntry* load_stack[8]; RegionEntry* recv_pkt_region; // the Region the packet being handled arrived on TransportKey default_scope; // this node's own scope, for replies it cannot scope otherwise @@ -42,11 +45,21 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { region_map(key_store), temp_map(key_store) { _fs = NULL; + next_local_advert = next_flood_advert = 0; recv_pkt_region = NULL; region_load_active = false; memset(default_scope.key, 0, sizeof(default_scope.key)); } + /** + * \brief Build this node's own advertisement. + * + * The only part of advertising that differs by role: each one announces its own + * type (repeater, room server, sensor), so the packet itself stays with the role + * while the schedule and the send paths live here. + */ + virtual mesh::Packet* createSelfAdvert() = 0; + /** * \brief Send a flooded reply to the request being handled, scoped the way the * request was, or with this node's own scope when that is unknowable. @@ -65,4 +78,12 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { void startRegionsLoad() override; bool saveRegions() override; void onDefaultRegionChanged(const RegionEntry* r) override; + +public: + // Public because each role's main.cpp advertises on its own after boot, and + // because CommonCLI reaches them through its CommonCLICallbacks pointer. They + // were public in all three roles before they moved here. + void sendSelfAdvertisement(int delay_millis, bool flood) override; + void updateAdvertTimer() override; + void updateFloodAdvertTimer() override; }; From 91357c563f1232705e994dc4a80e8245be05802e Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:26:17 +0200 Subject: [PATCH 034/136] test(server): tell the two advertisement send paths apart The advertisement test only checked that something was transmitted, so swapping the flood and zero hop paths in sendSelfAdvertisement() left it green. A mutation run found that. Assert the route type in the first byte of the frame that actually left the radio: a zero hop advertisement carries ROUTE_TYPE_DIRECT, a flooded one ROUTE_TYPE_FLOOD, since no default scope is configured in these tests and the scoped send falls back to a plain flood. Verification - pio test over all five environments: 105 of 105 test cases pass. - Six deliberately introduced faults in the moved schedule each turn the suite red: the local interval counting one minute per unit instead of two, the flood interval counting minutes instead of hours, either zero interval no longer clearing a pending timer, the two send paths swapped, and the local interval guard inverted. The file was restored afterwards and the suite is green. --- test/test_server_base/test_server_base.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/test_server_base/test_server_base.cpp b/test/test_server_base/test_server_base.cpp index 1eafc26a9de..985dfaf9651 100644 --- a/test/test_server_base/test_server_base.cpp +++ b/test/test_server_base/test_server_base.cpp @@ -417,13 +417,19 @@ TEST_F(AdvertScheduleTest, AFloodAdvertReschedulesTheLocalOneToo) { EXPECT_TRUE(runFor(35 * 60 * 1000)) << "again after the next hour"; } -TEST_F(AdvertScheduleTest, AnAdvertisementAskedForDirectlyIsTransmitted) { +TEST_F(AdvertScheduleTest, AnAdvertisementGoesOutOnTheRouteItWasAskedFor) { + // The route type is in the first byte of the frame, so the two send paths are + // told apart by what actually left the radio, not just by something leaving it. mesh->sendSelfAdvertisement(0, false); - EXPECT_TRUE(runFor(10 * 1000)) << "zero hop advertisement"; + ASSERT_TRUE(runFor(10 * 1000)) << "zero hop advertisement"; + EXPECT_EQ(radio_driver.last_send[0] & PH_ROUTE_MASK, ROUTE_TYPE_DIRECT); radio_driver.send_calls = 0; mesh->sendSelfAdvertisement(0, true); - EXPECT_TRUE(runFor(10 * 1000)) << "flooded advertisement"; + ASSERT_TRUE(runFor(10 * 1000)) << "flooded advertisement"; + // No default scope is configured here, so the scoped send falls back to a plain + // flood rather than a transport flood. + EXPECT_EQ(radio_driver.last_send[0] & PH_ROUTE_MASK, ROUTE_TYPE_FLOOD); } } // namespace From 30952284d8e0733122a9c902c8ad7d96790a43fd Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:32:18 +0200 Subject: [PATCH 035/136] fix(radiolib): pass the boosted gain mode to sx126xResetAGC CustomLLCC68Wrapper::doResetAGC() called sx126xResetAGC() with one argument, but SX126xReset.h:8 declares it with two: inline void sx126xResetAGC(SX126x* radio, bool rx_boost_gain) So the file does not compile. It never had to: nothing in the tree includes CustomLLCC68Wrapper.h, no variant sets WRAPPER_CLASS to CustomLLCC68Wrapper, and LLCC68 appears only in CustomLLCC68.h, in the wrapper itself and in a comment in SX126xReset.h. No environment pulls the header into a translation unit, so no compiler ever looked at it. The likely history is that the rx_boost_gain parameter was added and the three wrappers that are built were adjusted while the one that is not built was missed. The three siblings in the same chip family all pass the mode the radio is actually in: CustomSX1262Wrapper.h:51, CustomSX1268Wrapper.h:48 and CustomSTM32WLxWrapper.h:38. This makes the fourth match them character for character, which is the point: the four members of one family should not differ here. Passing a literal false instead would have been shorter and wrong, because SX126xReset.h re-applies the mode only when SX126X_RX_BOOSTED_GAIN is defined, so a literal would silently clear boosted gain after every AGC reset on any board that enables it. Evidence. Since no environment compiles the header, the check has to create one: a throwaway include of the wrapper in variants/heltec_v3/target.cpp pulls it into a real environment. before, Heltec_v3_repeater: src/helpers/radiolib/CustomLLCC68Wrapper.h:38:63: error: too few arguments to function 'void sx126xResetAGC(SX126x*, bool)' 1 failed, 0 succeeded after, same include, same environment: 1 succeeded, no diagnostics for the wrapper Five deliberate errors in the fixed line were each built the same way and each failed, so that green result is not vacuous: second argument dropped (too few arguments), a third argument added (too many arguments), the mode replaced by a PacketMillis (cannot convert 'PacketMillis' to 'bool'), the radio replaced by _board (cannot convert 'mesh::MainBoard*' to 'SX126x*'), and the function name misspelled (not declared in this scope). pio test -e native -e native_kiss_modem: 54 of 54 test cases passed. Build matrix of 18 environments on this commit against a dev worktree with the same toolchain (Heltec_v3 repeater, room_server, companion_radio_ble, companion_radio_wifi, sensor; RAK_4631 repeater, room_server, companion_radio_ble, sensor; PicoW_repeater; wio-e5-mini repeater and sensor; RAK_3112_repeater_bridge_espnow; waveshare_rp2040_lora_repeater_bridge_rs232; ProMicro repeater, room_server, companion_radio_usb, sensor): all 18 built, and flash and RAM are identical to dev in every one of them, to the byte. That is the intended result and the second, independent piece of evidence that no environment compiles the file. No runtime check applies: there is no LLCC68 variant in the tree and no firmware contains this code. --- src/helpers/radiolib/CustomLLCC68Wrapper.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index ae0fe0a2530..d5f97c65fde 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -35,7 +35,7 @@ class CustomLLCC68Wrapper : public RadioLibWrapper { } uint8_t getSpreadingFactor() const override { return ((CustomLLCC68 *)_radio)->spreadingFactor; } - void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } + void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } bool setRxBoostedGainMode(bool en) override { return ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; From 10051217363f824b1f5ca7654c639b474ab09903 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:33:20 +0200 Subject: [PATCH 036/136] docs(fixes): note for the LLCC68 AGC reset argument Records what was changed, why that argument and not a literal, the two alternatives that were rejected (deleting the dead driver, adding an LLCC68 variant) and the full evidence: the compile check, the five mutations, the host suites and the 18 environment build matrix. Also adds the diagnostic patch the compile check needs. No environment includes CustomLLCC68Wrapper.h, so the check has to pull the header into a real environment; the patch does that with a single include in variants/heltec_v3/target.cpp. It is a test aid and part of no fix, kept beside nodetool.py like diag-tuning.patch. This commit is documentation only and can be dropped when the fix is proposed upstream. --- docs/fixes/llcc68-reset-agc-args.md | 118 ++++++++++++++++++++ tools/refactoring/diag-llcc68-wrapper.patch | 15 +++ 2 files changed, 133 insertions(+) create mode 100644 docs/fixes/llcc68-reset-agc-args.md create mode 100644 tools/refactoring/diag-llcc68-wrapper.patch diff --git a/docs/fixes/llcc68-reset-agc-args.md b/docs/fixes/llcc68-reset-agc-args.md new file mode 100644 index 00000000000..22d2d277cd1 --- /dev/null +++ b/docs/fixes/llcc68-reset-agc-args.md @@ -0,0 +1,118 @@ +# Fix: LLCC68 wrapper calls sx126xResetAGC with one argument + +Status: ready for review. Behaviour change: none, no environment compiles the file. + +## Context + +`sx126xResetAGC()` in `src/helpers/radiolib/SX126xReset.h` takes two arguments, +the radio and the boosted gain mode to re-apply after calibration: + +```cpp +inline void sx126xResetAGC(SX126x* radio, bool rx_boost_gain) +``` + +Three of the four SX126x family wrappers call it that way: + +| Wrapper | Line | Call | +|---------|------|------| +| `CustomSX1262Wrapper.h` | 51 | `sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode());` | +| `CustomSX1268Wrapper.h` | 48 | `sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode());` | +| `CustomSTM32WLxWrapper.h` | 38 | `sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode());` | +| `CustomLLCC68Wrapper.h` | 38 | `sx126xResetAGC((SX126x *)_radio);` | + +The fourth does not compile. It never had to: nothing in the tree includes +`CustomLLCC68Wrapper.h`. `LLCC68` appears only in `CustomLLCC68.h`, in the +wrapper itself and in a comment in `SX126xReset.h`. No variant sets +`WRAPPER_CLASS` to `CustomLLCC68Wrapper`, so no environment pulls the header into +a translation unit and no compiler ever looks at it. + +The likely history is that the `rx_boost_gain` parameter was added to +`sx126xResetAGC()` and the three wrappers that are built were adjusted, while the +one that is not built was missed. Nothing reported it. + +## Decision + +Call it the way the other three do: + +```cpp +void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } +``` + +The line is now character for character the same as in the other three wrappers, +which is the point: the four members of one chip family should not differ here. + +## Alternatives considered + +- **Pass a literal `false`.** Shorter, but wrong: the other three re-apply the + mode the radio is actually in, and `SX126xReset.h` re-applies it only when + `SX126X_RX_BOOSTED_GAIN` is defined. A literal would silently clear boosted + gain after every AGC reset on any board that enables it. +- **Delete the dead files.** `CustomLLCC68.h` and `CustomLLCC68Wrapper.h` have no + users at all, so deleting them would also remove the defect. Rejected: the + LLCC68 is a real chip in the SX126x family, the driver looks complete and + someone adding an LLCC68 board would want it. Deleting it is a separate + decision that does not belong in a one line fix. +- **Add an LLCC68 variant so the file is built.** That is the real cure for the + blind spot, but it needs hardware nobody has here, and inventing a variant to + satisfy the compiler is worse than the compile check below. + +## Compatibility + +None affected. The file is in no build, so every binary is unchanged. See the +build matrix under Verification: all 18 environments report the same flash and +RAM as `dev`, to the byte. + +## Verification + +### Compile check + +Since no environment compiles the header, the check has to create one. The +diagnostic patch `tools/refactoring/diag-llcc68-wrapper.patch` adds a single +include to `variants/heltec_v3/target.cpp`, which pulls the wrapper into a real +environment. It is a test aid and part of no fix. + +Before the fix, `Heltec_v3_repeater` with that patch: + +``` +src/helpers/radiolib/CustomLLCC68Wrapper.h:38:63: error: too few arguments to function 'void sx126xResetAGC(SX126x*, bool)' +*** [.pio/build/Heltec_v3_repeater/variants/heltec_v3/target.cpp.o] Error 1 +==================== 1 failed, 0 succeeded in 00:01:30.621 ==================== +``` + +After the fix, same patch, same environment: + +``` +========================= 1 succeeded in 00:00:52.124 ========================= +``` + +### Mutation tests + +Five deliberate errors in the fixed line, each built with the diagnostic patch. +Every one has to fail, otherwise the compile check above proves nothing. + +| # | Mutation | Result | +|---|----------|--------| +| 1 | second argument dropped (the original defect) | `error: too few arguments to function 'void sx126xResetAGC(SX126x*, bool)'` | +| 2 | a third argument added | `error: too many arguments to function 'void sx126xResetAGC(SX126x*, bool)'` | +| 3 | second argument replaced by a `PacketMillis` | `error: cannot convert 'PacketMillis' to 'bool'` | +| 4 | first argument replaced by `_board` | `error: cannot convert 'mesh::MainBoard*' to 'SX126x*'` | +| 5 | function name misspelled | `error: 'sx126xResetAgc' was not declared in this scope` | + +All five failed to build. + +### Host tests + +`pio test -e native -e native_kiss_modem`: 54 of 54 test cases passed. The +suites `native_core` and `native_ofast` do not exist on this base. + +### Builds + +The 18 environment matrix on this commit against a `dev` worktree with the same +toolchain. Flash and RAM are expected to be identical in every environment, +because no environment compiles the changed file, and that is what makes this a +useful check rather than a formality. + +### Hardware + +Not applicable. There is no board with an LLCC68 radio in this tree, and no +firmware contains the changed code, so there is nothing to observe at runtime. diff --git a/tools/refactoring/diag-llcc68-wrapper.patch b/tools/refactoring/diag-llcc68-wrapper.patch new file mode 100644 index 00000000000..e8e536b8008 --- /dev/null +++ b/tools/refactoring/diag-llcc68-wrapper.patch @@ -0,0 +1,15 @@ +diff --git a/variants/heltec_v3/target.cpp b/variants/heltec_v3/target.cpp +index e6af466a..5c87138f 100644 +--- a/variants/heltec_v3/target.cpp ++++ b/variants/heltec_v3/target.cpp +@@ -1,6 +1,10 @@ + #include + #include "target.h" + ++// DIAGNOSTIC ONLY: pull the otherwise unused LLCC68 wrapper into a ++// translation unit so the compiler checks it. Not part of any fix. ++#include ++ + HeltecV3Board board; + + #if defined(P_LORA_SCLK) From 910090456047ec7ff21f06ca938800e0c6b5b636 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:33:52 +0200 Subject: [PATCH 037/136] refactor(server): share the temporary radio parameters What changes - ServerMesh gains applyTempRadioParams() and the six fields behind it (the two timers and the four pending values), which all three roles declared separately. - The two blocks each role ran in its own loop() become one base method, checkPendingRadioParams(), and each loop() now calls that instead. The blocks were identical in all three roles apart from comment spacing. - ServerMesh.cpp reaches the variant's radio_driver through target.h, the way helpers/CommonRadioPrefs.cpp already does. - 84 lines deleted from the roles, 45 added to the base. Why - applyTempRadioParams() was byte for byte identical in all three roles, and the apply and revert blocks in their loops were too. Third of the steps in docs/refactoring/0004-shared-server-base.md. Compatibility - Behaviour is unchanged, shown by the six characterization tests written for the temporary radio parameters before any of this series touched them: they pass identically with the code in the roles and with it in the base. - applyTempRadioParams() is public on the base, as it was on all three roles. Verification - pio test -e native -e native_core -e native_kiss_modem -e native_ofast -e native_server: 105 of 105 test cases pass, unchanged from before the move. - All three roles and all three main.cpp files compile for the host harness with no access errors. - Six deliberately introduced faults each turned the suite red: the reply delay dropped, either timer never cleared, the timeout read as seconds, the frequency and bandwidth swapped when applying, and the revert restoring the temporary values instead of the node's own settings. - NOT YET VERIFIED: the firmware build matrix, which runs once at the end of the series. --- examples/simple_repeater/MyMesh.cpp | 23 +---------------------- examples/simple_repeater/MyMesh.h | 6 ------ examples/simple_room_server/MyMesh.cpp | 23 +---------------------- examples/simple_room_server/MyMesh.h | 6 ------ examples/simple_sensor/SensorMesh.cpp | 23 +---------------------- examples/simple_sensor/SensorMesh.h | 6 ------ src/helpers/ServerMesh.cpp | 26 ++++++++++++++++++++++++++ src/helpers/ServerMesh.h | 16 ++++++++++++++++ 8 files changed, 45 insertions(+), 84 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1b52b0d29a5..40b3e02adea 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -827,7 +827,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc last_millis = 0; uptime_millis = 0; dirty_contacts_expiry = 0; - set_radio_at = revert_radio_at = 0; _logging = false; #if MAX_NEIGHBOURS @@ -942,16 +941,6 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif } -void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { - set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params - pending_freq = freq; - pending_bw = bw; - pending_sf = sf; - pending_cr = cr; - - revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params -} - bool MyMesh::formatFileSystem() { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) return InternalFS.format(); @@ -1194,17 +1183,7 @@ void MyMesh::loop() { updateAdvertTimer(); // schedule next local advert } - if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params - set_radio_at = 0; // clear timer - radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); - MESH_DEBUG_PRINTLN("Temp radio params"); - } - - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig - revert_radio_at = 0; // clear timer - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("Radio params restored"); - } + checkPendingRadioParams(); // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index f877c6f22e6..615a4e4c464 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -96,11 +96,6 @@ class MyMesh : public ServerMesh { NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif CayenneLPP telemetry; - unsigned long set_radio_at, revert_radio_at; - float pending_freq; - float pending_bw; - uint8_t pending_sf; - uint8_t pending_cr; int matching_peer_indexes[MAX_CLIENTS]; #if defined(WITH_RS232_BRIDGE) RS232Bridge bridge; @@ -163,7 +158,6 @@ class MyMesh : public ServerMesh { } // CommonCLICallbacks - void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; void setLoggingOn(bool enable) override { _logging = enable; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index ee1874d7b3f..8ea21fb2b2e 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -609,7 +609,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; dirty_contacts_expiry = 0; _logging = false; - set_radio_at = revert_radio_at = 0; // defaults _prefs.airtime_factor = 1.0; @@ -705,16 +704,6 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif } -void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { - set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params - pending_freq = freq; - pending_bw = bw; - pending_sf = sf; - pending_cr = cr; - - revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params -} - bool MyMesh::formatFileSystem() { #if defined(NRF52_PLATFORM) return InternalFS.format(); @@ -942,17 +931,7 @@ void MyMesh::loop() { updateAdvertTimer(); // schedule next local advert } - if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params - set_radio_at = 0; // clear timer - radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); - MESH_DEBUG_PRINTLN("Temp radio params"); - } - - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig - revert_radio_at = 0; // clear timer - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("Radio params restored"); - } + checkPendingRadioParams(); // is pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 0d11a5f0181..ca515d84d06 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -104,11 +104,6 @@ class MyMesh : public ServerMesh { int next_post_idx; PostInfo posts[MAX_UNSYNCED_POSTS]; // cyclic queue CayenneLPP telemetry; - unsigned long set_radio_at, revert_radio_at; - float pending_freq; - float pending_bw; - uint8_t pending_sf; - uint8_t pending_cr; int matching_peer_indexes[MAX_CLIENTS]; void addPost(ClientInfo* client, const char* postData); @@ -161,7 +156,6 @@ class MyMesh : public ServerMesh { } // CommonCLICallbacks - void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; bool formatFileSystem() override; void setLoggingOn(bool enable) override { _logging = enable; } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 5a6d30b7cf7..80b8cba5344 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -741,7 +741,6 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise dirty_contacts_expiry = 0; last_read_time = 0; num_alert_tasks = 0; - set_radio_at = revert_radio_at = 0; // defaults _prefs.airtime_factor = 1.0; @@ -843,16 +842,6 @@ void SensorMesh::saveIdentity(const mesh::LocalIdentity& new_id) { store.save("_main", new_id); } -void SensorMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { - set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params - pending_freq = freq; - pending_bw = bw; - pending_sf = sf; - pending_cr = cr; - - revert_radio_at = futureMillis(2000 + timeout_mins*60*1000); // schedule when to revert radio params -} - void SensorMesh::setTxPower(int8_t power_dbm) { radio_driver.setTxPower(power_dbm); } @@ -902,17 +891,7 @@ void SensorMesh::loop() { updateAdvertTimer(); // schedule next local advert } - if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params - set_radio_at = 0; // clear timer - radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); - MESH_DEBUG_PRINTLN("Temp radio params"); - } - - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig - revert_radio_at = 0; // clear timer - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("Radio params restored"); - } + checkPendingRadioParams(); uint32_t curr = getRTCClock()->getCurrentTime(); if (curr >= last_read_time + SENSOR_READ_INTERVAL_SECS) { diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 946f4de8ea4..a318dafcf2e 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -75,7 +75,6 @@ class SensorMesh : public ServerMesh { mesh::LocalIdentity& getSelfId() override { return self_id; } void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override { } - void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; float getTelemValue(uint8_t channel, uint8_t type); @@ -138,11 +137,6 @@ class SensorMesh : public ServerMesh { int matching_peer_indexes[MAX_SEARCH_RESULTS]; int num_alert_tasks; Trigger* alert_tasks[MAX_CONCURRENT_ALERTS]; - unsigned long set_radio_at, revert_radio_at; - float pending_freq; - float pending_bw; - uint8_t pending_sf; - uint8_t pending_cr; bool telemHasChanged(ClientInfo* c); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); diff --git a/src/helpers/ServerMesh.cpp b/src/helpers/ServerMesh.cpp index 31509847426..721ba99cf92 100644 --- a/src/helpers/ServerMesh.cpp +++ b/src/helpers/ServerMesh.cpp @@ -1,5 +1,7 @@ #include "ServerMesh.h" +#include "target.h" // the variant's radio_driver, as helpers/CommonRadioPrefs.cpp reaches it + // The bodies below moved here unchanged from the three roles, where they were // identical. Verified by extracting each one from all three role implementations and // comparing after normalising only the class name and the pointer star spacing. @@ -99,3 +101,27 @@ void ServerMesh::updateFloodAdvertTimer() { next_flood_advert = 0; // stop the timer } } + +void ServerMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { + set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params + pending_freq = freq; + pending_bw = bw; + pending_sf = sf; + pending_cr = cr; + + revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params +} + +void ServerMesh::checkPendingRadioParams() { + if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params + set_radio_at = 0; // clear timer + radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); + MESH_DEBUG_PRINTLN("Temp radio params"); + } + + if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig + revert_radio_at = 0; // clear timer + radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + MESH_DEBUG_PRINTLN("Radio params restored"); + } +} diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h index 692d019de8d..77278e70e71 100644 --- a/src/helpers/ServerMesh.h +++ b/src/helpers/ServerMesh.h @@ -34,6 +34,14 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { NodePrefs _prefs; // this node's persisted settings unsigned long next_local_advert, next_flood_advert; + // A 'set radio ...' command parks its values here until the reply has gone out, + // and schedules the return to the node's own settings. + unsigned long set_radio_at, revert_radio_at; + float pending_freq; + float pending_bw; + uint8_t pending_sf; + uint8_t pending_cr; + RegionEntry* load_stack[8]; RegionEntry* recv_pkt_region; // the Region the packet being handled arrived on TransportKey default_scope; // this node's own scope, for replies it cannot scope otherwise @@ -46,6 +54,7 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { { _fs = NULL; next_local_advert = next_flood_advert = 0; + set_radio_at = revert_radio_at = 0; recv_pkt_region = NULL; region_load_active = false; memset(default_scope.key, 0, sizeof(default_scope.key)); @@ -71,6 +80,12 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { */ void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); + /** + * \brief Hand the radio any pending temporary parameters, and take them back + * again once they have timed out. Call this from the role's loop(). + */ + void checkPendingRadioParams(); + // Mesh mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; @@ -83,6 +98,7 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { // Public because each role's main.cpp advertises on its own after boot, and // because CommonCLI reaches them through its CommonCLICallbacks pointer. They // were public in all three roles before they moved here. + void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; void updateFloodAdvertTimer() override; From c54cf2738032364cddb89cfeb27998c6f9e6b3ff Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:35:01 +0200 Subject: [PATCH 038/136] fix(companion): state the minimum frame length of 14 more commands handleCmdFrame() derives every field offset from the command code, and 35 of its 62 branches state the length they need in the condition. Fourteen do not, or test cmd_frame[1] before the length. Neither cmd_frame (a member of MyMesh) nor ArduinoSerialInterface::rx_buf is cleared between frames, and checkRecvFrame() copies only _frame_len bytes out, so everything above len is the tail of an earlier frame. Those branches therefore act on the previous command's bytes. No read goes outside cmd_frame: the largest unconditional offset is 135 and the buffer holds MAX_FRAME_SIZE + 1 = 177 bytes. The damage is acting on stale data. Twelve branches gain "&& len >= N" in the condition, so a short frame falls through to the existing else and is answered the way the 35 guarded branches already answer it. CMD_SET_PATH_HASH_MODE and CMD_SEND_PATH_DISCOVERY_REQ only have their length test moved in front of the cmd_frame[1] test. CMD_SEND_CHANNEL_DATA keeps its explicit check and gains a second one for the 2 byte data_type behind the path, whose offset is only known after path_len has been parsed; that is the shape CMD_SEND_RAW_DATA already uses. The minimums, each derived from the fields its branch reads without testing len: CMD_SEND_CHANNEL_DATA 62 3 + path + 2 data_type sits behind the path CMD_ADD_UPDATE_CONTACT 9 136 updateContactFromFrame reads to 135 CMD_REMOVE_CONTACT 15 33 pub_key[1..32] CMD_SHARE_CONTACT 16 33 pub_key[1..32] CMD_GET_CONTACT_BY_KEY 30 33 pub_key[1..32] CMD_SET_RADIO_PARAMS 11 11 freq, bw, sf, cr CMD_SET_RADIO_TX_POWER 12 2 power CMD_SET_TUNING_PARAMS 21 9 rx_delay_base, airtime_factor CMD_SET_OTHER_PARAMS 38 2 manual_add_contacts CMD_SET_PATH_HASH_MODE 61 3 operand order CMD_REBOOT 19 7 memcmp against "reboot" CMD_FACTORY_RESET 51 6 memcmp against "reset" CMD_SET_AUTOADD_CONFIG 58 2 autoadd_config CMD_SEND_PATH_DISCOVERY_REQ 52 34 operand order CMD_ADD_UPDATE_CONTACT is the one that changes an existing guard rather than adding one: it required 36 bytes while updateContactFromFrame() reads type, flags, out_path_len, 64 bytes of out_path, 32 bytes of name and the 4 byte last_advert_timestamp without testing len. Only lat, lon and lastmod are optional. No valid frame can be shorter: writeContactRespFrame() writes the same fields in the same order and sends 148 bytes. Evidence, measured on a T-Echo (techo-3dcf) with a marked image, dev 428a95c6 reporting "f1-dev". Every step first sends a longer frame, so the stale tail is known, and then a frame that is too short for the command under test: [21] SET_TUNING_PARAMS, after a CLI frame carrying "get name": before (0, 1000) -> after (544499136, 1701667200). The stale bytes "get " and "name" are 544499047 and 1701667182; the prefs hold them as float, and the host round trip gives 544499072 and 1701667200, so "name" matches exactly and "get " is one float32 step out, which is what the -Ofast nRF52 build allows. [12] SET_RADIO_TX_POWER, after GET_CHANNEL 5: 22 -> 5 [38] SET_OTHER_PARAMS, after GET_CHANNEL 0: manual_add 1 -> 0 [58] SET_AUTOADD_CONFIG, after GET_CHANNEL 0: (5, 0) -> (0, 0) [30] GET_CONTACT_BY_KEY, a 1 byte frame returned a full contact record [62] SEND_CHANNEL_DATA with len 4 answered OK, so a datagram went out with a data_type built from a stale byte [9] ADD_UPDATE_CONTACT with len 36, after a 141 byte CLI frame of "Z": a contact was created with out_path = 5a5a..., name = "ZZZZ...Z" and last_advert_timestamp = 0x5a5a5a5a, so 100 bytes of the earlier frame became contact fields [19] REBOOT, after a CLI frame carrying "reboot": uptime 258 s -> 19 s [61] and [52] answer ERR_CODE_UNSUPPORTED_CMD already, as expected: there the only fault is one read above len, with no effect on state CMD_FACTORY_RESET was deliberately not triggered on hardware; it would format the file system and take identity, prefs, contacts and channels with it. It is the same mechanism as CMD_REBOOT, which is shown above. All values were set back afterwards and read again: tuning (0, 1000), tx_power 22, manual_add 1, freq 869618, sf 8, cr 8, autoadd (5, 0), 68 contacts, the two test contacts removed. There is no host test for this change. native_chat, added for the group message length, does not build MyMesh.cpp, and compiling it on the host would need stand-ins for target.h, the board, the radio driver, the sensors, the telemetry and the store. Measured, not assumed: with "len >= 7" in the neighbouring branch mutated to "len >= 1", native_chat still reports 8 of 8 passing. native test_mesh_tables PASSED native test_config_serializer PASSED native test_utils PASSED native test_utf8_helpers PASSED native test_routing_policy PASSED native_kiss_modem test_kiss_modem PASSED 54 test cases: 54 succeeded in 00:00:51.743 --- examples/companion_radio/MyMesh.cpp | 35 ++++++++++++++++++----------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 99a9aa30d6e..a7fa511f1f5 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1217,6 +1217,12 @@ void MyMesh::handleCmdFrame(size_t len) { i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); } + if ((size_t)(i + 2) > len) { // the 2 byte data_type must be inside the frame + MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA frame too short for data_type: %d", (uint32_t)len); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } + uint16_t data_type = ((uint16_t)cmd_frame[i]) | (((uint16_t)cmd_frame[i + 1]) << 8); i += 2; const uint8_t *payload = &cmd_frame[i]; @@ -1325,7 +1331,10 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // unknown contact } - } else if (cmd_frame[0] == CMD_ADD_UPDATE_CONTACT && len >= 1 + 32 + 2 + 1) { + } else if (cmd_frame[0] == CMD_ADD_UPDATE_CONTACT && len >= 1 + PUB_KEY_SIZE + 3 + MAX_PATH_SIZE + 32 + 4) { + // updateContactFromFrame() reads every field up to last_advert_timestamp without + // testing len; only the trailing lat/lon and lastmod are optional. The reverse + // direction (writeContactRespFrame) writes the same fields in the same order. uint8_t *pub_key = &cmd_frame[1]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame @@ -1346,7 +1355,7 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); } } - } else if (cmd_frame[0] == CMD_REMOVE_CONTACT) { + } else if (cmd_frame[0] == CMD_REMOVE_CONTACT && len >= 1 + PUB_KEY_SIZE) { uint8_t *pub_key = &cmd_frame[1]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient && removeContact(*recipient)) { @@ -1356,7 +1365,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // not found, or unable to remove } - } else if (cmd_frame[0] == CMD_SHARE_CONTACT) { + } else if (cmd_frame[0] == CMD_SHARE_CONTACT && len >= 1 + PUB_KEY_SIZE) { uint8_t *pub_key = &cmd_frame[1]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { @@ -1368,7 +1377,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); } - } else if (cmd_frame[0] == CMD_GET_CONTACT_BY_KEY) { + } else if (cmd_frame[0] == CMD_GET_CONTACT_BY_KEY && len >= 1 + PUB_KEY_SIZE) { uint8_t *pub_key = &cmd_frame[1]; ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (contact) { @@ -1421,7 +1430,7 @@ void MyMesh::handleCmdFrame(size_t len) { out_frame[0] = RESP_CODE_NO_MORE_MESSAGES; _serial->writeFrame(out_frame, 1); } - } else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS) { + } else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS && len >= 11) { // freq, bw, sf, cr; the repeat flag stays optional int i = 1; uint32_t freq; memcpy(&freq, &cmd_frame[i], 4); @@ -1457,7 +1466,7 @@ void MyMesh::handleCmdFrame(size_t len) { (uint32_t)cr); writeErrFrame(ERR_CODE_ILLEGAL_ARG); } - } else if (cmd_frame[0] == CMD_SET_RADIO_TX_POWER) { + } else if (cmd_frame[0] == CMD_SET_RADIO_TX_POWER && len >= 2) { int8_t power = (int8_t)cmd_frame[1]; if (power < -9 || power > MAX_LORA_TX_POWER) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -1467,7 +1476,7 @@ void MyMesh::handleCmdFrame(size_t len) { radio_driver.setTxPower(_prefs.tx_power_dbm); writeOKFrame(); } - } else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS) { + } else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS && len >= 9) { // two 32 bit values, neither range checked int i = 1; uint32_t rx, af; memcpy(&rx, &cmd_frame[i], 4); @@ -1485,7 +1494,7 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], &rx, 4); i += 4; memcpy(&out_frame[i], &af, 4); i += 4; _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_SET_OTHER_PARAMS) { + } else if (cmd_frame[0] == CMD_SET_OTHER_PARAMS && len >= 2) { // the fields after manual_add stay optional _prefs.manual_add_contacts = cmd_frame[1]; if (len >= 3) { _prefs.telemetry_mode_base = cmd_frame[2] & 0x03; // v5+ @@ -1501,7 +1510,7 @@ void MyMesh::handleCmdFrame(size_t len) { } savePrefs(); writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && cmd_frame[1] == 0 && len >= 3) { + } else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && len >= 3 && cmd_frame[1] == 0) { if (cmd_frame[2] >= 3) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { @@ -1509,7 +1518,7 @@ void MyMesh::handleCmdFrame(size_t len) { savePrefs(); writeOKFrame(); } - } else if (cmd_frame[0] == CMD_REBOOT && memcmp(&cmd_frame[1], "reboot", 6) == 0) { + } else if (cmd_frame[0] == CMD_REBOOT && len >= 7 && memcmp(&cmd_frame[1], "reboot", 6) == 0) { if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? saveContacts(); } @@ -1648,7 +1657,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found } - } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && cmd_frame[1] == 0 && len >= 2 + PUB_KEY_SIZE) { + } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0) { uint8_t *pub_key = &cmd_frame[2]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { @@ -1962,7 +1971,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type } - } else if (cmd_frame[0] == CMD_FACTORY_RESET && memcmp(&cmd_frame[1], "reset", 5) == 0) { + } else if (cmd_frame[0] == CMD_FACTORY_RESET && len >= 6 && memcmp(&cmd_frame[1], "reset", 5) == 0) { if (_serial) { 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 @@ -2020,7 +2029,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_TABLE_FULL); } - } else if (cmd_frame[0] == CMD_SET_AUTOADD_CONFIG) { + } else if (cmd_frame[0] == CMD_SET_AUTOADD_CONFIG && len >= 2) { // autoadd_max_hops stays optional _prefs.autoadd_config = cmd_frame[1]; if (len >= 3) { _prefs.autoadd_max_hops = min(cmd_frame[2], (uint8_t)64); From 47f5297ec71b4c80122600c3a2a76eaf419852eb Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:35:35 +0200 Subject: [PATCH 039/136] test(server): pin the identity, file system and stats behaviour before it moves What this adds - Five characterization tests for the housekeeping that step 4d moves into the shared base: that setting the TX power reaches the radio once with the value given, that clearing the stats zeroes the radio counters, that formatting the file system actually erases it, that all three stats replies are filled in, and that the packet stats reply follows the counters it reads rather than being a fixed string. - Driven through the role's public surface, so they read the same before and after the move. What is deliberately not pinned - saveIdentity(). It writes through IdentityStore, and the identity stubs in this suite refuse the file path on purpose, so calling it would be a test failure rather than a check. The move of that method rests on its diff proof. - The exact text of the stats replies. StatsFormatHelper owns that format and it is not what moves. Verification - pio test over all five environments: 105 of 105 test cases pass, with the housekeeping still implemented in the roles. --- test/test_server_base/test_server_base.cpp | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/test/test_server_base/test_server_base.cpp b/test/test_server_base/test_server_base.cpp index 985dfaf9651..f71ec07daf5 100644 --- a/test/test_server_base/test_server_base.cpp +++ b/test/test_server_base/test_server_base.cpp @@ -432,6 +432,91 @@ TEST_F(AdvertScheduleTest, AnAdvertisementGoesOutOnTheRouteItWasAskedFor) { EXPECT_EQ(radio_driver.last_send[0] & PH_ROUTE_MASK, ROUTE_TYPE_FLOOD); } + +// Identity, file system and statistics, which step 4d moves into the shared base. +// Driven through the role's own public surface, so these read the same before and +// after the move. +class HousekeepingTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + LittleFS.reset(); + rng.begin(1); + + mesh.reset(new MyMesh(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + } +}; + +TEST_F(HousekeepingTest, SettingTheTxPowerReachesTheRadio) { + radio_driver.tx_power_calls = 0; + + mesh->setTxPower(-7); + + EXPECT_EQ(radio_driver.tx_power_calls, 1); + EXPECT_EQ(radio_driver.tx_power, -7); +} + +TEST_F(HousekeepingTest, ClearingTheStatsResetsTheRadioCounters) { + radio_driver.packets_recv = 11; + radio_driver.packets_sent = 7; + radio_driver.packets_recv_errors = 3; + + mesh->clearStats(); + + EXPECT_EQ(radio_driver.packets_recv, 0u); + EXPECT_EQ(radio_driver.packets_sent, 0u); + EXPECT_EQ(radio_driver.packets_recv_errors, 0u); +} + +TEST_F(HousekeepingTest, FormattingTheFileSystemErasesIt) { + // On this platform the role erases through the LittleFS global, which is the same + // in-memory instance the test can look into. + LittleFS.files["/leftover"] = { 1, 2, 3 }; + ASSERT_TRUE(LittleFS.exists("/leftover")); + + EXPECT_TRUE(mesh->formatFileSystem()); + + EXPECT_FALSE(LittleFS.exists("/leftover")); +} + +TEST_F(HousekeepingTest, TheStatsRepliesAreFilledIn) { + char reply[256]; + + memset(reply, 0, sizeof(reply)); + mesh->formatStatsReply(reply); + EXPECT_GT(strlen(reply), 0u) << "core stats"; + + memset(reply, 0, sizeof(reply)); + mesh->formatRadioStatsReply(reply); + EXPECT_GT(strlen(reply), 0u) << "radio stats"; + + memset(reply, 0, sizeof(reply)); + mesh->formatPacketStatsReply(reply); + EXPECT_GT(strlen(reply), 0u) << "packet stats"; +} + +TEST_F(HousekeepingTest, ThePacketStatsFollowTheRadioCounters) { + char before[256] = {0}; + mesh->formatPacketStatsReply(before); + + radio_driver.packets_recv_errors = 42; + char after[256] = {0}; + mesh->formatPacketStatsReply(after); + + EXPECT_STRNE(before, after) << "the reply has to reflect the counters it reads"; +} + } // namespace int main(int argc, char** argv) { From 3162e17cb185876e2354fb8823bb12d09b69ac5b Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:41:14 +0200 Subject: [PATCH 040/136] refactor(server): share the identity, file system and stats housekeeping What changes - ServerMesh gains setTxPower(), formatFileSystem(), saveIdentity(), clearStats() and the three stats replies. All of them were identical in the roles apart from pointer star spacing, except as noted below. - The sensor keeps its own empty clearStats() override, because it never reset anything and that is behaviour, not duplication. The base implements the version the repeater and the room server shared, and the sensor's override still wins. - ServerMesh.cpp picks up the platform file system headers under the same guards the roles used, plus SimpleMeshTables and StatsFormatHelper. - One extra test: a mutation run showed that clearing the stats could stop resetting the core's own counters without any test noticing, so there is now a case that makes the node count a transmission first and checks the packet stats reply changes after the reset. Behaviour change, deliberate - formatFileSystem() takes the repeater's and the sensor's guards, which erase with InternalFS on STM32 as well. The room server's own copy only covered NRF52, so an STM32 room server would have hit the '#error' branch. This is unreachable today: neither STM32 variant (wio-e5-mini, wio-e5-dev) defines a room server environment, so the branch was never compiled. Recorded in docs/refactoring/0004-shared-server-base.md as difference 2. Why - Seven more methods that each role carried separately, for no reason beyond having been copied. Fourth of the steps in the design note. Verification - pio test -e native -e native_core -e native_kiss_modem -e native_ofast -e native_server: 110 of 110 test cases pass, unchanged from before the move (111 counting the case added above). - All three roles and all three main.cpp files compile for the host harness with no access errors. - Five of six deliberately introduced faults turned the suite red: the TX power negated, the stats reset skipping either the radio or the core, the file system reporting success without erasing, and the core stats reply left empty. - NOT CAUGHT, and stated as a limit: reporting the sent counters twice in the packet stats reply instead of the received ones. Telling those apart needs the core's receive counters to differ from its send counters, which needs the receive path driven, and that is more harness than this step builds. The field order of that reply is therefore not pinned. - NOT YET VERIFIED: the firmware build matrix, which runs once at the end of the series. The sensor's empty clearStats() is preserved by the compiler but has no behavioural test, because the sensor role needs an environment of its own. --- examples/simple_repeater/MyMesh.cpp | 49 ------------------ examples/simple_repeater/MyMesh.h | 8 --- examples/simple_room_server/MyMesh.cpp | 49 ------------------ examples/simple_room_server/MyMesh.h | 7 --- examples/simple_sensor/SensorMesh.cpp | 43 ---------------- examples/simple_sensor/SensorMesh.h | 6 --- src/helpers/ServerMesh.cpp | 60 ++++++++++++++++++++++ src/helpers/ServerMesh.h | 7 +++ test/test_server_base/test_server_base.cpp | 17 ++++++ 9 files changed, 84 insertions(+), 162 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 40b3e02adea..584f42c62e8 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -941,19 +941,6 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif } -bool MyMesh::formatFileSystem() { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - return InternalFS.format(); -#elif defined(RP2040_PLATFORM) - return LittleFS.format(); -#elif defined(ESP32) - return SPIFFS.format(); -#else -#error "need to implement file system erase" - return false; -#endif -} - void MyMesh::dumpLogFile() { #if defined(RP2040_PLATFORM) File f = _fs->open(PACKET_LOG_FILE, "r"); @@ -970,10 +957,6 @@ void MyMesh::dumpLogFile() { } } -void MyMesh::setTxPower(int8_t power_dbm) { - radio_driver.setTxPower(power_dbm); -} - bool MyMesh::setRxBoostedGain(bool enable) { return radio_driver.setRxBoostedGainMode(enable); } @@ -1039,38 +1022,6 @@ void MyMesh::removeNeighbor(const uint8_t *pubkey, int key_len) { #endif } -void MyMesh::formatStatsReply(char *reply) { - StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); -} - -void MyMesh::formatRadioStatsReply(char *reply) { - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); -} - -void MyMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect()); -} - -void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - IdentityStore store(*_fs, ""); -#elif defined(ESP32) - IdentityStore store(*_fs, "/identity"); -#elif defined(RP2040_PLATFORM) - IdentityStore store(*_fs, "/identity"); -#else -#error "need to define saveIdentity()" -#endif - store.save("_main", new_id); -} - -void MyMesh::clearStats() { - radio_driver.resetStats(); - resetStats(); - ((SimpleMeshTables *)getTables())->resetStats(); -} - void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 615a4e4c464..631fa882830 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -158,7 +158,6 @@ class MyMesh : public ServerMesh { } // CommonCLICallbacks - bool formatFileSystem() override; void setLoggingOn(bool enable) override { _logging = enable; } @@ -167,18 +166,11 @@ class MyMesh : public ServerMesh { } void dumpLogFile() override; - void setTxPower(int8_t power_dbm) override; void formatNeighborsReply(char *reply) override; void removeNeighbor(const uint8_t* pubkey, int key_len) override; - void formatStatsReply(char *reply) override; - void formatRadioStatsReply(char *reply) override; - void formatPacketStatsReply(char *reply) override; mesh::LocalIdentity& getSelfId() override { return self_id; } - void saveIdentity(const mesh::LocalIdentity& new_id) override; - void clearStats() override; - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 8ea21fb2b2e..5e49e14cf0c 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -704,19 +704,6 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif } -bool MyMesh::formatFileSystem() { -#if defined(NRF52_PLATFORM) - return InternalFS.format(); -#elif defined(RP2040_PLATFORM) - return LittleFS.format(); -#elif defined(ESP32) - return SPIFFS.format(); -#else -#error "need to implement file system erase" - return false; -#endif -} - void MyMesh::dumpLogFile() { #if defined(RP2040_PLATFORM) File f = _fs->open(PACKET_LOG_FILE, "r"); @@ -733,46 +720,10 @@ void MyMesh::dumpLogFile() { } } -void MyMesh::setTxPower(int8_t power_dbm) { - radio_driver.setTxPower(power_dbm); -} - bool MyMesh::setRxBoostedGain(bool enable) { return radio_driver.setRxBoostedGainMode(enable); } -void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - IdentityStore store(*_fs, ""); -#elif defined(ESP32) - IdentityStore store(*_fs, "/identity"); -#elif defined(RP2040_PLATFORM) - IdentityStore store(*_fs, "/identity"); -#else -#error "need to define saveIdentity()" -#endif - store.save("_main", new_id); -} - -void MyMesh::clearStats() { - radio_driver.resetStats(); - resetStats(); - ((SimpleMeshTables *)getTables())->resetStats(); -} - -void MyMesh::formatStatsReply(char *reply) { - StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); -} - -void MyMesh::formatRadioStatsReply(char *reply) { - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); -} - -void MyMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect()); -} - void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index ca515d84d06..a3724e744c4 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -156,7 +156,6 @@ class MyMesh : public ServerMesh { } // CommonCLICallbacks - bool formatFileSystem() override; void setLoggingOn(bool enable) override { _logging = enable; } @@ -165,22 +164,16 @@ class MyMesh : public ServerMesh { } void dumpLogFile() override; - void setTxPower(int8_t power_dbm) override; bool setRxBoostedGain(bool enable) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); } - void formatStatsReply(char *reply) override; - void formatRadioStatsReply(char *reply) override; - void formatPacketStatsReply(char *reply) override; mesh::LocalIdentity& getSelfId() override { return self_id; } static bool saveFilter(ClientInfo* client); - void saveIdentity(const mesh::LocalIdentity& new_id) override; - void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); }; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 80b8cba5344..a8a768a0a34 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -816,49 +816,6 @@ void SensorMesh::begin(FILESYSTEM* fs) { #endif } -bool SensorMesh::formatFileSystem() { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - return InternalFS.format(); -#elif defined(RP2040_PLATFORM) - return LittleFS.format(); -#elif defined(ESP32) - return SPIFFS.format(); -#else - #error "need to implement file system erase" - return false; -#endif -} - -void SensorMesh::saveIdentity(const mesh::LocalIdentity& new_id) { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - IdentityStore store(*_fs, ""); -#elif defined(ESP32) - IdentityStore store(*_fs, "/identity"); -#elif defined(RP2040_PLATFORM) - IdentityStore store(*_fs, "/identity"); -#else - #error "need to define saveIdentity()" -#endif - store.save("_main", new_id); -} - -void SensorMesh::setTxPower(int8_t power_dbm) { - radio_driver.setTxPower(power_dbm); -} - -void SensorMesh::formatStatsReply(char *reply) { - StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); -} - -void SensorMesh::formatRadioStatsReply(char *reply) { - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); -} - -void SensorMesh::formatPacketStatsReply(char *reply) { - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect()); -} - float SensorMesh::getTelemValue(uint8_t channel, uint8_t type) { return findTelemValue(telemetry.getBuffer(), telemetry.getSize(), channel, type, 0.0f); } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index a318dafcf2e..ca17528c70f 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -61,19 +61,13 @@ class SensorMesh : public ServerMesh { const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; } void savePrefs() override { _cli.savePrefs(_fs); } - bool formatFileSystem() override; void setLoggingOn(bool enable) override { } void eraseLogFile() override { } void dumpLogFile() override { } - void setTxPower(int8_t power_dbm) override; void formatNeighborsReply(char *reply) override { strcpy(reply, "not supported"); } - void formatStatsReply(char *reply) override; - void formatRadioStatsReply(char *reply) override; - void formatPacketStatsReply(char *reply) override; mesh::LocalIdentity& getSelfId() override { return self_id; } - void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override { } float getTelemValue(uint8_t channel, uint8_t type); diff --git a/src/helpers/ServerMesh.cpp b/src/helpers/ServerMesh.cpp index 721ba99cf92..c1f387ebeb0 100644 --- a/src/helpers/ServerMesh.cpp +++ b/src/helpers/ServerMesh.cpp @@ -2,6 +2,17 @@ #include "target.h" // the variant's radio_driver, as helpers/CommonRadioPrefs.cpp reaches it +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include +#elif defined(RP2040_PLATFORM) +#include +#elif defined(ESP32) +#include +#endif + +#include "SimpleMeshTables.h" +#include "StatsFormatHelper.h" + // The bodies below moved here unchanged from the three roles, where they were // identical. Verified by extracting each one from all three role implementations and // comparing after normalising only the class name and the pointer star spacing. @@ -125,3 +136,52 @@ void ServerMesh::checkPendingRadioParams() { MESH_DEBUG_PRINTLN("Radio params restored"); } } + +void ServerMesh::setTxPower(int8_t power_dbm) { + radio_driver.setTxPower(power_dbm); +} + +bool ServerMesh::formatFileSystem() { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + return InternalFS.format(); +#elif defined(RP2040_PLATFORM) + return LittleFS.format(); +#elif defined(ESP32) + return SPIFFS.format(); +#else +#error "need to implement file system erase" + return false; +#endif +} + +void ServerMesh::saveIdentity(const mesh::LocalIdentity& new_id) { +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + IdentityStore store(*_fs, ""); +#elif defined(ESP32) + IdentityStore store(*_fs, "/identity"); +#elif defined(RP2040_PLATFORM) + IdentityStore store(*_fs, "/identity"); +#else +#error "need to define saveIdentity()" +#endif + store.save("_main", new_id); +} + +void ServerMesh::clearStats() { + radio_driver.resetStats(); + resetStats(); + ((SimpleMeshTables *)getTables())->resetStats(); +} + +void ServerMesh::formatStatsReply(char* reply) { + StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr); +} + +void ServerMesh::formatRadioStatsReply(char* reply) { + StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); +} + +void ServerMesh::formatPacketStatsReply(char* reply) { + StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), + getNumRecvFlood(), getNumRecvDirect()); +} diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h index 77278e70e71..bee4b18fd08 100644 --- a/src/helpers/ServerMesh.h +++ b/src/helpers/ServerMesh.h @@ -99,6 +99,13 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { // because CommonCLI reaches them through its CommonCLICallbacks pointer. They // were public in all three roles before they moved here. void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; + void setTxPower(int8_t power_dbm) override; + bool formatFileSystem() override; + void saveIdentity(const mesh::LocalIdentity& new_id) override; + void clearStats() override; + void formatStatsReply(char* reply) override; + void formatRadioStatsReply(char* reply) override; + void formatPacketStatsReply(char* reply) override; void sendSelfAdvertisement(int delay_millis, bool flood) override; void updateAdvertTimer() override; void updateFloodAdvertTimer() override; diff --git a/test/test_server_base/test_server_base.cpp b/test/test_server_base/test_server_base.cpp index f71ec07daf5..fc7b965c0da 100644 --- a/test/test_server_base/test_server_base.cpp +++ b/test/test_server_base/test_server_base.cpp @@ -506,6 +506,23 @@ TEST_F(HousekeepingTest, TheStatsRepliesAreFilledIn) { EXPECT_GT(strlen(reply), 0u) << "packet stats"; } +TEST_F(HousekeepingTest, ClearingTheStatsResetsTheCoreCountersToo) { + // Make the core count a transmission of its own, so the reset has something to + // clear beyond the radio's counters. + mesh->sendSelfAdvertisement(0, true); + for (int i = 0; i < 40; i++) { g_mock_millis += 250; mesh->loop(); } + ASSERT_GT(radio_driver.send_calls, 0) << "something has to have been sent"; + + char busy[256] = {0}; + mesh->formatPacketStatsReply(busy); + + mesh->clearStats(); + + char cleared[256] = {0}; + mesh->formatPacketStatsReply(cleared); + EXPECT_STRNE(busy, cleared) << "the core's own counters have to be reset as well"; +} + TEST_F(HousekeepingTest, ThePacketStatsFollowTheRadioCounters) { char before[256] = {0}; mesh->formatPacketStatsReply(before); From 9f4c968e504c436ffbbded542275820414b11053 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:41:40 +0200 Subject: [PATCH 041/136] fix(companion): rename the InterfaceType NONE enumerator so a macro cannot replace it RCC-SKIPPED: single-step cause, measured and shown before the edit What changes - src/helpers/MultiSerialInterface.h renames the InterfaceType enumerator NONE to None, and with it the one use of that name, the default initializer of the type field of InterfaceEntry. Why - The meshsolar dependency of the heltec_mesh_solar variant defines NONE as a plain macro in src/utils/logger.h:18, next to ERROR, WARNING, INFO, LOG, DEBUG, FET and S. The preprocessor does not respect the scope of an enum class, so the enumerator NONE is replaced by 1, the enum body becomes invalid, and every other enumerator goes missing with it: logger.h:18: error: expected identifier before numeric constant MultiSerialInterface.h:86: error: 'Bluetooth' is not a member of 'InterfaceType' companion_radio/main.cpp:260: error: 'USB' is not a member of 'InterfaceType' Heltec_mesh_solar_companion_radio_usb and Heltec_mesh_solar_companion_radio_ble therefore do not build at all. - None is also what the other enumerators look like. They are Bluetooth, USB, WiFi, Ethernet and HardwareSerial, so the one name in shouting case was the odd one out, and it was the only one a macro could reach. - Renaming the enumerator keeps the fix inside the file this repository owns. An "#undef NONE" before the enum was rejected: it would change the dependency's macro for the rest of the translation unit and would depend on include order, which is what made this break in the first place. Compatibility - InterfaceType is named in two files and eleven places in the tree, and InterfaceType::NONE in exactly one of them, the initializer in this header, so no other code refers to the old name. - 29 environments were built and all 29 build: the two that did not build before this change (Heltec_mesh_solar_companion_radio_usb and Heltec_mesh_solar_companion_radio_ble), the three remaining environments of that variant, one companion environment from each of the six platform families that compile this header, and the eighteen environment regression matrix. --- src/helpers/MultiSerialInterface.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helpers/MultiSerialInterface.h b/src/helpers/MultiSerialInterface.h index f7742b24c38..87cf4955292 100644 --- a/src/helpers/MultiSerialInterface.h +++ b/src/helpers/MultiSerialInterface.h @@ -8,7 +8,7 @@ #endif enum class InterfaceType : uint8_t { - NONE, + None, Bluetooth, USB, WiFi, @@ -20,7 +20,7 @@ class MultiSerialInterface : public BaseSerialInterface { private: struct RegisteredInterface { - InterfaceType type = InterfaceType::NONE; + InterfaceType type = InterfaceType::None; BaseSerialInterface* instance = nullptr; }; From f1bf12e1130e7c7dd764f700c8e313f0d84038f5 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:41:45 +0200 Subject: [PATCH 042/136] ci: run the group message suite The workflow calls pio test with an explicit list of environments, so the native_chat environment added with GroupMessageTest would never run there. Adding it to the list is what makes the suite a guard rather than a local check. Verified by reading the step before the change: run: pio test -e native -e native_kiss_modem -vv --- .github/workflows/run-unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..933c0770ce8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_chat -e native_kiss_modem -vv - name: Upload Test Results # Upload test results even if the test step failed. From 67b8843d243fb2f9279cace5a87dd4faa8d53e02 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:34:11 +0200 Subject: [PATCH 043/136] fix(prefs): persist extra_sf and the discovery timestamp RCC-SKIPPED: defect and cause established by reading the source; the root cause chain was waived for this fix. What changes - NodePrefs writes and reads two fields it was keeping only in memory: discovery_mod_timestamp as "disc_mod" next to the advert intervals, and extra_sf as "extra_sf" in the radio object, hex encoded with the same def() overload the companion already uses for its default scope key. - The constructor zeroes extra_sf. It is a plain array member with no initialiser, and it is now written out and read back, so it needs a defined starting value. - New environment native_node_prefs covers the serialisation of NodePrefs against stream doubles. NodePrefs lives in CommonCLI.h, whose filesystem types only exist for a platform, so the environment selects the nRF52 branch and takes the types from a mock filesystem. Why - Both fields are live state. discovery_mod_timestamp answers discovery queries ("has this node changed since"), read in simple_repeater/MyMesh.cpp :806 and simple_sensor/SensorMesh.cpp:730 and set in CommonCLI.cpp:914. extra_sf holds the side detector spread factors, set through "extra.sf" (CommonCLI.cpp:687) and read back for display (:826). - The binary loader still reads discovery_mod_timestamp at offset 162 (CommonCLI.cpp:97), so the field survived as long as a node booted from /com_prefs. The move to /prefs.json dropped it, which means it has been lost on every reboot since 1.17, and every node reports itself as never modified. - extra_sf was never persisted in either format. Compatibility - Upgrade: a /prefs.json without the two keys loads as before. The serializer leaves a field alone when its key is absent, so both fields keep whatever the firmware put there. - Downgrade: a firmware that does not know the keys skips them like any unknown key. - Both keys are written on every board, not only on those that can use extra_sf, which keeps the file portable between roles and boards. The bridge keys are handled the same way although the feature is build flag gated. Evidence - Six of the nine new cases are red on upstream/dev 428a95c6 and green with the change: both save cases, both load cases, the round trip and the case that mixes the new keys with unknown ones. The other three pin what must not change: a fresh NodePrefs starts with extra_sf zeroed, a file without the new keys leaves both fields alone while the keys that are present still arrive, and unknown keys are skipped. - pio test -e native -e native_kiss_modem -e native_node_prefs: 62 of 62. - Five deliberate mutations were each caught: dropping either def(), a blob one byte short, a constructor that fills extra_sf with ones, and a different JSON key for the timestamp. - Board, techo-b43b as a room server, marked images, each marker confirmed in the image and reported by ver: Upgrade: values set on dev-428a95c6 (af 3, txdelay 0.7, direct.txdelay 0.4, flood.max 32, multi.acks 1, allow.read.only on), rebooted, read back, then the new build flashed: all ten readings identical, only ver differs. Downgrade: new values set on the new build (af 4, txdelay 0.8, direct.txdelay 0.5, flood.max 48, multi.acks 0, allow.read.only off), rebooted so the file was rewritten with both new keys, then dev-428a95c6 flashed back: all ten readings identical, only ver differs. - Not shown on hardware: "extra.sf" is compiled in only for USE_LR2021 boards and implemented only by the repeater, and no such board was available, so that field is covered by the host tests and the builds alone. discovery_mod_timestamp has no CLI reader, so its round trip is covered by the host tests; the board test shows the file survives both directions. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 27 ++++- src/helpers/CommonCLI.h | 4 + test/mocks/Adafruit_LittleFS.h | 116 +++++++++++++++++++ test/mocks/Arduino.h | 23 ++++ test/mocks/target.h | 20 ++++ test/test_node_prefs/test_node_prefs.cpp | 141 +++++++++++++++++++++++ 7 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 test/mocks/Adafruit_LittleFS.h create mode 100644 test/mocks/target.h create mode 100644 test/test_node_prefs/test_node_prefs.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..17c6483e335 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_node_prefs -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index f296f838213..d02aa37632f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,7 +167,9 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = + test_kiss_modem + test_node_prefs build_src_filter = -<*> +<../src/Utils.cpp> @@ -177,6 +179,29 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_node_prefs] +; NodePrefs lives in CommonCLI.h, whose filesystem types only exist for a +; platform, so this environment picks the nRF52 branch and uses the mock +; filesystem for the types. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks + -D NRF52_PLATFORM +test_build_src = yes +test_filter = test_node_prefs +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/TxtDataHelpers.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_kiss_modem] platform = native test_framework = googletest diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2759b0c4f0e..8b5978d0a0f 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -95,6 +95,8 @@ class NodePrefs : public ConfigSerializer { def("agc_int", _parent->agc_reset_interval); def("hash_mode", _parent->path_hash_mode); def("multi_ack", _parent->multi_acks); + // A fixed size byte array, encoded as hex like the companion's scope key. + def("extra_sf", (void *) _parent->extra_sf, sizeof(_parent->extra_sf)); } public: RadioPrefs(NodePrefs* parent) : _parent(parent) { } @@ -213,6 +215,7 @@ class NodePrefs : public ConfigSerializer { def("owner", owner_info, sizeof(owner_info)); def("adv_int", advert_interval); def("f_adv_int", flood_advert_interval); + def("disc_mod", discovery_mod_timestamp); def("lat", node_lat); def("lon", node_lon); def("radio", radio); @@ -231,6 +234,7 @@ class NodePrefs : public ConfigSerializer { guest_password[0] = 0; bridge_secret[0] = 0; owner_info[0] = 0; + memset(extra_sf, 0, sizeof(extra_sf)); } CommonRadioPrefs* getRadioPrefs() { return &radio; } diff --git a/test/mocks/Adafruit_LittleFS.h b/test/mocks/Adafruit_LittleFS.h new file mode 100644 index 00000000000..6d5368e5c6b --- /dev/null +++ b/test/mocks/Adafruit_LittleFS.h @@ -0,0 +1,116 @@ +#pragma once + +// Mock of the nRF52 filesystem API for native tests. Only what IdentityStore +// touches is modelled: open, exists, remove, mkdir, and a File that is a +// Stream. +// +// The write budget is the point of this mock. A real flash lets a file open +// and then refuses the write once the medium is full or worn out, so a test +// needs a file that is valid but whose writes come up short. Print::write() +// loops over write(uint8_t), so a budget that runs out mid buffer returns a +// short count, which is how the real driver reports a partial write. + +#include +#include +#include +#include +#include +#include + +#include "Stream.h" + +#define FILE_O_READ 0 +#define FILE_O_WRITE 1 + +namespace Adafruit_LittleFS_Namespace { + +struct FileData { + std::vector bytes; + size_t write_budget = std::numeric_limits::max(); +}; + +class File : public Stream { + FileData* _data; + size_t _read_pos; + bool _open; + +public: + File() : _data(nullptr), _read_pos(0), _open(false) { } + explicit File(FileData* data) : _data(data), _read_pos(0), _open(data != nullptr) { } + + // The driver signals "could not open" through the bool conversion, not an + // exception or a null return. + operator bool() const { return _open; } + + void close() { _open = false; } + + // Overriding the single byte write would otherwise hide the buffer overloads + // of Print, which is what the callers use. + using Print::write; + + size_t write(uint8_t b) override { + if (!_open || _data == nullptr || _data->write_budget == 0) return 0; + _data->bytes.push_back(b); + _data->write_budget--; + return 1; + } + + int available() override { + if (!_open || _data == nullptr) return 0; + return (int) (_data->bytes.size() - _read_pos); + } + + int read() override { + if (available() <= 0) return -1; + return _data->bytes[_read_pos++]; + } + + int peek() override { + if (available() <= 0) return -1; + return _data->bytes[_read_pos]; + } + + // The driver's File offers a buffer read next to the Stream interface, and + // IdentityStore uses it to pick up the display name. + size_t read(uint8_t* buffer, size_t size) { + size_t n = 0; + while (n < size && available() > 0) buffer[n++] = (uint8_t) read(); + return n; + } +}; + +} // namespace Adafruit_LittleFS_Namespace + +class Adafruit_LittleFS { + std::map _files; + +public: + // Test knobs. + size_t write_budget = std::numeric_limits::max(); + bool fail_open = false; + + bool exists(const char* path) { return _files.count(path) > 0; } + bool remove(const char* path) { return _files.erase(path) > 0; } + bool mkdir(const char* path) { return true; } + + Adafruit_LittleFS_Namespace::File open(const char* path, uint8_t mode = FILE_O_READ) { + if (fail_open) return Adafruit_LittleFS_Namespace::File(); + if (mode == FILE_O_WRITE) { + // std::map keeps its values at a stable address, so handing out a + // pointer stays valid while other files are added. + Adafruit_LittleFS_Namespace::FileData& data = _files[path]; + data.bytes.clear(); + data.write_budget = write_budget; + return Adafruit_LittleFS_Namespace::File(&data); + } + auto found = _files.find(path); + if (found == _files.end()) return Adafruit_LittleFS_Namespace::File(); + return Adafruit_LittleFS_Namespace::File(&found->second); + } + + // Lets a test see what actually reached the medium. + const std::vector* contents(const char* path) { + auto found = _files.find(path); + return found == _files.end() ? nullptr : &found->second.bytes; + } +}; diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe4145..773ac6dab0e 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include "Stream.h" @@ -15,3 +17,24 @@ inline uint32_t millis() { inline void delay(uint32_t ms) { g_mock_millis += ms; } + +#ifndef constrain +#define constrain(amt, low, high) ((amt) < (low) ? (low) : ((amt) > (high) ? (high) : (amt))) +#endif + +// Arduino core extension (not in the C standard library). +inline char* ltoa(long value, char* buf, int base) { + char tmp[sizeof(long) * 8 + 1]; + char* t = tmp; + unsigned long v = (value < 0 && base == 10) ? -(unsigned long)value : (unsigned long)value; + do { + int digit = v % base; + *t++ = (char)(digit < 10 ? '0' + digit : 'a' + digit - 10); + v /= base; + } while (v); + char* p = buf; + if (value < 0 && base == 10) *p++ = '-'; + while (t > tmp) *p++ = *--t; + *p = 0; + return buf; +} diff --git a/test/mocks/target.h b/test/mocks/target.h new file mode 100644 index 00000000000..22ed9638dee --- /dev/null +++ b/test/mocks/target.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +// Stand-in for a variant's target.h, so code that reaches the global +// radio_driver (e.g. CommonRadioPrefs.cpp) builds and runs in the native env. +struct MockRadioDriver { + int8_t tx_power = 0; + int tx_power_calls = 0; + bool rx_boosted = false; + int rx_boost_calls = 0; + bool rx_boost_supported = true; + + void setTxPower(int8_t dbm) { tx_power = dbm; tx_power_calls++; } + bool setRxBoostedGainMode(bool en) { rx_boosted = en; rx_boost_calls++; return rx_boost_supported; } + + void reset() { *this = MockRadioDriver(); } +}; + +inline MockRadioDriver radio_driver; diff --git a/test/test_node_prefs/test_node_prefs.cpp b/test/test_node_prefs/test_node_prefs.cpp new file mode 100644 index 00000000000..ed251f52b25 --- /dev/null +++ b/test/test_node_prefs/test_node_prefs.cpp @@ -0,0 +1,141 @@ +#include + +#include + +// extra_sf and discovery_mod_timestamp live in NodePrefs but were missing from +// its serialisation, so a server role lost both on every reboot. The binary +// loader still read discovery_mod_timestamp (CommonCLI.cpp, offset 162), which +// is why the loss only shows on nodes that boot from /prefs.json, meaning every +// node since 1.17. + +namespace { + +class StringInput : public Stream { + const char* _text; + size_t _pos, _len; +public: + explicit StringInput(const char* text) : _text(text), _pos(0), _len(strlen(text)) { } + int available() override { return (int) (_len - _pos); } + int read() override { return _pos < _len ? _text[_pos++] : -1; } + int peek() override { return _pos < _len ? _text[_pos] : -1; } +}; + +class StringOutput : public Stream { + std::string _text; +public: + using Print::write; + size_t write(uint8_t b) override { _text.push_back((char) b); return 1; } + size_t print(double v, int places = 2) override { + char buf[32]; + snprintf(buf, sizeof(buf), "%.*f", places, v); + return Print::print(buf); + } + size_t print(unsigned int v, int base = 10) override { return printNumber((unsigned long long) v); } + size_t print(unsigned long v, int base = 10) override { return printNumber((unsigned long long) v); } + size_t print(int v, int base = 10) override { return printSigned(v); } + size_t print(long v, int base = 10) override { return printSigned(v); } + const std::string& text() const { return _text; } +private: + size_t printNumber(unsigned long long v) { + char buf[24]; + snprintf(buf, sizeof(buf), "%llu", v); + return Print::print(buf); + } + size_t printSigned(long long v) { + char buf[24]; + snprintf(buf, sizeof(buf), "%lld", v); + return Print::print(buf); + } +}; + +std::string save(NodePrefs& p) { + StringOutput out; + EXPECT_TRUE(p.saveSerial(out)); + return out.text(); +} + +bool contains(const std::string& haystack, const char* needle) { + return haystack.find(needle) != std::string::npos; +} + +class NodePrefsSerialisation : public ::testing::Test { +protected: + NodePrefs p; +}; + +// A fresh NodePrefs has to start from a defined extra_sf, because it is now +// written out and "get extra.sf" reads it. +TEST_F(NodePrefsSerialisation, ExtraSfStartsZeroed) { + for (size_t i = 0; i < sizeof(p.extra_sf); i++) { + EXPECT_EQ(0, (int) p.extra_sf[i]) << "index " << i; + } +} + +TEST_F(NodePrefsSerialisation, SaveWritesTheDiscoveryTimestamp) { + p.discovery_mod_timestamp = 1760000000; + EXPECT_TRUE(contains(save(p), "disc_mod:1760000000")); +} + +TEST_F(NodePrefsSerialisation, SaveWritesTheExtraSpreadFactors) { + p.extra_sf[0] = 9; p.extra_sf[1] = 10; p.extra_sf[2] = 11; p.extra_sf[3] = 0; + EXPECT_TRUE(contains(save(p), "extra_sf:\"090A0B00\"")); +} + +TEST_F(NodePrefsSerialisation, LoadRestoresTheDiscoveryTimestamp) { + StringInput in("{disc_mod:1760000000}"); + EXPECT_TRUE(p.loadSerial(in)); + EXPECT_EQ(1760000000u, p.discovery_mod_timestamp); +} + +TEST_F(NodePrefsSerialisation, LoadRestoresTheExtraSpreadFactors) { + StringInput in("{radio:{extra_sf:\"090A0B00\"}}"); + EXPECT_TRUE(p.loadSerial(in)); + EXPECT_EQ(9, (int) p.extra_sf[0]); + EXPECT_EQ(10, (int) p.extra_sf[1]); + EXPECT_EQ(11, (int) p.extra_sf[2]); + EXPECT_EQ(0, (int) p.extra_sf[3]); +} + +TEST_F(NodePrefsSerialisation, RoundTripKeepsBothFields) { + p.discovery_mod_timestamp = 1234567890; + p.extra_sf[0] = 12; p.extra_sf[1] = 0; + std::string text = save(p); + + NodePrefs loaded; + StringInput in(text.c_str()); + EXPECT_TRUE(loaded.loadSerial(in)); + EXPECT_EQ(1234567890u, loaded.discovery_mod_timestamp); + EXPECT_EQ(12, (int) loaded.extra_sf[0]); + EXPECT_EQ(0, (int) loaded.extra_sf[1]); +} + +// Upgrade: a file written by an older firmware has neither key. Loading it must +// leave both fields alone instead of zeroing them. +TEST_F(NodePrefsSerialisation, LoadWithoutTheNewKeysLeavesThemAlone) { + p.discovery_mod_timestamp = 42; + p.extra_sf[0] = 7; + + StringInput in("{name:\"node\",adv_int:1,radio:{sf:11,cr:5}}"); + EXPECT_TRUE(p.loadSerial(in)); + + EXPECT_EQ(42u, p.discovery_mod_timestamp); + EXPECT_EQ(7, (int) p.extra_sf[0]); + EXPECT_EQ(11, (int) p.sf); // the keys that are there still arrive +} + +// Downgrade: a firmware that does not know the keys has to skip them, which is +// what the serializer does with any unknown key. +TEST_F(NodePrefsSerialisation, UnknownKeysAreSkipped) { + StringInput in("{disc_mod:5,whats_this:9,radio:{extra_sf:\"0A000000\",nope:1,sf:10}}"); + EXPECT_TRUE(p.loadSerial(in)); + EXPECT_EQ(5u, p.discovery_mod_timestamp); + EXPECT_EQ(10, (int) p.extra_sf[0]); + EXPECT_EQ(10, (int) p.sf); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 480d1a6715c5af14e2a7404eb206f9407f419b69 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:48:58 +0200 Subject: [PATCH 044/136] ci: run the server role tests The native_server environment added with the host harness was never called. The workflow names its environments explicitly, so the thirty odd characterization tests for the server roles would have passed locally and never run in CI, on this fork or upstream. Add it to the list. This also puts those tests in front of GCC, which the runner uses; everything so far was only compiled by clang on the development machine, and GCC has caught things clang let through before (see the -Ofast NaN case in 0003). --- .github/workflows/run-unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 8b2de2b4442..0ede5386829 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -vv + run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -vv - name: Upload Test Results # Upload test results even if the test step failed. From 875f3ad914741d3dbecb291787b37408c5332b78 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:49:18 +0200 Subject: [PATCH 045/136] refactor(server): share the ACL peer lookup What changes - ServerMesh gains the client ACL, the index array behind a peer search, and the two methods that use them: searchPeersByHash() and getPeerSharedSecret(). 73 lines deleted from the roles, 29 added to the base. - The array is sized MAX_CLIENTS, which is what ClientACL can hold, and the search carries one bound against that size. The sensor's own MAX_SEARCH_RESULTS define is gone with it; the identically named one in helpers/BaseChatMesh.h belongs to the chat side and is untouched. - Four characterization tests for the merged behaviour, which the ACL being protected on the base finally makes reachable: it was private in each role before, so no test could populate it. Behaviour change, deliberate - The sensor reported at most 24 matching clients, because its index array was sized 24 while the repeater and the room server sized theirs for the whole ACL of 32. One array ends that truncation, so the sensor now reports up to 32. Recorded in docs/refactoring/0004-shared-server-base.md as difference 1 and pinned by its own test. - Checked that nothing downstream assumed the old ceiling: Mesh.cpp iterates the returned count and calls getPeerSharedSecret() per index, with no array of its own (src/Mesh.cpp:157-162), and the six places in the roles that read matching_peer_indexes[sender_idx] index the same array, now sized 32 rather than 24. Neither can overflow, since ClientACL caps its count at MAX_CLIENTS. Why - Fifth and last of the moves planned for this base in docs/refactoring/0004-shared-server-base.md. Step 4f, the boot sequence, waits on the open sensor question. Verification - pio test -e native -e native_core -e native_kiss_modem -e native_ofast -e native_server: 115 of 115 test cases pass (111 before, 4 new). - All three roles and all three main.cpp files compile for the host harness with no access errors. The two moved methods are protected on the base, as they were in all three roles. - The firmware builds pick the new file up: arduino_base's build_src_filter includes helpers/*.cpp as a wildcard, so no environment needs listing it. - Six deliberately introduced faults each turned the suite red: the match test inverted, the search stopping after the first match, the old 24 entry cap reinstated, the stored index off by one, the shared secret taken from the wrong client, and the count always reported as zero. - NOT YET VERIFIED: the firmware build matrix, which runs once at the end of the series, and the GCC compile. Everything so far was built with clang on the development machine; no GCC is installed there, so that check happens in CI. --- examples/simple_repeater/MyMesh.cpp | 20 ----- examples/simple_repeater/MyMesh.h | 4 - examples/simple_room_server/MyMesh.cpp | 20 ----- examples/simple_room_server/MyMesh.h | 4 - examples/simple_sensor/SensorMesh.cpp | 20 ----- examples/simple_sensor/SensorMesh.h | 5 -- src/helpers/ServerMesh.cpp | 22 +++++ src/helpers/ServerMesh.h | 7 ++ test/test_server_base/test_server_base.cpp | 99 ++++++++++++++++++++++ 9 files changed, 128 insertions(+), 73 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 584f42c62e8..a0aa94b8693 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -570,26 +570,6 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m } } -int MyMesh::searchPeersByHash(const uint8_t *hash) { - int n = 0; - for (int i = 0; i < acl.getNumClients(); i++) { - if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) - } - } - return n; -} - -void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - // lookup pre-calculated shared_secret - memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); - } else { - MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); - } -} - static bool isShare(const mesh::Packet *packet) { if (packet->hasTransportCodes()) { return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 631fa882830..4d4c5aa8595 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -83,7 +83,6 @@ class MyMesh : public ServerMesh { uint32_t last_millis; uint64_t uptime_millis; bool _logging; - ClientACL acl; CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; uint8_t reply_path[MAX_PATH_SIZE]; @@ -96,7 +95,6 @@ class MyMesh : public ServerMesh { NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif CayenneLPP telemetry; - int matching_peer_indexes[MAX_CLIENTS]; #if defined(WITH_RS232_BRIDGE) RS232Bridge bridge; #elif defined(WITH_ESPNOW_BRIDGE) @@ -130,8 +128,6 @@ class MyMesh : public ServerMesh { #endif void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; - int searchPeersByHash(const uint8_t* hash) override; - void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 5e49e14cf0c..3bed7d7e6cc 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -381,26 +381,6 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m } } -int MyMesh::searchPeersByHash(const uint8_t *hash) { - int n = 0; - for (int i = 0; i < acl.getNumClients(); i++) { - if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) - } - } - return n; -} - -void MyMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - // lookup pre-calculated shared_secret - memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); - } else { - MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); - } -} - void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, uint8_t *data, size_t len) { int i = matching_peer_indexes[sender_idx]; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index a3724e744c4..97036c1e4e9 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -94,7 +94,6 @@ class MyMesh : public ServerMesh { uint32_t last_millis; uint64_t uptime_millis; bool _logging; - ClientACL acl; CommonCLI _cli; unsigned long dirty_contacts_expiry; uint8_t reply_data[MAX_PACKET_PAYLOAD]; @@ -104,7 +103,6 @@ class MyMesh : public ServerMesh { int next_post_idx; PostInfo posts[MAX_UNSYNCED_POSTS]; // cyclic queue CayenneLPP telemetry; - int matching_peer_indexes[MAX_CLIENTS]; void addPost(ClientInfo* client, const char* postData); void storePost(const mesh::Identity& author, const char* postData); @@ -125,8 +123,6 @@ class MyMesh : public ServerMesh { bool allowPacketForward(const mesh::Packet* packet) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; - int searchPeersByHash(const uint8_t* hash) override ; - void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index a8a768a0a34..7537b1f249e 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -515,26 +515,6 @@ void SensorMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, con } } -int SensorMesh::searchPeersByHash(const uint8_t* hash) { - int n = 0; - for (int i = 0; i < acl.getNumClients() && n < MAX_SEARCH_RESULTS; i++) { - if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) - } - } - return n; -} - -void SensorMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - // lookup pre-calculated shared_secret - memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); - } else { - MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); - } -} - void SensorMesh::sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size) { if (dest.out_path_len == OUT_PATH_UNKNOWN) { mesh::Packet* ack = createAck(ack_hash); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index ca17528c70f..4fc1d33d4e7 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -44,7 +44,6 @@ #define FIRMWARE_ROLE "sensor" -#define MAX_SEARCH_RESULTS 24 #define MAX_CONCURRENT_ALERTS 4 class SensorMesh : public ServerMesh { @@ -112,8 +111,6 @@ class SensorMesh : public ServerMesh { uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; uint8_t getExtraAckTransmitCount() const override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; - int searchPeersByHash(const uint8_t* hash) override; - void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onControlDataRecv(mesh::Packet* packet) override; @@ -122,13 +119,11 @@ class SensorMesh : public ServerMesh { virtual bool handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t* data, uint8_t flags, size_t len); void sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size=1); private: - ClientACL acl; CommonCLI _cli; uint8_t reply_data[MAX_PACKET_PAYLOAD]; unsigned long dirty_contacts_expiry; CayenneLPP telemetry; uint32_t last_read_time; - int matching_peer_indexes[MAX_SEARCH_RESULTS]; int num_alert_tasks; Trigger* alert_tasks[MAX_CONCURRENT_ALERTS]; diff --git a/src/helpers/ServerMesh.cpp b/src/helpers/ServerMesh.cpp index c1f387ebeb0..de85589ab62 100644 --- a/src/helpers/ServerMesh.cpp +++ b/src/helpers/ServerMesh.cpp @@ -185,3 +185,25 @@ void ServerMesh::formatPacketStatsReply(char* reply) { StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), getNumRecvFlood(), getNumRecvDirect()); } + +int ServerMesh::searchPeersByHash(const uint8_t* hash) { + int n = 0; + // ClientACL caps its own count at MAX_CLIENTS, so the bound can never bite; it is + // here so the array's size and the loop that fills it are stated in one place. + for (int i = 0; i < acl.getNumClients() && n < MAX_CLIENTS; i++) { + if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { + matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) + } + } + return n; +} + +void ServerMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + // lookup pre-calculated shared_secret + memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); + } else { + MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); + } +} diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h index bee4b18fd08..ad86c070ffd 100644 --- a/src/helpers/ServerMesh.h +++ b/src/helpers/ServerMesh.h @@ -31,7 +31,12 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { TransportKeyStore key_store; RegionMap region_map, temp_map; + ClientACL acl; // the clients this node knows and what they may do NodePrefs _prefs; // this node's persisted settings + + // Filled by searchPeersByHash(), read by getPeerSharedSecret() right after. Sized + // for the whole ACL, which is what ClientACL can hold. + int matching_peer_indexes[MAX_CLIENTS]; unsigned long next_local_advert, next_flood_advert; // A 'set radio ...' command parks its values here until the reply has gone out, @@ -88,6 +93,8 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { // Mesh mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; + int searchPeersByHash(const uint8_t* hash) override; + void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; // CommonCLICallbacks void startRegionsLoad() override; diff --git a/test/test_server_base/test_server_base.cpp b/test/test_server_base/test_server_base.cpp index fc7b965c0da..d70a051b69d 100644 --- a/test/test_server_base/test_server_base.cpp +++ b/test/test_server_base/test_server_base.cpp @@ -534,6 +534,105 @@ TEST_F(HousekeepingTest, ThePacketStatsFollowTheRadioCounters) { EXPECT_STRNE(before, after) << "the reply has to reflect the counters it reads"; } + +// The ACL peer lookup, which step 4e moved into the shared base. The ACL is +// protected on the base, so a subclass can populate it; before the move it was +// private in each role and could not be reached from a test at all. +class PeerLookupProbe : public MyMesh { +public: + using MyMesh::MyMesh; + + ClientACL& clients() { return acl; } + int search(const uint8_t* hash) { return searchPeersByHash(hash); } + void secretFor(uint8_t* dest, int peer_idx) { getPeerSharedSecret(dest, peer_idx); } +}; + +class PeerLookupTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + mesh.reset(new PeerLookupProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + } + + // The hash is the first byte of the public key (PATH_HASH_SIZE is 1), so the + // first byte decides who matches and the rest only keeps the keys distinct. + mesh::Identity identityStarting(uint8_t first, uint8_t tag) { + uint8_t pub[PUB_KEY_SIZE]; + memset(pub, tag, sizeof(pub)); + pub[0] = first; + return mesh::Identity(pub); + } + + ClientInfo* addClient(uint8_t first, uint8_t tag) { + ClientInfo* c = mesh->clients().putClient(identityStarting(first, tag), 0); + if (c) memset(c->shared_secret, tag, PUB_KEY_SIZE); + return c; + } +}; + +TEST_F(PeerLookupTest, AnEmptyAclMatchesNobody) { + const uint8_t hash[1] = { 0xAA }; + EXPECT_EQ(mesh->search(hash), 0); +} + +TEST_F(PeerLookupTest, OnlyTheClientsWhoseHashMatchesAreReturned) { + addClient(0xAA, 1); + addClient(0xBB, 2); + addClient(0xAA, 3); + + const uint8_t hash[1] = { 0xAA }; + EXPECT_EQ(mesh->search(hash), 2); + + const uint8_t other[1] = { 0xBB }; + EXPECT_EQ(mesh->search(other), 1); + + const uint8_t missing[1] = { 0xCC }; + EXPECT_EQ(mesh->search(missing), 0); +} + +TEST_F(PeerLookupTest, TheSharedSecretBelongsToTheMatchedClient) { + addClient(0xBB, 2); // not a match, so the index must not be mistaken for it + addClient(0xAA, 7); + + const uint8_t hash[1] = { 0xAA }; + ASSERT_EQ(mesh->search(hash), 1); + + uint8_t secret[PUB_KEY_SIZE]; + memset(secret, 0, sizeof(secret)); + mesh->secretFor(secret, 0); + + uint8_t expected[PUB_KEY_SIZE]; + memset(expected, 7, sizeof(expected)); + EXPECT_EQ(memcmp(secret, expected, PUB_KEY_SIZE), 0); +} + +TEST_F(PeerLookupTest, MoreThanTwentyFourMatchesAreAllReported) { + // The sensor used to stop at 24, because its index array was sized that way + // while the other two roles sized theirs for the whole ACL. One array in the + // base ends the truncation, which is a deliberate change of behaviour for the + // sensor and is recorded in docs/refactoring/0004-shared-server-base.md. + const int wanted = 30; // more than 24, still inside MAX_CLIENTS + for (int i = 0; i < wanted; i++) { + ASSERT_NE(addClient(0xAA, (uint8_t)(i + 1)), nullptr) << "client " << i; + } + + const uint8_t hash[1] = { 0xAA }; + EXPECT_EQ(mesh->search(hash), wanted); +} + } // namespace int main(int argc, char** argv) { From da9af7bf5645cd916170e9ee74183f206480e26c Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:50:05 +0200 Subject: [PATCH 046/136] docs(refactoring): record what steps 4a to 4e achieved Measured against the state before the series: 564 lines left the three roles, 328 arrived in the shared base, and the host suite grew from 82 to 115 test cases. Also lists what is still shared between all three roles and why each one stayed, so the remaining scope is explicit rather than implied. --- docs/refactoring/0004-shared-server-base.md | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md index 6010fec9b25..e5639df78e7 100644 --- a/docs/refactoring/0004-shared-server-base.md +++ b/docs/refactoring/0004-shared-server-base.md @@ -150,6 +150,32 @@ small: - RX boosted gain: initialise the pref under the same guards as the other roles and apply it in `begin()`, mirroring upstream's own room server fix. +## What the series achieved + +Steps 4a to 4e have landed. Measured on the role implementations, against the state +before the series (`98ffa5c1`): + +| file | before | after | +|---|---|---| +| `examples/simple_repeater/MyMesh.cpp` | 1328 | 1137 | +| `examples/simple_room_server/MyMesh.cpp` | 1069 | 879 | +| `examples/simple_sensor/SensorMesh.cpp` | 1102 | 919 | +| `src/helpers/ServerMesh.h` and `.cpp` | 0 | 328 | + +564 lines left the roles, 328 arrived in the base, and what remains exists once +instead of three times. The host suite grew from 82 to 115 test cases. + +What is still shared between all three roles, and why it stayed: + +- `begin()` is step 4f, which waits on the sensor's RX boosted gain gap being + closed first, so the three boot paths can become one. +- `allowPacketForward()` is part of the sensor alignment, not of a move. +- `createSelfAdvert()` is a role hook by design now: only the advert type differs. +- `handleCommand()`, `handleRequest()`, `onAnonDataRecv()`, `onPeerDataRecv()` and + `loop()` differ by role and are out of scope, as stated above. +- `onPeerPathRecv()` is 90 percent shared between the repeater and the room server + but only about 30 percent with the sensor, so it was never part of this cut. + ## Verification of the steps Marcel's decision of 19 October: the 18 environment build matrix and the flash and From 670889e2a246b875f4a368afb04063e728fcc4c7 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:43:40 +0200 Subject: [PATCH 047/136] docs(fixes): note for the group message length fix Describes the symptom as measured on hardware, the chain from the missing length guard through the size_t subtraction to the memcpy, why the fix sits in two places, the alternatives including the central minimum length table that is deliberately left for later, and the host, mutation, build and hardware evidence. Kept in its own commit so an upstream pull request can leave it out. --- docs/fixes/companion-channel-txt-min-len.md | 232 ++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/fixes/companion-channel-txt-min-len.md diff --git a/docs/fixes/companion-channel-txt-min-len.md b/docs/fixes/companion-channel-txt-min-len.md new file mode 100644 index 00000000000..606636704ec --- /dev/null +++ b/docs/fixes/companion-channel-txt-min-len.md @@ -0,0 +1,232 @@ +# A short CMD_SEND_CHANNEL_TXT_MSG frame stops the node + +Status: fixed, verified on hardware. Behaviour change: a frame below 7 bytes is +answered with an error instead of being parsed. + +## Symptom + +A companion frame `03 00 00` (CMD_SEND_CHANNEL_TXT_MSG, 3 bytes) makes the node +stop answering and it never comes back. Measured on a LilyGo T-Echo running `dev` +428a95c6 over the USB companion interface: + +``` +CH_TXT len=7 -> RESP_CODE_OK +GET_STATS core -> uptime=78s +CH_TXT len=3 -> NO REPLY +DEVICE_QUERY -> NO REPLY (three times over 8 s) +``` + +USB stayed enumerated under the application id `0x239a:0x8029` while the main +loop was gone. Neither a 1200 baud touch nor the reset button brought it back, +only unplugging the cable, so the fault state is not something a user recovers +from by restarting the app. + +## Cause + +`handleCmdFrame()` reads the fields of this command at fixed offsets: `txt_type` +at 1, `channel_idx` at 2, a 4 byte timestamp at 3, and the text from 7 on. The +branch had no length guard, so the text length is computed as `len - i` with +`i == 7` (`examples/companion_radio/MyMesh.cpp:1178`). + +`len` is a `size_t`. For `len == 3` the subtraction wraps, and the result reaches +`BaseChatMesh::sendGroupMessage()` through an `int` parameter as `-4`. There the +clamp is + +```c +if (text_len + prefix_len > MAX_TEXT_LEN) text_len = MAX_TEXT_LEN - prefix_len; +memcpy(ep, text, text_len); +``` + +A negative value passes that test, and `memcpy` converts the length back to a +`size_t`: the call asks for 4294967292 bytes into a 197 byte stack buffer +(`src/helpers/BaseChatMesh.cpp:518,526-527`). + +The same chain reproduces on the host, away from the radio and the channel table: + +``` +$ bugdemo 5 calling sendGroupMessage(text_len=5) ... returned false exit=0 +$ bugdemo -4 calling sendGroupMessage(text_len=-4) ... exit=139 +``` + +### Root versus proximate + +The missing `len >= 7` is the proximate cause. One layer down, the frame handler +derives payload lengths by subtraction without ever testing that the frame is +long enough; 35 of its 62 branches state their minimum in the condition and this +one did not. The other 14 are the subject of +`docs/fixes/companion-cmd-frame-min-len.md`. + +One layer further down, `sendGroupMessage()` trusts a length its caller computed, +although its own sibling `sendGroupData()` in the same file already refuses a +negative one (`src/helpers/BaseChatMesh.cpp:538-541`). The library had no +invariant of its own, so any caller could do the same damage. + +## Fix + +Two guards, one per mistake. + +- `examples/companion_radio/MyMesh.cpp`: the branch condition gains `len >= 7`, + the way the neighbouring `CMD_SEND_TXT_MSG` branch states `len >= 14`. A + shorter frame falls through to the existing final `else` and is answered with + `ERR_CODE_UNSUPPORTED_CMD`, which is what the 35 already guarded branches + answer for a frame that is too short. A frame of exactly 7 bytes keeps working + and sends an empty message. +- `src/helpers/BaseChatMesh.cpp`: `sendGroupMessage()` refuses `text_len < 0`, + mirroring `sendGroupData()`. + +### Alternatives considered + +- **Only the frame guard.** Rejected: it leaves the library able to corrupt + memory for any other caller, and it cannot be tested on the host, because + `MyMesh.cpp` needs `target.h`, the board, the radio driver, the sensors, the + telemetry and the store. +- **Only the library guard.** Rejected: the frame handler would still read a + timestamp out of the tail of an earlier frame and send a message with it. +- **A central table of minimum lengths**, checked once at the top of + `handleCmdFrame()` and answering `ERR_CODE_ILLEGAL_ARG`. This is the better + long term shape: the minimums would live in one place, the next command could + not forget one, and the table would be testable on the host. It is not part of + this fix because it changes the answer for the 35 branches that already guard, + from `ERR_CODE_UNSUPPORTED_CMD` to `ERR_CODE_ILLEGAL_ARG`, which is a visible + protocol change and does not belong in the same change as a crash fix. +- **Clamping a negative length to 0** instead of refusing. Rejected: it would + send an empty message for a frame the sender got wrong, and silence is worse + than an error code here. + +## Verification + +### Host tests + +`test/test_chat_group_msg` runs in the new `native_chat` environment, which +builds the real `Utils.cpp`, `Packet.cpp`, `Dispatcher.cpp`, `Mesh.cpp`, +`BaseChatMesh.cpp`, `AdvertDataHelpers.cpp` and `TxtDataHelpers.cpp` against +fakes for radio, clock, RNG and packet manager, and against the link time +identity stubs the board crypto would otherwise provide. The packet manager keeps +every packet that was queued, so "refused before sending" and "sent" are told +apart by whether a packet was queued at all, and the payload length the chat +layer produced is observable. + +The suite was written first and is red on the unchanged code: + +``` +$ pio test -e native_chat +Program received signal SIGBUS (Bus error: 10) + +$ pio test -e native_chat -a "--gtest_filter=-GroupMessageTest.Refuses*" +6 test cases: 6 succeeded in 00:00:03.471 +``` + +The six cases that describe today's behaviour pass before the fix, so the +expectations are right; only the two negative lengths bring the process down. +After the fix, on the committed tree with nothing uncommitted: + +``` +native test_mesh_tables PASSED +native test_config_serializer PASSED +native test_utils PASSED +native test_utf8_helpers PASSED +native test_routing_policy PASSED +native_chat test_chat_group_msg PASSED +native_kiss_modem test_kiss_modem PASSED +62 test cases: 62 succeeded in 00:00:33.664 +``` + +`test_companion_node_prefs` is reported SKIPPED. It is also SKIPPED on unchanged +`dev` (46 of 46 there), so that is not a change from this fix. + +`native_chat` is added to the environment list in +`.github/workflows/run-unit-tests.yml`; without that the workflow would never run +the suite, because it names its environments explicitly. + +### Mutation tests + +Each fault was introduced on its own and the suite had to fail: + +| Mutation | Result | +|----------|--------| +| guard removed | SIGBUS | +| guard only below -10 | SIGBUS | +| guard returns `true` | 2 cases fail | +| clamp forgets the prefix | `ClampsTextThatIsTooLong` fails | +| clamp `>` becomes `>=` | not caught, see below | +| datagram length off by one | `SendsTextAcrossABlockBoundary` fails | +| guard refuses 0 as well | `SendsEmptyText` fails | + +The clamp mutation is an equivalent mutant, not a gap: `>` and `>=` differ only +when `text_len + prefix_len == MAX_TEXT_LEN`, and in that case the clamp assigns +`MAX_TEXT_LEN - prefix_len`, which is the value `text_len` already has. The +behaviour is identical for every input. + +The suite cannot cover the frame guard, and that was measured rather than +assumed: with `len >= 7` mutated to `len >= 1`, `native_chat` still reports 8 of +8 passing, because the environment does not build `MyMesh.cpp`. That half of the +fix rests on the hardware check below. + +### Builds + +The 18 environments of the review series plus both T-Echo companion environments +were built from `dev` (428a95c6) and from this branch with the same toolchain. All +20 build on both sides. RAM is unchanged everywhere. Only the environments that +build the companion example change size at all: + +| Environment | Flash `dev` | Flash fix | Delta | +|---|---:|---:|---:| +| `Heltec_v3_companion_radio_ble` | 1,666,437 | 1,666,457 | +20 | +| `Heltec_v3_companion_radio_wifi` | 1,102,677 | 1,102,693 | +16 | +| `RAK_4631_companion_radio_ble` | 462,548 | 462,532 | -16 | +| `ProMicro_companion_radio_usb` | 357,300 | 357,284 | -16 | +| `LilyGo_T-Echo_companion_radio_ble` | 374,096 | 374,080 | -16 | +| `LilyGo_T-Echo_companion_radio_usb` | 366,704 | 366,688 | -16 | + +The other 14 environments, all repeater, room server and sensor roles, report the +same flash and RAM size as `dev`. Two independent readings say why, and the +second corrects the obvious guess: + +- None of the 14 has `+<../examples/companion_radio/*.cpp>` in its + `build_src_filter`, so `MyMesh.cpp` is not part of those builds. Exactly the + six environments that do have it are the six whose size changed. +- `BaseChatMesh.cpp` *is* compiled in all of them (the object file is there), but + `sendGroupMessage` is not linked into their images. `nm` finds the symbol once + in `RAK_4631_companion_radio_ble` and not at all in `Heltec_v3_repeater` or + `RAK_4631_repeater`, on both sides of the change: the roles never call it, so + the linker drops it. + +Their images are not byte identical: the difference is 69 bytes of build +metadata, located by comparing the two binaries and reading the differing +regions. For `Heltec_v3_repeater` they are the 32 byte ELF SHA-256 in the ESP32 +app descriptor at offset 177, the `__TIME__` string (`"22:32:04"` against +`"23:05:08"`) at offset 274217, and the 33 byte image hash esptool appends at the +end. The stock environments do not set `SOURCE_DATE_EPOCH`, so two builds of the +same source always differ in those bytes. + +### Hardware + +LilyGo T-Echo `techo-3dcf`, USB companion build, images marked through +`FIRMWARE_BUILD_DATE` and confirmed by `ver` before each run. + +Before, on `dev` reporting `v1.18.0 (Build: f1-dev)`: see the symptom above. + +After, on this branch reporting `v1.18.0 (Build: f1-fix1)`: + +``` +CH_TXT len=1 .. len=6 -> ERR UNSUPPORTED_CMD (each one) +uptime -> 23 s before, 35 s after, so no restart +CH_TXT len=7 (empty) -> RESP_CODE_OK +CH_TXT len=12 (text) -> RESP_CODE_OK, node transmits +CH_TXT txt_type=1 -> ERR UNSUPPORTED_CMD, unchanged +DEVICE_QUERY -> answers, before and after +``` + +Downgrading to the `dev` image afterwards left every stored value unchanged: +tx_power 22, freq 869.618, bw 62.5, sf 8, cr 8, rx_delay 0, airtime_factor 1.0, +name `android_dev_3`, BLE pin, path hash mode 2, 68 contacts. + +Two observations from the runs that this fix does not change, noted because they +look like findings and are not: + +- A message addressed to a channel index that is in range but has no channel + configured is accepted. `BaseChatMesh::getChannel()` returns true for every + index below `MAX_GROUP_CHANNELS` and hands out a zeroed channel, so the node + sends on a zero secret. That is unchanged upstream behaviour. +- The crash leaves USB enumerated while the main loop is dead, so a host sees a + device that cannot be opened. That is a property of the fault, not of the fix. From 92248b1999cd05a09967070cf0e2f6990b170467 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:49:37 +0200 Subject: [PATCH 048/136] docs(fixes): note for the range check after loading prefs Records what the JSON path lost against the binary loader, why a NaN is refused at the parser instead of clamped, why the airtime factor limit moved from 9 to 99, and the evidence: host tests, five mutations, the build matrix and a board run with out of range values produced through the CLI itself. Also records what is not covered: sanitisePrefs() has no host test on this branch, because no native environment here compiles CommonCLI.cpp. The note is a separate commit so it can be left out of an upstream pull request. --- docs/fixes/prefs-json-range-check.md | 217 +++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/fixes/prefs-json-range-check.md diff --git a/docs/fixes/prefs-json-range-check.md b/docs/fixes/prefs-json-range-check.md new file mode 100644 index 00000000000..f30d4f81b66 --- /dev/null +++ b/docs/fixes/prefs-json-range-check.md @@ -0,0 +1,217 @@ +# /prefs.json was loaded without a range check + +## Context + +Server roles read their settings from `/prefs.json`: + +```cpp +if (file) { + _prefs->loadSerial(file); // new Serial prefs + file.close(); +} +``` + +That was the whole of it (`src/helpers/CommonCLI.cpp:31-41` on `upstream/dev` +428a95c6). Whatever the file said became the running configuration, and the +file is plain text on a filesystem a user can write. The values reach the radio +setup directly: `freq`, `bw`, `sf`, `cr`, `tx_power_dbm`, the delay factors and +the bridge settings. + +The binary loader right below it tells a different story. It ends with a block +headed `// sanitise bad pref values` and about two dozen `constrain()` calls +(`:108-137`), written in `01d84d5d` on 2025-03-05 with the commit line "Now +sanitise bad prefs values". The move to `/prefs.json` did not carry it over, so +the protection depended on which file a node happened to boot from. Since 1.17 +that is always `/prefs.json`, so in practice it was gone. + +## Decision + +Both loaders call the same code. The block moved into +`CommonCLI::sanitisePrefs()`, the binary loader calls it where the block used to +sit, and the JSON path calls it after `loadSerial()`. + +Values that are not finite numbers are handled one layer down, in +`ConfigSerializer`: a token that does not parse to a finite float or double +leaves the field alone. `loadPrefs()` also logs it when `loadSerial()` reports a +parse error; that result used to be discarded. + +### Why non-finite values cannot be clamped + +`constrain()` cannot catch a NaN. Measured on the host: + +| token | `atof` | `constrain(v, 0, 9)` | +|---|---|---| +| `nan` | nan | **nan**, passed through | +| `-nan` | nan | **nan**, passed through | +| `inf` | inf | 9 | +| `1e40` | inf | 9 | +| `abc` | 0 | 0 | + +Both comparisons inside `constrain()` are false for a NaN, so it returns the +NaN unchanged. Infinity is clamped, NaN is not. + +Clamping a NaN to a bound has no sensible answer either. The firmware sets its +defaults before it reads the file (for the repeater at +`examples/simple_repeater/MyMesh.cpp:891` onwards, with `loadPrefs()` at +`:949`), so refusing the assignment keeps the build default, which is the right +value. Clamping to the nearest bound would put a node whose file says +`freq:nan` on 150 MHz. + +The check reads the bits rather than comparing, because nRF52 builds use +`-Ofast`: `-ffast-math` lets float comparisons assume that NaN and infinity +never occur. For floats it runs after the narrowing, since a value that is +finite as a double can overflow to infinity as a float. + +### The one limit that changed + +`airtime_factor` is clamped to 0..99, not to the 0..9 of the old block. +`set dutycycle 1` stores `100/1 - 1 = 99`, a value `set af` itself refuses. +The limit of 9 predates that command by a year (`01d84d5d` 2025-03-05 against +`0aa0ec1f` 2026-03-07), so it was never adjusted for it, and keeping 9 would +throw away every duty cycle below 10 percent at the next boot. Verified on the +board: `set dutycycle 1` gives `get af` `> 99.0`. + +`set tx` and `set multi.acks` have no check in the CLI at all. For those the +loader's limit is the only guard, which is its job, so they keep the values the +binary loader used. + +## Alternatives considered + +- **Derive every limit from the CLI checks.** Rejected. The CLI validates the + value a user types while the file stores a scaled one: `advert_interval` is + minutes divided by two and `agc_reset_interval` is seconds divided by four, + so the mapping would have to be written by hand per field. It would also + newly clamp values the binary loader accepted. The one place where CLI and + loader genuinely disagree is bandwidth, and there the loader is right: the + CLI takes anything from 7.0 while 7.8 kHz is the smallest bandwidth the + radios offer. +- **Clamp a NaN to the nearest bound.** Rejected, see above. +- **Reject the whole file when it does not parse.** Not done. A partially + parsed file is still applied, as before, and now sanitised. Refusing it would + be a second behaviour change, and the right answer (keep defaults, or keep + what parsed) is a product decision. +- **Leave `airtime_factor` out of the block.** Rejected. It would be the only + float without a range check, and a file saying `af:1000000` would come + through untouched. + +## Compatibility + +- The binary loader keeps its behaviour except for `airtime_factor`, which it + now leaves alone up to 99 instead of cutting it to 9. +- On the `/prefs.json` path a value outside the limits is clamped at boot where + it used to be taken as is. That is the point of the change. +- A file holding a non-finite number leaves that one field at its default + instead of poisoning it. Every other key in the file is still applied. +- The `ConfigSerializer` change also covers the companion `NodePrefs`, which + uses the same serializer. + +## Verification + +### Host tests + +Five new cases in `test_config_serializer`. Three are red on `upstream/dev` +428a95c6 and green with the change: a file with `nan`, a file with `inf` and +`-inf`, and a float that overflows to infinity. Two pin behaviour that must not +change: finite values are stored, and garbage still parses as zero. + +`pio test -e native -e native_kiss_modem` reports 59 of 59. `native_core` and +`native_ofast` do not exist on `upstream/dev`; they run on `integration` after +the merge. + +`sanitisePrefs()` itself has **no host test**: no native environment on this +branch compiles `CommonCLI.cpp`, and building that scaffolding would duplicate +what the refactoring steps already added on `integration`. It is covered by the +build of every role and by the board run below, which is the check the task +asked for. + +### Mutations + +Five deliberate errors in the parser check, each had to turn the suite red: + +| Mutation | Caught by | +|---|---| +| float path stores without checking | all three non-finite cases | +| double path stores without checking | the `nan` and the `inf` case | +| float path checks before the narrowing | the overflow case | +| bit comparison inverted | all five cases | +| doubles use the float mask | the `nan` and the `inf` case | + +### Builds + +All 20 environments build on this commit, and so does `upstream/dev` 428a95c6 +in its own worktree with the same toolchain. The 18 of the review matrix are +joined by `LilyGo_T-Echo_room_server`, the build the board run used, and +`LilyGo_T-Echo_kiss_modem`. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +28 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +24 | +| `Heltec_v3_repeater` | OK | +0 | +44 | +| `Heltec_v3_room_server` | OK | +0 | +40 | +| `Heltec_v3_sensor` | OK | +0 | +40 | +| `LilyGo_T-Echo_kiss_modem` | OK | +0 | +0 | +| `LilyGo_T-Echo_room_server` | OK | +0 | +48 | +| `PicoW_repeater` | OK | +0 | +40 | +| `ProMicro_companion_radio_usb` | OK | +0 | +32 | +| `ProMicro_repeater` | OK | +0 | +48 | +| `ProMicro_room_server` | OK | +0 | +48 | +| `ProMicro_sensor` | OK | +0 | +48 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +44 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +16 | +| `RAK_4631_repeater` | OK | +0 | +48 | +| `RAK_4631_room_server` | OK | +0 | +48 | +| `RAK_4631_sensor` | OK | +0 | +48 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +40 | +| `wio-e5-mini_repeater` | OK | +0 | +32 | +| `wio-e5-mini_sensor` | OK | +0 | +40 | + +RAM is unchanged in all 20. Flash grows by 0 to 48 bytes, and the spread lines +up with what each build actually compiles, which is a useful check that the +change lands where it should: + +- The server roles pay the most (40 to 48 bytes): they get both the bit check in + the serializer and the second call site of the sanitising block. +- The companion builds pay 16 to 32 bytes. They have their own `NodePrefs` and + never call `CommonCLI::sanitisePrefs()`, so only the serializer change + reaches them. +- `LilyGo_T-Echo_kiss_modem` pays nothing. Its `build_src_filter` adds only + `examples/kiss_modem/`, so it compiles neither loader and references neither + change. + +The sanitising block itself only moved; it was already linked into every build +that has `CommonCLI.cpp`. + +### Hardware + +techo-b43b, `LilyGo_T-Echo_room_server`, both images marked and each marker +confirmed in the image before flashing and reported by `ver` on the board. + +The out of range values were produced with the CLI itself, through the two +setters upstream does not check, and through the NaN hole in `set af` that this +branch does not touch: + +| reading | `dev-428a95c6` | `range-check-bc5708f7` | +|---|---|---| +| `get multi.acks` after `set multi.acks 5`, reboot | `> 5` | `> 1` | +| `get af` after `set af nan`, reboot | `> 0` (the field holds a NaN) | `> 1.0` (build default) | +| `get dutycycle` | `> 0.0%` | `> 50.0%` | +| `get tx` | `> 22` | `> 22` | +| `get txdelay` | `> 0.8` | `> 0.8` | + +Same board, same file: the out of range value is clamped, the NaN falls back to +the default the firmware set before reading, and the two values that were +inside their limits are untouched. + +## Follow-ups + +- `isFinite` now exists twice in the tree with the same shape, here and in + `CommonRadioPrefs.cpp` from the airtime factor fix. Worth unifying once both + are on `integration`. +- `extra_sf` is not in the block. Its values are validated where they are + applied (`CustomLR2021Wrapper.h:37-39`, against the primary spread factor), + and that check depends on `sf`, so a loader side limit would have to be + recomputed whenever `sf` changes. +- CLI and loader still disagree about bandwidth (7.0 against 7.8) and bridge + channel (1..14 against 0..14). Both are harmless, and unifying them is a + change to the CLI, not to the loader. From efeec201261c1408a632a591d9cf6bdcfbc3472e Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:53:21 +0200 Subject: [PATCH 049/136] docs(refactoring): the sensor keeps its own retransmit delay Marcel's decision of 19 October, after the upstream history was checked: the sensor stays on the six slot scheme and the retransmit delay becomes the one role parameter of the shared base. Upstream moved the repeater and the room server off that scheme in 8cbcd227, whose subject calls itself experimental, and has neither revisited nor confirmed it in the eleven and a half months since. Aligning the sensor would change radio timing on battery nodes on an experimental basis, which is the case the decision reserved for a role profile. The sensor alignment therefore covers multi.acks, the flood limits and the RX boosted gain, and leaves the two retransmit overrides in place, documented. --- docs/refactoring/0004-shared-server-base.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md index e5639df78e7..11e037a37c1 100644 --- a/docs/refactoring/0004-shared-server-base.md +++ b/docs/refactoring/0004-shared-server-base.md @@ -186,11 +186,18 @@ its diff proof, and a step is not called verified until the matrix has run. ## Open points -1. **The retransmit formula.** Upstream moved the repeater and the room server off - the six slot scheme in a commit that calls itself experimental, and never - revisited it. Aligning the sensor adopts that experiment. The alternative is to - keep the slot scheme for the sensor as the one role parameter of the shared base. - Marcel decides before 4f. +1. **The retransmit formula: decided, the sensor keeps its own.** Marcel's decision + of 19 October, after the upstream history was checked: the sensor stays on the six + slot scheme, and the retransmit delay becomes the one role parameter of the shared + base. The reason is that upstream moved the repeater and the room server off that + scheme in `8cbcd227`, whose subject calls itself experimental, and never + revisited or confirmed it in the eleven and a half months since. Adopting it for + the sensor would change radio timing on battery nodes on an experimental basis, + which is exactly the case the decision reserved for a role profile. + + So the sensor alignment covers `multi.acks`, the flood limits and the RX boosted + gain, and leaves `getRetransmitDelay()` and `getDirectRetransmitDelay()` as + documented role overrides. 2. **How deep the characterization goes: settled by measuring it.** The question was whether the real role classes can be driven on the host at all, or whether the steps have to lean on the diff alone. Probing the compiler rather than estimating From a18117b6b0d06b92ccedc59c83059054cc663b61 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:53:23 +0200 Subject: [PATCH 050/136] docs(fixes): base the LLCC68 claim on the include graph, not on binary size The note argued that identical flash and RAM across the build matrix shows no environment compiles CustomLLCC68Wrapper.h. That inference does not hold, and a measured counterexample is in the same tree: every ESP32 companion environment compiles helpers/esp32/ESPNOWRadio.cpp (39 variant files pull +), yet Heltec_v3_companion_radio_ble and Heltec_v3_companion_radio_wifi are byte identical with and without a change to that file, because the inline member it changes is never called there and the linker drops it. Unchanged size means the code did not reach the image, not that the file was not compiled. The conclusion for the LLCC68 wrapper is unchanged and now rests on evidence that does carry it: - Include graph: nothing includes the header. On integration the name appears only in the header itself, in docs/refactoring/architecture-review-v1.18.md (which records the defect), in this note and in the diagnostic patch. A header no translation unit includes cannot be compiled. - Artefact: xtensa-esp32-elf-nm on a built Heltec_v3_companion_radio_ble image finds zero LLCC68 symbols, and nothing under that environment's build directory references the wrapper. The build matrix stays in the note as corroboration and as a regression check, with its weight stated correctly. --- docs/fixes/llcc68-reset-agc-args.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/fixes/llcc68-reset-agc-args.md b/docs/fixes/llcc68-reset-agc-args.md index 22d2d277cd1..9c1ada43988 100644 --- a/docs/fixes/llcc68-reset-agc-args.md +++ b/docs/fixes/llcc68-reset-agc-args.md @@ -58,9 +58,18 @@ which is the point: the four members of one chip family should not differ here. ## Compatibility -None affected. The file is in no build, so every binary is unchanged. See the -build matrix under Verification: all 18 environments report the same flash and -RAM as `dev`, to the byte. +None affected, and the claim rests on the include graph rather than on binary +sizes. Nothing includes `CustomLLCC68Wrapper.h`, so no translation unit contains +it: on `integration` the name appears only in the header itself, in the +architecture review that records this defect, in this note and in the diagnostic +patch. The artefact side agrees: an `xtensa-esp32-elf-nm` of a built +`Heltec_v3_companion_radio_ble` image finds zero LLCC68 symbols, and nothing +under that environment's build directory references the wrapper. + +Identical flash and RAM across the build matrix is listed below as corroboration, +not as the proof. Unchanged size does not by itself mean a file is not compiled: +a file can be compiled while the linker drops its unused code, which is exactly +what happens to `ESPNOWRadio.cpp` in the ESP32 companion environments. ## Verification @@ -108,9 +117,12 @@ suites `native_core` and `native_ofast` do not exist on this base. ### Builds The 18 environment matrix on this commit against a `dev` worktree with the same -toolchain. Flash and RAM are expected to be identical in every environment, -because no environment compiles the changed file, and that is what makes this a -useful check rather than a formality. +toolchain: all 18 built, and flash and RAM are identical to `dev` in every one of +them, to the byte. + +That result is consistent with the file reaching no binary, and it rules out a +regression, but on its own it does not establish it. The include graph and the +symbol check under Compatibility are what establish it. ### Hardware From d8b7e35c9ea22fdeff01e347b508cf1462a19af0 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:47:33 +0200 Subject: [PATCH 051/136] docs(fixes): note for the identity write result Records the defect, the call site survey that made the change safe, the alternatives that were rejected, and the evidence: host tests with a mock filesystem that refuses the write, five mutations, the build matrix against upstream/dev, and what the board run does and does not show. The note is a separate commit so it can be left out of an upstream pull request. --- docs/fixes/identity-store-save-result.md | 197 +++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/fixes/identity-store-save-result.md diff --git a/docs/fixes/identity-store-save-result.md b/docs/fixes/identity-store-save-result.md new file mode 100644 index 00000000000..aa37d395453 --- /dev/null +++ b/docs/fixes/identity-store-save-result.md @@ -0,0 +1,197 @@ +# IdentityStore::save reported success for a failed write + +## Context + +`IdentityStore` is the only place that persists a node's keypair. Each role +loads it at boot and, when nothing is there, generates a new one and saves it: + +| Role | Generates and saves at boot | +|---|---| +| Repeater | `examples/simple_repeater/main.cpp:94` | +| Room server | `examples/simple_room_server/main.cpp:75` | +| Sensor | `examples/simple_sensor/main.cpp:101` | +| Companion | `examples/companion_radio/MyMesh.cpp:955` | +| KISS modem | `examples/kiss_modem/main.cpp:56` | +| Secure chat | `examples/simple_secure_chat/main.cpp:327` | + +Both overloads of `save()` ended in `return true` as long as the file could be +opened. The two argument form computed the result of `id.writeTo(file)`, logged +it and then threw it away (`src/helpers/IdentityStore.cpp:58-61` on +`upstream/dev` 428a95c6). The form that also stores a display name did not even +look at the result of its two writes. + +A node whose write fails therefore keeps running under a keypair that never +reached the medium, and comes back with a different public key after the next +boot. Everything that identifies the node to the mesh changes with it. + +## Decision + +Return what actually happened. In the two argument form that is the result of +`writeTo()`; in the form with a display name both writes count, because the +name is written after the keypair and a medium that fills up in between leaves +a file with an identity and no name. + +The form with a display name also gets the debug line the other form already +had, so a failure is visible in both. + +### Why the result of a failed write is safe to report + +The caller contract already expects an honest result. `CMD_IMPORT_PRIVATE_KEY` +in `examples/companion_radio/MyMesh.cpp:1544` adopts the imported key and +answers `OK` when `save()` reports success, and answers +`ERR_CODE_FILE_IO_ERROR` when it does not. That error path existed before this +change and was unreachable. + +All nine call sites were checked for what a `false` now triggers: + +| Call site | Reads the result | Effect of a false | +|---|---|---| +| `companion_radio/MyMesh.cpp:1544` (import private key) | yes | answers `ERR_CODE_FILE_IO_ERROR`, keeps the old key. The path already existed | +| `companion_radio/MyMesh.cpp:955` (boot) | no | unchanged | +| `companion_radio/MyMesh.cpp:2245` (`rebuild` command) | no | unchanged | +| `simple_repeater/main.cpp:94` (boot) | no | unchanged | +| `simple_room_server/main.cpp:75` (boot) | no | unchanged | +| `simple_sensor/main.cpp:101` (boot) | no | unchanged | +| `kiss_modem/main.cpp:56` (boot) | no | unchanged | +| `simple_secure_chat/main.cpp:327` (boot) | no | unchanged | +| `simple_repeater/MyMesh.cpp:1189`, `simple_room_server/MyMesh.cpp:858`, `simple_sensor/SensorMesh.cpp:896` (`saveIdentity`) | no | `void` wrappers behind `CommonCLI.h:265`, so they cannot propagate it | + +No boot path reads the result, so a failing write adds no error cascade at +startup. That was the condition for this change. + +The overload with a display name has no caller in this repository. It is part +of the public interface of the class and carried the same defect, so it is +fixed in the same place rather than left as the one that still lies. + +## Alternatives considered + +- **Remove the half written file when the write fails.** Not done. A truncated + `.id` file fails to load (`readFrom()` comes up short), so the node behaves + as if no file existed, and the next `save()` removes the file first anyway on + the platforms that take that branch. Deleting it would be a second behaviour + change in a fix whose point is to stop reporting a false success. +- **Retry the write inside `save()`.** Not done. The caller knows whether a + retry is useful; `save()` does not. The companion already decides this. +- **Only fix the two argument form, the one with a caller.** Not done. Both + overloads carry the identical defect in the identical place. +- **Leave the display name write unchecked.** Not done. It is the second half + of the same file; a file with a keypair and no name is not a saved identity. + +## Compatibility + +- `/prefs.json`, the identity file format and the success path are unchanged. + Nothing differs while the filesystem accepts the write. +- The only behaviour change on a failing write is in + `CMD_IMPORT_PRIVATE_KEY`, which now takes the error path that was written for + it. + +## Verification + +### Host tests + +New environment `native_identity_store` runs `test/test_identity_store` against +a mock filesystem (`test/mocks/Adafruit_LittleFS.h`) whose file opens and then +runs out of write budget, which is how a full or worn flash behaves. +`Print::write()` loops over the single byte write, so an exhausted budget +returns a short count, exactly as a partial write does on the real driver. The +mock serialisation of `mesh::Identity` and `mesh::LocalIdentity` is copied from +`src/Identity.cpp`, so a short write is a failure in the double as well. + +The environment defines `NRF52_PLATFORM`, so the covered branch of `save()` is +the one the nRF52 and STM32 firmware take (`remove()` then +`open(..., FILE_O_WRITE)`). The ESP32 and RP2040 branches differ only in the +`open()` call and are not covered by a host test. + +Four of the nine tests are red on `upstream/dev` 428a95c6 and green with the +change, each with `Actual: true, Expected: false`: + +| Test | On dev | With the fix | +|---|---|---| +| `IdentityStoreSave.ReportsFailureWhenNoByteCanBeWritten` | red | green | +| `IdentityStoreSave.ReportsFailureWhenThePrivateKeyIsCutOff` | red | green | +| `IdentityStoreSaveWithName.ReportsFailureWhenTheKeypairIsCutOff` | red | green | +| `IdentityStoreSaveWithName.ReportsFailureWhenOnlyTheNameIsCutOff` | red | green | + +The other five pin behaviour that must not change: the two success paths, the +two "file cannot be opened" paths and a save and load round trip. + +All suites of this branch: `pio test -e native -e native_kiss_modem -e +native_identity_store` reports 63 of 63. `native_core` and `native_ofast` do +not exist on `upstream/dev`; they run on `integration` after the merge. + +### Mutations + +Five deliberate errors in the changed code, each had to turn the suite red: + +| Mutation | Caught by | +|---|---| +| Two argument form returns `true` again (the original defect) | `ReportsFailureWhenNoByteCanBeWritten`, `ReportsFailureWhenThePrivateKeyIsCutOff` | +| Form with display name returns `true` again | `ReportsFailureWhenTheKeypairIsCutOff`, `ReportsFailureWhenOnlyTheNameIsCutOff` | +| Result of the display name write not checked | `ReportsFailureWhenOnlyTheNameIsCutOff` | +| Result of `writeTo()` not read in the two argument form | `ReportsFailureWhenNoByteCanBeWritten`, `ReportsFailureWhenThePrivateKeyIsCutOff` | +| The two writes joined with OR instead of AND | `ReportsSuccessWhenKeypairAndNameFit`, `ReportsFailureWhenOnlyTheNameIsCutOff` | + +### Builds + +All 21 environments build on this commit, and so does `upstream/dev` 428a95c6 in +its own worktree with the same toolchain. The 18 of the review matrix are joined +by three builds this change reaches through a call site of their own: +`LilyGo_T-Echo_room_server`, the build the board run used, +`LilyGo_T-Echo_kiss_modem` (`examples/kiss_modem/main.cpp:56`) and +`Heltec_v3_terminal_chat` (`examples/simple_secure_chat/main.cpp:327`), the one +role that keeps its own prefs structure and still uses `IdentityStore`. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +4 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +4 | +| `Heltec_v3_repeater` | OK | +0 | +4 | +| `Heltec_v3_room_server` | OK | +0 | +4 | +| `Heltec_v3_sensor` | OK | +0 | +4 | +| `LilyGo_T-Echo_kiss_modem` | OK | +0 | +0 | +| `LilyGo_T-Echo_room_server` | OK | +0 | +0 | +| `PicoW_repeater` | OK | +0 | +8 | +| `ProMicro_companion_radio_usb` | OK | +0 | +16 | +| `ProMicro_repeater` | OK | +0 | +16 | +| `ProMicro_room_server` | OK | +0 | +0 | +| `ProMicro_sensor` | OK | +0 | +0 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +4 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +0 | +| `RAK_4631_repeater` | OK | +0 | +0 | +| `RAK_4631_room_server` | OK | +0 | +0 | +| `RAK_4631_sensor` | OK | +0 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +8 | +| `wio-e5-mini_repeater` | OK | +0 | +0 | +| `wio-e5-mini_sensor` | OK | +0 | +0 | +| `Heltec_v3_terminal_chat` | OK | +0 | +4 | + +RAM is unchanged in all 21 environments. Flash grows by 0 to 16 bytes: the +function returns a value it used to throw away, and the second overload gained a +debug line. The cost does not follow the chip family, it follows what the +compiler did with the old constant return. Both ProMicro server roles come out +at 0 while the ProMicro repeater and the USB companion pay 16, all four being +nRF52840. + +### Hardware + +techo-b43b, `LilyGo_T-Echo_room_server`, image marked `save-result-b160f39a` +and the marker confirmed in the image before flashing +(`unzip -p firmware.zip '*.bin' | grep -c -a save-result-b160f39a` = 1) and +reported by `ver` on the board. + +The node boots on the changed firmware and keeps its settings and its identity +across a reboot, so the success path is intact on real hardware. + +The path this change alters, a write that fails, was deliberately **not** +provoked on the board. Reaching it means filling or wearing out the flash, and +the only CLI command that writes an identity is `set prv.key`, which would put +the identity of a node in the test rack at risk for no gain: on healthy +hardware the success path returns true both before and after this change, so +there is nothing to compare. That path is covered by the host tests, where the +mock filesystem lets a file open and then refuses the write. + +## Follow-ups + +- `src/helpers/IdentityStore.cpp:85` compares a signed `int` with + `sizeof(tmp)-1`. Left untouched; it is not part of this defect and the + firmware builds suppress the warning. From c93672a0e75f27da7a4ded80982091e4911b3881 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:58:08 +0200 Subject: [PATCH 052/136] docs(fixes): replace the guessed history of the LLCC68 defect with the commit The note said the rx_boost_gain parameter "likely" went in without this wrapper. The log says it plainly, so the guess is gone: commit 6f491f30 added the parameter and updated CustomLR1110Wrapper.h, CustomSTM32WLxWrapper.h, CustomSX1262Wrapper.h, CustomSX1268Wrapper.h and LR11x0Reset.h. CustomLLCC68Wrapper.h is not among them. --- docs/fixes/llcc68-reset-agc-args.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/fixes/llcc68-reset-agc-args.md b/docs/fixes/llcc68-reset-agc-args.md index 9c1ada43988..c94fcb9ef1c 100644 --- a/docs/fixes/llcc68-reset-agc-args.md +++ b/docs/fixes/llcc68-reset-agc-args.md @@ -26,9 +26,13 @@ wrapper itself and in a comment in `SX126xReset.h`. No variant sets `WRAPPER_CLASS` to `CustomLLCC68Wrapper`, so no environment pulls the header into a translation unit and no compiler ever looks at it. -The likely history is that the `rx_boost_gain` parameter was added to -`sx126xResetAGC()` and the three wrappers that are built were adjusted, while the -one that is not built was missed. Nothing reported it. +The history is in the log rather than a guess. Commit 6f491f30 (9 August 2026, +"RX Boosted Gain was set back to the compiled in value rather than the +current/flash value set by the user when the ACG reset is triggered") added the +`rx_boost_gain` parameter and updated five files with it: +`CustomLR1110Wrapper.h`, `CustomSTM32WLxWrapper.h`, `CustomSX1262Wrapper.h`, +`CustomSX1268Wrapper.h` and `LR11x0Reset.h`. `CustomLLCC68Wrapper.h` is not among +them. Nothing reported it, because nothing compiles it. ## Decision From 7ce79e206012ed8ff8da707fe20ac05fe16208be Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Mon, 19 Oct 2026 23:59:12 +0200 Subject: [PATCH 053/136] test(sensor): run the sensor role on the host and pin what it does today What this adds - A native_sensor environment and test/test_sensor_role, so the sensor role runs on the host. It needs an environment of its own because each role is a separate binary, and the sensor had none. SensorMesh is abstract on purpose (the application supplies what to read and how to answer a series query), so the suite carries the smallest concrete role that satisfies that, shaped like examples/simple_sensor/main.cpp without its battery logic. - Seven characterization tests recording what the sensor does now, before the alignment changes any of it: - multi.acks has no effect: the CLI stores it and sendAckTo() has a branch guarded by it, but the role reports no count, so the branch is dead. - only flood_max is applied: a flood packet at two hops is still forwarded with flood.max.unscoped at one, which the other two roles would stop. - flood_max itself is applied, at the limit rather than past it, and disable_fwd stops forwarding outright. - the stored RX boosted gain is not pushed to the radio during begin(), and the pref is left at zero. - the retransmit delay draws from six discrete multiples of the airtime. That one is pinned to keep it, not to change it: Marcel's decision is that the sensor stays on the slot scheme, because upstream still calls the continuous version experimental. - native_sensor is in the CI test run from the start. The native_server environment was not, and the mistake is worth not repeating. Verification - pio test over all six environments: 122 of 122 test cases pass (115 before, 7 new). Nothing is changed yet; these tests describe the code as it stands. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 36 +++++ test/test_sensor_role/identity_stubs.cpp | 98 +++++++++++++ test/test_sensor_role/test_sensor_role.cpp | 158 +++++++++++++++++++++ 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 test/test_sensor_role/identity_stubs.cpp create mode 100644 test/test_sensor_role/test_sensor_role.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 0ede5386829..369c0ec84a9 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -vv + run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -e native_sensor -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index 4b56c2649e9..32320098a93 100644 --- a/platformio.ini +++ b/platformio.ini @@ -171,6 +171,7 @@ test_ignore = test_kiss_modem test_core_tuning test_server_base + test_sensor_role build_src_filter = -<*> +<../src/Utils.cpp> @@ -234,6 +235,41 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_sensor] +; The sensor role on the host, in its own environment: each role needs a separate +; binary, and the sensor is the one whose behaviour differs from the other two. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I src + -I test/mocks + -I examples/simple_sensor +test_build_src = yes +test_filter = test_sensor_role +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/ClientACL.cpp> + +<../src/helpers/CommonCLI.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/IdentityStore.cpp> + +<../src/helpers/RegionMap.cpp> + +<../src/helpers/ServerMesh.cpp> + +<../src/helpers/StaticPoolPacketManager.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> + +<../examples/simple_sensor/SensorMesh.cpp> + +<../examples/simple_sensor/TimeSeriesData.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_core] platform = native test_framework = googletest diff --git a/test/test_sensor_role/identity_stubs.cpp b/test/test_sensor_role/identity_stubs.cpp new file mode 100644 index 00000000000..0803f0aadae --- /dev/null +++ b/test/test_sensor_role/identity_stubs.cpp @@ -0,0 +1,98 @@ +// Link-time stand-ins for the parts of mesh::Identity that the core and the server +// helpers reference. The real implementations need the board crypto libraries, +// which do not exist on the host. +// +// These are deterministic, NOT cryptographic. The roles sign every advertisement +// they schedule, so a stub that refused to run would turn ordinary operation into a +// test failure; instead each operation derives its output from its inputs, so a +// round trip is self consistent and a test stays repeatable. No test in this suite +// asserts a cryptographic property, and none may: a passing signature here says +// nothing about Ed25519. + +#include +#include + +#include + +namespace { + +// A stable byte mix, so output depends on every input byte. +void derive(uint8_t* dest, size_t dest_len, const uint8_t* a, size_t a_len, + const uint8_t* b, size_t b_len) { + uint8_t acc = 0x5A; + for (size_t i = 0; i < a_len; i++) acc = (uint8_t)(acc * 31 + a[i]); + for (size_t i = 0; i < b_len; i++) acc = (uint8_t)(acc * 31 + b[i]); + for (size_t i = 0; i < dest_len; i++) { + acc = (uint8_t)(acc * 31 + (uint8_t)i); + dest[i] = acc; + } +} + +} // namespace + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + derive(sig, SIGNATURE_SIZE, prv_key, PRV_KEY_SIZE, message, (size_t)msg_len); +} + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + // Cannot be checked without the private key, so accept anything. A test that + // needs to tell a good signature from a bad one must not use this stub. + return true; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + derive(secret, PUB_KEY_SIZE, prv_key, PRV_KEY_SIZE, other_pub_key, PUB_KEY_SIZE); +} + +// The identity file and the identity CLI commands are not exercised here, so these +// only have to satisfy the linker, and any call is a test failure. + +bool Identity::readFrom(Stream& s) { + ADD_FAILURE() << "Identity::readFrom must not be called by the sensor role tests"; + return false; +} + +bool Identity::writeTo(Stream& s) const { + ADD_FAILURE() << "Identity::writeTo must not be called by the sensor role tests"; + return false; +} + +void Identity::printTo(Stream& s) const { + ADD_FAILURE() << "Identity::printTo must not be called by the sensor role tests"; +} + +bool LocalIdentity::readFrom(Stream& s) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the sensor role tests"; + return false; +} + +bool LocalIdentity::writeTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the sensor role tests"; + return false; +} + +void LocalIdentity::printTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::printTo must not be called by the sensor role tests"; +} + +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the sensor role tests"; + return 0; +} + +void LocalIdentity::readFrom(const uint8_t* src, size_t len) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the sensor role tests"; +} + +bool LocalIdentity::validatePrivateKey(const uint8_t prv[64]) { + ADD_FAILURE() << "LocalIdentity::validatePrivateKey must not be called by the sensor role tests"; + return false; +} + +} // namespace mesh diff --git a/test/test_sensor_role/test_sensor_role.cpp b/test/test_sensor_role/test_sensor_role.cpp new file mode 100644 index 00000000000..8f37c4e3433 --- /dev/null +++ b/test/test_sensor_role/test_sensor_role.cpp @@ -0,0 +1,158 @@ +// Characterization tests for the sensor role, which until now had no host +// environment of its own: the repeater and the room server both define MyMesh, so +// each role needs a separate binary. +// +// The sensor is the role that drifted away from the other two (see the ledger, +// finding F2). These tests record what it does today, before the alignment changes +// any of it, so the change can be shown to land exactly where it was meant to. + +#include + +#include +#include +#include +#include + +#include "SensorMesh.h" + +namespace { + +constexpr uint32_t T0 = 1000; + +// SensorMesh is abstract on purpose: the application supplies what to read and how +// to answer a series query. This is the smallest concrete role that satisfies that, +// mirroring the shape of examples/simple_sensor/main.cpp without its battery logic, +// so the tests exercise the role and not an application's choices. +class TestSensor : public SensorMesh { +public: + TestSensor(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, + mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables) + : SensorMesh(board, radio, ms, rng, rtc, tables) { } + + int reads = 0; + + // The role's own decisions, opened up so a test can read them directly. Nothing + // else in the firmware reports any of these. + uint8_t extraAcks() const { return getExtraAckTransmitCount(); } + bool forwards(const mesh::Packet* pkt) { return allowPacketForward(pkt); } + uint32_t floodRetransmitDelay(const mesh::Packet* pkt) { return getRetransmitDelay(pkt); } + uint32_t airtimeFor(int len) { return _radio->getEstAirtimeFor(len); } + NodePrefs* prefs() { return getNodePrefs(); } + +protected: + void onSensorDataRead() override { reads++; } + + int querySeriesData(uint32_t start_secs_ago, uint32_t end_secs_ago, + MinMaxAvg dest[], int max_num) override { + return 0; + } +}; + +class SensorRoleTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr mesh; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + LittleFS.reset(); + rng.begin(1); + + mesh.reset(new TestSensor(board, radio_driver, ms, rng, rtc_clock, tables)); + mesh->begin(&fs); + } +}; + +TEST_F(SensorRoleTest, TheSensorStartsUp) { + // The harness itself, so a failure here is the environment and not the behaviour. + EXPECT_NE(mesh->getNodePrefs(), nullptr); +} + + +// A flood packet carrying a chosen number of path hashes, which is what the hop +// limits are tested against. +static mesh::Packet floodPacketWithHops(uint8_t route_type, uint8_t hops) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = route_type; + pkt.path_len = hops; // one byte per hash at the default path hash size + return pkt; +} + +TEST_F(SensorRoleTest, MultiAcksHasNoEffectToday) { + // The CLI accepts and stores the setting, and SensorMesh::sendAckTo() even has a + // branch guarded by it, but the role never reports a count, so the branch is dead. + mesh->prefs()->multi_acks = 1; + + EXPECT_EQ(mesh->extraAcks(), 0) << "today the sensor sends no extra ACKs"; +} + +TEST_F(SensorRoleTest, OnlyFloodMaxIsAppliedToday) { + mesh->prefs()->disable_fwd = 0; + mesh->prefs()->flood_max = 5; + mesh->prefs()->flood_max_unscoped = 1; // the other roles would stop at one hop + mesh->prefs()->flood_max_advert = 1; + + mesh::Packet pkt = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + + EXPECT_TRUE(mesh->forwards(&pkt)) + << "today the sensor forwards this, because it only looks at flood_max"; +} + +TEST_F(SensorRoleTest, FloodMaxItselfIsApplied) { + mesh->prefs()->disable_fwd = 0; + mesh->prefs()->flood_max = 2; + + mesh::Packet under = floodPacketWithHops(ROUTE_TYPE_FLOOD, 1); + EXPECT_TRUE(mesh->forwards(&under)); + + mesh::Packet at_limit = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + EXPECT_FALSE(mesh->forwards(&at_limit)) << "the limit is reached, not exceeded"; +} + +TEST_F(SensorRoleTest, ForwardingCanBeTurnedOffEntirely) { + mesh->prefs()->disable_fwd = 1; + mesh->prefs()->flood_max = 5; + + mesh::Packet pkt = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + EXPECT_FALSE(mesh->forwards(&pkt)); +} + +TEST_F(SensorRoleTest, TheBoostedGainPrefIsNotAppliedAtBootToday) { + // begin() has already run in SetUp(). The other two roles push the stored value + // to the radio there; the sensor does not, so a value set over the CLI is applied + // at once but is gone after a reboot. + EXPECT_EQ(radio_driver.rx_boost_calls, 0) << "today the sensor applies nothing"; + EXPECT_EQ(mesh->prefs()->rx_boosted_gain, 0) << "and leaves the pref at zero"; +} + +TEST_F(SensorRoleTest, TheRetransmitDelayDrawsFromSixDiscreteSlots) { + // This one is deliberately kept, not aligned: the other roles draw uniformly from + // a continuous range, and upstream still calls that change experimental. Pinned so + // the role parameter cannot be lost by accident. + mesh->prefs()->tx_delay_factor = 1.0f; + + mesh::Packet pkt = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + const uint32_t t = mesh->airtimeFor(pkt.getPathByteLen() + pkt.payload_len + 2); + ASSERT_GT(t, 0u); + + for (int i = 0; i < 200; i++) { + const uint32_t d = mesh->floodRetransmitDelay(&pkt); + ASSERT_EQ(d % t, 0u) << "every delay is a whole multiple of the airtime"; + ASSERT_LE(d / t, 5u) << "and never more than five of them"; + } +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 04967a7cb6932d373d18f9ac20b28af9a5cbccad Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:01:54 +0200 Subject: [PATCH 054/136] Revert the InterfaceType enumerator rename in favour of the upstream proposal This reverts the merge of fix/interface-type-none-macro-clash (2119cc8b). Both fixes for the meshsolar NONE macro collision work; this one is withdrawn because the other one is already proposed upstream as #3510 and the fork should not carry a second, different solution for the same defect. Measured before the swap, with the toolchain of the build environment action: the rename built 29 environments, all 29 building; the upstream proposal builds all five Heltec_mesh_solar environments, including companion_radio_usb and companion_radio_ble, which do not build without either fix. The rename is referenced in exactly one place, the default initializer in src/helpers/MultiSerialInterface.h, so nothing else depends on it. What the rename did better, for the record: the macros ERROR, WARNING, INFO, LOG, DEBUG, FET and S are still defined by the same dependency header, so the next enumerator that happens to share one of those names will hit the same problem. The undef only covers NONE. --- src/helpers/MultiSerialInterface.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helpers/MultiSerialInterface.h b/src/helpers/MultiSerialInterface.h index 87cf4955292..f7742b24c38 100644 --- a/src/helpers/MultiSerialInterface.h +++ b/src/helpers/MultiSerialInterface.h @@ -8,7 +8,7 @@ #endif enum class InterfaceType : uint8_t { - None, + NONE, Bluetooth, USB, WiFi, @@ -20,7 +20,7 @@ class MultiSerialInterface : public BaseSerialInterface { private: struct RegisteredInterface { - InterfaceType type = InterfaceType::None; + InterfaceType type = InterfaceType::NONE; BaseSerialInterface* instance = nullptr; }; From 254e421cb60aa225a0323661f5035035a6d38dff Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:02:45 +0200 Subject: [PATCH 055/136] fix(companion-ui): keep the status bar behind the display guard, drop a leak Two small faults in the companion UI tasks, both in UITask::begin(). ui-tiny dereferenced _display one line outside the guard above it: if (_display != NULL) { _display->turnOn(); } _statusBar.begin(_display->width()); main.cpp leaves disp as NULL when display.begin() returns false (main.cpp:143-146) and passes it straight in (main.cpp:285), so a node whose panel does not answer faults during setup() instead of running headless. U8g2Display::begin() forwards what _u8g2.begin() returns, and variants/lilygo_techo_card is the only variant that builds ui-tiny and it sets DISPLAY_CLASS=U8g2Display, so an unpopulated or dead I2C panel is enough. Every other _display use in the file is guarded, which is what makes this an oversight rather than a decision: lines 517, 532, 699, 746, 760, 775 and 783 all test the pointer first. The call moves inside the guard. Nothing is lost when there is no display: the status bar is only ever drawn through *_display, inside the guard in loop(), and ScrollingStatusBar's constructor initialises every member including _display_width, so the object stays in a defined state when begin() is not called. Checked by walking the block depth of all four _statusBar uses, not by eye: each one now sits under an "if (_display != NULL ...)". ui-orig leaked the strdup of FIRMWARE_VERSION. It is one allocation of a few bytes per boot in a function that runs once, so nothing grows, but the copy only has to outlive the sprintf two lines later and a local array does that without an allocation. StrHelper::strzcpy bounds it to the size of the field it feeds, which also removes the chance of a long FIRMWARE_VERSION walking past _version_info. Both implementations were run against each other on the host for twelve inputs, including a leading dash, several dashes and no dash at all: the resulting _version_info is identical in every case, 0 differences. grep finds no strdup, malloc or calloc left in the function, only the word in the new comment. No test for either change. UITask pulls in the display driver, the board and the mesh, so it does not build on the host, and no board in reach runs ui-tiny (LilyGo T-Echo Card) or ui-orig. The display path in particular is NOT verified at runtime: it needs a T-Echo Card whose panel fails to initialise. --- examples/companion_radio/ui-orig/UITask.cpp | 5 ++++- examples/companion_radio/ui-tiny/UITask.cpp | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index db8772f46e6..8bc5462eae7 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -52,7 +52,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 - char *version = strdup(FIRMWARE_VERSION); + // A local copy, because this runs once at boot and the strdup it replaces was + // never freed; the buffer only has to outlive the sprintf below. + char version[sizeof(_version_info)]; + StrHelper::strzcpy(version, FIRMWARE_VERSION, sizeof(version)); char *dash = strchr(version, '-'); if (dash) { *dash = 0; diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index 7b70f83686c..acab2c5bd7d 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -446,8 +446,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no if (_display != NULL) { _display->turnOn(); + _statusBar.begin(_display->width()); // only ever drawn through *_display, see loop() } - _statusBar.begin(_display->width()); #ifdef PIN_BUZZER From f8866037c71f4df7bce3a597591cc4857809ab29 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:07:50 +0200 Subject: [PATCH 056/136] fix(esp32): return a value from ESPNOWRadio::setRxBoostedGainMode The function declares a bool and never returns one: virtual bool setRxBoostedGainMode(bool) { } Flowing off the end of a value returning function is undefined behaviour. The neighbour on the next line gets it right, and so does the same no-op in the LoRa base class, RadioLibWrappers.h:78, which returns false. How it got there: commit 1f9bb674 ("return bool when setting rx boost") changed the return type across the radio drivers. In this file it changed exactly one line, void to bool, and left the empty body. The same commit gave the other drivers a real return value and CommonCLI.h the branch that reads it. The value is read. CommonRadioPrefs.cpp:126 handles "set radio.rxgain on|off" and picks its reply from it: if (radio_driver.setRxBoostedGainMode(enabled)) { strcpy(reply, "OK"); } else { strcpy(reply, "Error: unsupported"); } mesh::Radio (Dispatcher.h:22) does not declare the function, so that call binds to whichever type radio_driver has in the variant. Two variants give it this driver: variants/generic_espnow/target.cpp:7 and variants/sensecap_indicator-espnow/target.cpp:7. There, the reply comes from whatever happens to be in the return register. false is the truthful answer, because ESP-NOW has no boosted gain mode to switch on, so the command reports "Error: unsupported" rather than claiming success. Returning true would silence the compiler just as well and report success for something that did not happen; no compile check can tell the two apart, which is stated in the note. Reach. The file is compiled widely, the function reaches no image, and the call site above is unreachable in anything that builds: - 39 variant files pull +, so this file is compiled in a great many ESP32 environments, two of them in the build matrix below. - The function is an inline member, and in those variants radio_driver is a LoRa wrapper, so nothing calls it and the linker never emits it. xtensa-esp32-elf-nm on a finished Heltec_v3_companion_radio_ble image finds no ESPNOWRadio symbol, on both sides of this change; the only boosted gain symbols present are CustomSX1262Wrapper's and SX126x's. - The two variants whose radio_driver is an ESPNOWRadio do not build at all: ESP32Board.h:73 returns P_LORA_DIO_1 from getIRQGpio() unconditionally and ESP32Board.cpp:62 uses P_LORA_NSS, while neither variant defines any P_LORA pin. Measured on an unmodified tree, both fail. That defect is not fixed here. Evidence. The framework compiles project sources with -w, so this was invisible to every build that ever ran. Read from the generated compile_commands.json for this file: "-w -Wno-error=unused-function ... -Wwrite-strings". A later -Werror=return-type does not win against it; with the target compiler on the reduced construct, "-w -Werror=return-type" exits 0 and silent while "-Werror=return-type" alone reports the error. A diagnostic patch therefore defines the two pins ESP32Board needs, drops -w through build_unflags and promotes that one check to an error. With it: before: src/helpers/esp32/ESPNOWRadio.h:41:45: error: no return statement in function returning non-void [-Werror=return-type] 1 failed, 0 succeeded after: 1 succeeded, no return-type diagnostic anywhere in the build, and no other error surfacing from dropping -w in that environment Five deliberate errors were each built the same way. Four were caught: no return at all, the return type changed to void, the function removed, and a return on an unreachable path only. The two that fail at the call site prove by measurement that CommonRadioPrefs.cpp:126 really does consume this value in this build. The fifth, returning true, is not caught, and cannot be by a compile check. pio test -e native -e native_kiss_modem: 54 of 54 test cases passed. Build matrix of 18 environments on this commit against a dev worktree with the same toolchain: all 18 built, flash and RAM identical to dev in every one of them, to the byte. In the 16 that never compile the file that is expected; in the two that do, it is because the changed member is not emitted, which the symbol check above establishes. No runtime check applies: no board here has ESP-NOW as its only radio, and no image contains the changed function. --- src/helpers/esp32/ESPNOWRadio.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index f474215a155..bda44a7ebb4 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -38,7 +38,7 @@ class ESPNOWRadio : public mesh::Radio { * These two functions do nothing for ESP-NOW, but are needed for the * Radio interface. */ - virtual bool setRxBoostedGainMode(bool) { } + virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } uint32_t intID(); From 173dc3225a3528329075d881ce680bc8ddb66565 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:08:04 +0200 Subject: [PATCH 057/136] docs(fixes): note for the ESP-NOW boosted gain return value Records the one line change, where the defect came from (commit 1f9bb674), why false and not true, the three alternatives that were rejected, and how far the defect reaches: compiled in many ESP32 environments, emitted in none of them, and reachable only from two variants that do not build. Also records two pre-existing defects found while looking for an environment that compiles the file, neither of them fixed here: ESP32Board reaches for the LoRa pins unconditionally, which makes an ESP-NOW-only variant impossible to build, and ESPNOWRadio.cpp does not match the ESP-IDF 5.x esp_now_recv_cb_t signature, which is why every C6 environment excludes the file again. Adds the diagnostic patch the compile check needs. Project sources are compiled with -w, so the missing return is invisible; the patch defines the two pins, drops -w and promotes that one check to an error. It is a test aid and part of no fix, kept beside nodetool.py like diag-tuning.patch. This commit is documentation only and can be dropped when the fix is proposed upstream. --- docs/fixes/espnow-rxgain-return.md | 260 +++++++++++++++++++++ tools/refactoring/diag-espnow-rxgain.patch | 20 ++ 2 files changed, 280 insertions(+) create mode 100644 docs/fixes/espnow-rxgain-return.md create mode 100644 tools/refactoring/diag-espnow-rxgain.patch diff --git a/docs/fixes/espnow-rxgain-return.md b/docs/fixes/espnow-rxgain-return.md new file mode 100644 index 00000000000..caa41c0eb3e --- /dev/null +++ b/docs/fixes/espnow-rxgain-return.md @@ -0,0 +1,260 @@ +# Fix: ESPNOWRadio::setRxBoostedGainMode returns no value + +Status: ready for review. Behaviour change: none observable today, see Reach below. + +## Context + +`src/helpers/esp32/ESPNOWRadio.h:41` declares a `bool` and never returns one: + +```cpp +virtual bool setRxBoostedGainMode(bool) { } +``` + +Flowing off the end of a value returning function is undefined behaviour. The +comment above it says both functions "do nothing for ESP-NOW", which is the right +intent, but the neighbour on the next line gets it right (`getRxBoostedGainMode() +const { return false; }`) and so does the same no-op in the LoRa base class, +`RadioLibWrappers.h:78`. + +The value is read. `src/helpers/CommonRadioPrefs.cpp:126` handles the CLI command +`set radio.rxgain on|off` and decides the reply from it: + +```cpp +if (radio_driver.setRxBoostedGainMode(enabled)) { + strcpy(reply, "OK"); +} else { + strcpy(reply, "Error: unsupported"); +} +``` + +`mesh::Radio` (`src/Dispatcher.h:22`) does not declare `setRxBoostedGainMode`, so +this call binds statically to whichever type `radio_driver` has in the variant. +Two variants give it the ESP-NOW driver: + +- `variants/generic_espnow/target.cpp:7` +- `variants/sensecap_indicator-espnow/target.cpp:7` + +On those, `set radio.rxgain on` answers from whatever happens to be in the return +register. Three other callers ignore the result, so they are unaffected in +practice: `examples/companion_radio/MyMesh.cpp:1007`, +`examples/simple_repeater/MyMesh.cpp:983`, +`examples/simple_room_server/MyMesh.cpp:727`. + +## How it got there + +Commit 1f9bb674 (26 June 2026, "return bool when setting rx boost") changed the +return type across the radio drivers. In this file it changed exactly one line: + +``` +- virtual void setRxBoostedGainMode(bool) { } ++ virtual bool setRxBoostedGainMode(bool) { } +``` + +The signature became a `bool` and the empty body stayed empty. The same commit +gave the other drivers a real return value, and `CommonCLI.h` the branch that +reads it. So this is not an oversight in a new file, it is one line of a sweep +that the compiler could not flag, because the framework builds project sources +with `-w` (measured under Verification). + +## Decision + +Return `false`, character for character the same as the no-op in +`RadioLibWrappers.h:78`: + +```cpp +virtual bool setRxBoostedGainMode(bool) { return false; } +``` + +`false` is the truthful answer: ESP-NOW has no boosted gain mode to switch on, so +`set radio.rxgain on` should report `Error: unsupported` rather than claim success. + +## Alternatives considered + +- **Return `true`.** It would silence the compiler just as well and is what an + author optimising for a quiet CLI might pick. Rejected: it would report success + for something that did not happen. Note that no compile check can tell these two + apart, see the gap in the mutation table below. +- **Make the function `void`.** Honest about doing nothing, but then the caller in + `CommonRadioPrefs.cpp` no longer compiles (mutation 3 below shows exactly that + error), so it would have to grow a driver specific branch. More code for no gain. +- **Declare the pair in `mesh::Radio` and give the base a `false` default.** This + is the real cure for the underlying shape, since every driver currently invents + the same two functions. Rejected here: it touches the central radio interface + and every driver, which is a refactoring step and not a fix for undefined + behaviour in one line. + +## Reach: compiled widely, emitted nowhere, reachable in nothing that builds + +The file is compiled in many environments, the changed function reaches no image, +and the one call site that reads its value belongs to two variants that do not +build. Each of those three steps is measured below, because the easy version of +this claim is wrong: unchanged binary sizes do not mean a file was not compiled. + +1. **The two ESP-NOW-only variants do not compile.** + `src/helpers/ESP32Board.h:73` returns `P_LORA_DIO_1` from `getIRQGpio()` + unconditionally, and `src/helpers/ESP32Board.cpp:62` uses `P_LORA_NSS`. Neither + variant defines any `P_LORA_*` pin, because neither has a LoRa radio. Measured + on unmodified `upstream/dev` 428a95c6: + + ``` + Generic_ESPNOW_repeatr: 1 failed, 0 succeeded + SenseCapIndicator-ESPNow_comp_radio_usb: 1 failed, 0 succeeded + both: src/helpers/ESP32Board.h:73:12: error: 'P_LORA_DIO_1' was not declared in this scope + ``` + +2. **`ESPNOWRadio.cpp` does not compile against ESP-IDF 5.x.** On the C6 platform + the ESP-NOW receive callback changed shape: + + ``` + src/helpers/esp32/ESPNOWRadio.cpp:40:28: error: invalid conversion from + 'void (*)(const uint8_t*, const uint8_t*, int)' to 'esp_now_recv_cb_t' + {aka 'void (*)(const esp_now_recv_info*, const unsigned char*, int)'} + src/helpers/esp32/ESPNOWRadio.cpp:68:3: error: 'esp_efuse_mac_get_default' was not declared in this scope + ``` + + That is why every C6 environment that pulls in `helpers/esp32/*.cpp` excludes + this one file again: `variants/lilygo_tlora_c6/platformio.ini:82`, + `variants/m5stack_unit_c6l/platformio.ini:86` and `:104`, + `variants/xiao_c6/platformio.ini:66`, `:121` and `:183`. + +`arduino_base.build_src_filter` matches `helpers/*.cpp` and does not recurse, so +an environment picks the file up only if it asks for `helpers/esp32/*.cpp`. 39 +variant files do exactly that, so the file is compiled in a great many ESP32 +environments, two of them inside this fix's own build matrix: + +``` +.pio/build/Heltec_v3_companion_radio_ble/src/helpers/esp32/ESPNOWRadio.cpp.o +.pio/build/Heltec_v3_companion_radio_wifi/src/helpers/esp32/ESPNOWRadio.cpp.o +``` + +What those builds do not contain is the function. It is an inline member, and in +those variants `radio_driver` is a LoRa wrapper, so nothing calls the ESP-NOW one +and the linker never emits it. `xtensa-esp32-elf-nm` on the finished +`Heltec_v3_companion_radio_ble` image finds no `ESPNOWRadio` symbol at all, and +the only boosted gain symbols present belong to the SX1262 path: + +``` +ESPNOWRadio symbols in the image: 0 +42009d04 W _ZN19CustomSX1262Wrapper20setRxBoostedGainModeEb +42015c28 T _ZN6SX126x20setRxBoostedGainModeEbb +``` + +That, and not an absent compile, is why both environments are byte identical with +and without this change (1666437 / 194836 and 1102677 / 172376 on each side). + +The one call site that reads the value, `CommonRadioPrefs.cpp:126`, binds to +`ESPNOWRadio` only where `radio_driver` is one, which is the two ESP-NOW-only +variants, and those do not build. So the defect is undefined behaviour that sits +in many translation units, reaches no image, and is reachable in nothing that +builds today. + +It is still worth fixing: the file is the ESP-NOW driver, whoever repairs the two +defects above should not inherit this one, and until then every ESP32 companion +build carries a translation unit the standard calls undefined. + +## Compatibility + +None affected. The two environments of the build matrix that compile the file are +byte identical with and without the change, because the inline member is never +called there and so never emitted; the other 16 do not compile it at all. Both +facts are measured under Reach, and the matrix figures are under Verification. + +## Verification + +### Compile check + +Two obstacles had to be cleared to get a deterministic signal, and both are +interesting in their own right. + +The framework compiles project sources with `-w`, which suppresses every warning. +Read from the generated `compile_commands.json` for `ESPNOWRadio.cpp`: + +``` +-w -Wno-error=unused-function -Wno-error=unused-variable ... -Wwrite-strings +``` + +A later `-Werror=return-type` does not win against it. Measured with the exact +target compiler on the reduced construct: + +``` +riscv32-esp-elf-g++ -w -Werror=return-type -> rc=0, silent +riscv32-esp-elf-g++ -Werror=return-type -> rc=1, error: no return statement + in function returning non-void +``` + +So this defect was invisible to every build that ever ran, by configuration. + +The diagnostic patch `tools/refactoring/diag-espnow-rxgain.patch` therefore does +three things to `variants/generic_espnow/platformio.ini`: it defines the two LoRa +pins that `ESP32Board` needs, drops `-w` through `build_unflags`, and promotes +`return-type` to an error. It is a test aid and part of no fix. + +Before the fix, `Generic_ESPNOW_repeatr` with that patch: + +``` +src/helpers/esp32/ESPNOWRadio.h:41:45: error: no return statement in function returning non-void [-Werror=return-type] +==================== 1 failed, 0 succeeded in 00:00:20.442 ==================== +``` + +After the fix, same patch, same environment: + +``` +========================= 1 succeeded in 00:00:40.118 ========================= +``` + +with no `return-type` diagnostic left anywhere in the build, and no other error +surfacing from dropping `-w` in that environment. + +### Mutation tests + +Five deliberate errors, each built with the diagnostic patch. + +| # | Mutation | Result | +|---|----------|--------| +| 1 | no return at all (the original defect) | caught: `error: no return statement in function returning non-void` | +| 2 | `return true;` | **not caught**, see below | +| 3 | return type changed to `void` | caught: `error: could not convert 'radio_driver.ESPNOWRadio::setRxBoostedGainMode(...)'` | +| 4 | function removed | caught: `error: 'class ESPNOWRadio' has no member named 'setRxBoostedGainMode'` | +| 5 | `if (false) return false;` | caught: `error: control reaches end of non-void function` | + +Mutations 3 and 4 are worth more than their pass marks: they prove by measurement +that `CommonRadioPrefs.cpp:126` really does consume this value in this build, +rather than leaving that as a reading of the source. + +Mutation 2 is a genuine blind spot. A compile check can prove that a value is +returned, not that it is the right one. Catching it would need a host test that +instantiates `ESPNOWRadio` and asserts `false`, which needs stand-ins for the +twelve member functions that live in `ESPNOWRadio.cpp`. That is not justified for +asserting one constant, so the argument for `false` rather than `true` rests on +the reasoning under Decision, not on a test. + +### Host tests + +`pio test -e native -e native_kiss_modem`: 54 of 54 test cases passed. The suites +`native_core` and `native_ofast` do not exist on this base. + +### Builds + +The 18 environment matrix on this commit against a `dev` worktree with the same +toolchain, including `RAK_3112_repeater_bridge_espnow` (which compiles +`helpers/bridges/ESPNowBridge.cpp`, not this driver). Flash and RAM identical +everywhere: in the 16 environments that never compile the file, and in the two +that do, because the changed member is not emitted there. + +### Hardware + +Not applicable, and not merely unavailable: there is no board here whose only +radio is ESP-NOW, and the two variants that would instantiate this driver do not +build at all. No image contains the changed function, which the symbol check +under Reach establishes, so there is nothing to observe at runtime. + +## Follow-ups + +Not part of this fix, listed because they were measured while verifying it: + +- `ESP32Board` reaches for `P_LORA_*` unconditionally, which makes an ESP-NOW-only + variant impossible to build. +- `ESPNOWRadio.cpp` needs updating for the ESP-IDF 5.x `esp_now_recv_cb_t` + signature and a missing include for `esp_efuse_mac_get_default`. +- Project sources are compiled with `-w`. A defect like this one cannot be caught + while that holds. diff --git a/tools/refactoring/diag-espnow-rxgain.patch b/tools/refactoring/diag-espnow-rxgain.patch new file mode 100644 index 00000000000..c495d12b31d --- /dev/null +++ b/tools/refactoring/diag-espnow-rxgain.patch @@ -0,0 +1,20 @@ +diff --git a/variants/generic_espnow/platformio.ini b/variants/generic_espnow/platformio.ini +index 928d5315..c6fb9839 100644 +--- a/variants/generic_espnow/platformio.ini ++++ b/variants/generic_espnow/platformio.ini +@@ -13,6 +13,15 @@ build_flags = + -D PIN_BOARD_SCL=-1 + ; -D P_LORA_TX_LED=8 + -D PIN_USER_BTN=0 ++; DIAGNOSTIC ONLY: ESP32Board references the LoRa pins unconditionally ++; (ESP32Board.h:73, ESP32Board.cpp:62), so an ESP-NOW-only variant ++; cannot compile without them. Dummy values, compile check only. ++ -D P_LORA_DIO_1=0 ++ -D P_LORA_NSS=0 ++; DIAGNOSTIC ONLY: the framework compiles project sources with -w, which ++; beats any later -Werror=. Drop -w, then promote this one check. ++ -Werror=return-type ++build_unflags = -w + ; -D ARDUINO_USB_MODE=1 + ; -D ARDUINO_USB_CDC_ON_BOOT=1 + ; -D ESPNOW_DEBUG_LOGGING=1 From e18abec40c5b4e6054c4dbdf0c220e0b6f8258d5 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:11:34 +0200 Subject: [PATCH 058/136] docs(fixes): note for the airtime factor NaN check Records the defect, why only the two negatively phrased guards were reachable with a NaN while the positively phrased ones reject it by accident of phrasing, why the check reads the bits instead of comparing, and the evidence: the host suite in IEEE and -Ofast before and after, five mutations of which two are only caught by the IEEE run, 21 builds against upstream/dev, and the board run that shows the same T-Echo accepting the value before and refusing it now. Also records the two things the merge into integration must resolve by hand: the step 3 expectation that pins "set af nan" as accepted, and the environment name native_ofast that already exists there with a different test filter. Both phases of the root cause chain are in the commit message of the fix. The note is a separate commit so it can be left out of an upstream pull request. --- docs/fixes/af-nan-bit-check.md | 232 +++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/fixes/af-nan-bit-check.md diff --git a/docs/fixes/af-nan-bit-check.md b/docs/fixes/af-nan-bit-check.md new file mode 100644 index 00000000000..9a91051915b --- /dev/null +++ b/docs/fixes/af-nan-bit-check.md @@ -0,0 +1,232 @@ +# "set af nan" was accepted + +## Context + +`set af` parses its argument and then checks the range: + +```cpp +float af = strtof(&command[7], &end); +if (end == &command[7] || af < 0 || af > 9) { + strcpy(reply, "ERROR: af must be 0-9"); +} else { + setAirtimeFactor(af); + strcpy(reply, "OK"); +} +``` + +(`src/helpers/CommonRadioPrefs.cpp:69-79` on `upstream/dev` 428a95c6.) + +`strtof("nan")` consumes the token, so the parse guard does not fire, and under +IEEE 754 every ordered comparison with a NaN is false, so neither `af < 0` nor +`af > 9` fires either. The else branch stores the NaN and answers `OK`. + +`airtime_factor` is the duty cycle budget. The roles hand it to the airtime +accounting, for the repeater through `examples/simple_repeater/MyMesh.h:157` +with matching getters in the room server and the sensor, so a NaN there is not +cosmetic. `set dutycycle` writes the same field through `100/dc - 1` and had the +same hole. + +Step 3 of the refactoring series already knew about this and pinned it as +current behaviour rather than changing it: `test/mocks/RadioPrefsChecks.h:238` +on `integration` carries `EXPECT_EQ("OK", accepted("set af nan"))` behind +`#ifndef __FAST_MATH__`, with a comment calling it odd. + +### Why only these two commands + +The other float commands in the same file phrase their guard the other way +round, `if (value >= low && value <= high) { accept } else { error }`, and a NaN +makes that condition false, so it lands in the error branch. Measured on the +host with both shapes: + +| token | guard as in `set af` | guard as in `set rxdelay` | +|---|---|---| +| `nan` | **accepted** | error | +| `-nan` | **accepted** | error | +| `inf` | error | error | +| `1e40` | error | error | +| `0.5` | accepted | accepted | + +So on `upstream/dev` the two commands that are reachable with a NaN are exactly +`set af` and `set dutycycle`, the two written as "reject if out of range". That +is also why the board measurement found `af` and nothing else. + +The positively phrased guards are safe by accident of phrasing, not by design: +their result still depends on the compiler once `-ffast-math` is in play, which +is why step 3 put a bit check behind all of them on `integration` +(`inRange`, `src/helpers/CommonRadioPrefs.cpp:65`). + +## Decision + +Keep the parse guard, keep both comparisons, keep both reply strings, and add a +check that the value is a finite number at all. The check reads the bits: + +```cpp +bool isFinite(float v) { + uint32_t bits; + memcpy(&bits, &v, sizeof(bits)); + return (bits & 0x7F800000u) != 0x7F800000u; +} +``` + +That is integer arithmetic, so no optimisation can fold it away, and it answers +the same on every platform with any flags. The same guard goes in front of +`set dutycycle`, whose range stays 1..100. + +### Why not a comparison or a library call + +nRF52 firmware is built with `-Ofast`, which includes `-ffast-math`, and that +lets the compiler assume NaN and infinity never occur. The outcome then depends +on the compiler, and the two disagree. Measured with the same test file: + +| | `set af nan` on `upstream/dev` | +|---|---| +| host, IEEE comparisons | accepted | +| host, `-Ofast` (Apple clang 21) | rejected | +| board, nRF52 with `arm-none-eabi-gcc -Ofast` | accepted | + +A host run with `-Ofast` alone would therefore have reported this defect as +absent. `std::isnan` is no better: it is the same comparison in a different +spelling and is equally free to be folded away. + +## Alternatives considered + +- **Compile this file without fast math.** Rejected. It would hide the problem + behind a build flag that any variant can change, and the defect exists under + plain IEEE rules as well, which is where it was measured. +- **Take the table driven `inRange` from step 3.** Not available here: this + branch starts from `upstream/dev`, where the radio CLI is still written out by + hand. The two will meet when this branch is merged, and the shape of the check + is deliberately the same so they can be unified. +- **Refuse the value one layer down, in the parser.** That is the right place + for a value read from `/prefs.json` and is what the range check fix does. It + is the wrong place here: this value comes from a CLI command and the caller + has to be told that its command was refused. +- **Also rewrite the positively phrased guards.** Not done, see above: they + reject a NaN under IEEE, and on `integration` step 3 has already put the bit + check behind them. Changing them here would widen a fix whose defect is the + two commands measured above. + +## Compatibility + +- Every value that was accepted before is still accepted, including the 99 that + `set dutycycle 1` stores, and every rejected value is still rejected with the + same message. Only values that are not finite numbers change from accepted to + refused. +- Server roles and the companion share this handler + (`src/helpers/CommonCLI.cpp:184` and + `examples/companion_radio/NodePrefs.h`), so one change covers every role. + +## Verification + +### Host tests + +New `test/test_radio_prefs_af` drives `CommonRadioPrefs::handleCommand` through +a test double. Nine cases: every spelling of not a number, infinity and +overflow, the garbage and out of range cases that must stay rejected with their +old message, the range that must stay accepted, `get af`, and the duty cycle +range including the 99 that step 3 pinned. + +The suite runs twice, in `native` with IEEE comparisons and in the new +`native_ofast` with the flags the nRF52 firmware uses: + +| | before the change | after | +|---|---|---| +| `native` (IEEE) | 2 of 9 red | 9 of 9 green | +| `native_ofast` | 9 of 9 green | 9 of 9 green | + +The second row is the point of the environment, not a contradiction: on this +host `-Ofast` already rejects the NaN, so an `-Ofast` run alone would have +shown nothing. Both rows green afterwards is what says the result no longer +depends on the flags. + +All suites of this branch: `pio test -e native -e native_kiss_modem -e +native_ofast` reports 70 of 70. + +### Mutations + +Five deliberate errors, each had to turn the suite red. Two of them are only +caught by the IEEE run, which is worth recording: + +| Mutation | `native` (IEEE) | `native_ofast` | +|---|---|---| +| `set af` without the finite check | red | green | +| `set dutycycle` without the finite check | red | green | +| bit comparison inverted | red (4) | red (2) | +| wrong mask | red | red | +| mask compared against zero | red (3) | red | + +### Builds + +All 20 environments of the matrix build on this commit, and so does +`upstream/dev` 428a95c6 in its own worktree with the same toolchain. +`Heltec_v3_terminal_chat` was built as well, because it is the one role with its +own prefs structure, to show it is untouched. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +32 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +32 | +| `Heltec_v3_repeater` | OK | +0 | +32 | +| `Heltec_v3_room_server` | OK | +0 | +28 | +| `Heltec_v3_sensor` | OK | +0 | +28 | +| `LilyGo_T-Echo_kiss_modem` | OK | +0 | +0 | +| `LilyGo_T-Echo_room_server` | OK | +0 | +32 | +| `PicoW_repeater` | OK | +0 | +24 | +| `ProMicro_companion_radio_usb` | OK | +0 | +32 | +| `ProMicro_repeater` | OK | +0 | +32 | +| `ProMicro_room_server` | OK | +0 | +32 | +| `ProMicro_sensor` | OK | +0 | +32 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +32 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +32 | +| `RAK_4631_repeater` | OK | +0 | +32 | +| `RAK_4631_room_server` | OK | +0 | +32 | +| `RAK_4631_sensor` | OK | +0 | +32 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +24 | +| `wio-e5-mini_repeater` | OK | +0 | +24 | +| `wio-e5-mini_sensor` | OK | +0 | +24 | +| `Heltec_v3_terminal_chat` | OK | +0 | +0 | + +RAM is unchanged everywhere, and flash grows by 24 to 32 bytes in every build +that compiles this handler. Exactly two builds come out byte identical to dev, +and both are explained rather than suspicious: `LilyGo_T-Echo_kiss_modem` adds +only `examples/kiss_modem/` to its sources, and `Heltec_v3_terminal_chat` runs +`examples/simple_secure_chat`, which keeps its own prefs structure and never +reaches `CommonRadioPrefs`. + +### Hardware + +techo-b43b, `LilyGo_T-Echo_room_server`, both images marked and each marker +confirmed in the image before flashing +(`unzip -p firmware.zip '*.bin' | grep -c -a ` = 1) and reported by `ver` +in the same run that produced the readings. + +| command | `dev-428a95c6` | `af-nan-wt` | +|---|---|---| +| `set af nan` | `OK` | `ERROR: af must be 0-9` | +| `get af` afterwards | `> 0` | `> 1.0`, unchanged | +| `set af -nan` | not measured | `ERROR: af must be 0-9` | +| `set af inf` | not measured | `ERROR: af must be 0-9` | +| `set af abc` | not measured | `ERROR: af must be 0-9` | +| `set dutycycle nan` | `OK - 0.0%` | `ERROR: dutycycle must be 1-100` | +| `get af` afterwards | `> 0` | `> 2.0`, unchanged | +| `set af 2` | not measured | `OK`, `get af` `> 2.0` | +| `set dutycycle 1` | not measured | `OK - 1.0%`, `get af` `> 99.0` | + +Worth recording: on `dev` the poisoned field reads back as `> 0`, so nothing on +the CLI showed that it held a NaN. The board was left on the values it came +with, `af 1.0` and `dutycycle 50.0%`. + +## Follow-ups + +- **The merge into `integration` needs a hand.** Two things there are not a + textual conflict and must not be resolved by taking one side: + `test/mocks/RadioPrefsChecks.h:238-242` expects `set af nan` to be accepted + and has to be turned into a rejection, and `integration` already has an + environment called `native_ofast` with a different `test_filter`, which has to + become the union of both rather than either one. +- `isFinite` now exists twice with the same shape, here and in + `ConfigSerializer.cpp` from the range check fix. Worth unifying once both are + on `integration`. +- `set radio` parses `freq` and `bw` with `strtof` and discards the end pointer + entirely, so `set radio abc def 8 5` is read as zeros and rejected by the + range check rather than by the parse. Harmless today, and a separate change. From 1895ea84d3b12a8fa764ce413f702e50e89e3d6c Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:25:58 +0200 Subject: [PATCH 059/136] docs(fixes): note for the frame length guards Describes the stale data mechanism, the minimum each of the fourteen commands needs and where it comes from, the before and after runs on hardware, the two related defects that are deliberately left out, and the builds. Also records what the hardware runs do not cover, that a frame of exactly the minimum length was only exercised for six of the fourteen, and the three readings that close that gap without a board. Kept in its own commit so an upstream pull request can leave it out. --- docs/fixes/companion-cmd-frame-min-len.md | 246 ++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/fixes/companion-cmd-frame-min-len.md diff --git a/docs/fixes/companion-cmd-frame-min-len.md b/docs/fixes/companion-cmd-frame-min-len.md new file mode 100644 index 00000000000..17542fa0bac --- /dev/null +++ b/docs/fixes/companion-cmd-frame-min-len.md @@ -0,0 +1,246 @@ +# Fourteen companion commands act on the previous frame's bytes + +Status: fixed, measured on hardware for 8 of the 14 commands. Behaviour change: +a frame too short for its command is answered with an error instead of being +parsed. + +## Symptom + +`handleCmdFrame()` reads the fields of each command at fixed offsets derived from +the command code. Thirty-five of its sixty-two branches state the length they +need in the condition, for example `CMD_SEND_TXT_MSG && len >= 14`. Fourteen do +not, or test `cmd_frame[1]` before the length. Those branches read fields that +the frame does not contain. + +What they read is not uninitialised memory. `cmd_frame` is a member of `MyMesh` +(`examples/companion_radio/MyMesh.h:267`), `ArduinoSerialInterface::rx_buf` is a +member too (`src/helpers/ArduinoSerialInterface.h:12`), and `checkRecvFrame()` +copies only `_frame_len` bytes out (`.cpp:66`). Everything above `len` is +therefore the tail of an earlier frame, and that is what the handler acts on. + +For these fourteen commands no read leaves the buffer: their largest +unconditional offset is 135 and the buffer holds `MAX_FRAME_SIZE + 1 = 177` +bytes. `checkRecvFrame()` also clamps `len` to `MAX_FRAME_SIZE` and never returns +0, so `len` is always between 1 and 176. For them this is a stale data bug, not +an out of bounds bug. + +Two related defects in the same function are deliberately not part of this +change, because they are a different fault and each deserves its own review: + +- `CMD_SEND_CHANNEL_TXT_MSG` turns the length itself into the argument of a + `memcpy`. That one is `docs/fixes/companion-channel-txt-min-len.md`. +- `CMD_SET_DEFAULT_FLOOD_SCOPE` calls `strlen((char *) &cmd_frame[1])` with no + bound. `len` is clamped to 176, so a 176 byte frame of non-zero bytes fills + offsets 0 to 175, and if the stale byte at 176 is non-zero too there is no zero + left in `cmd_frame`. The scan then runs past the array into `out_frame`, which + is the next member, and stops at the first zero there. The `strcpy` that follows + is safe, because `n < 31` is tested and the field is `char[31]`, and `n` is + never returned, so nothing leaks and nothing is written out of bounds. It is + still an unbounded read of data the host controls, and a length check cannot fix + it: it needs `strnlen` or a scan limited to `len`. + +## The minimums + +Each is the offset just past the last byte its branch reads without first testing +`len`. + +| Command | Code | Minimum | What it reads unconditionally | +|---|---:|---:|---| +| `CMD_SEND_CHANNEL_DATA` | 62 | 3 + path + 2 | `channel_idx`, `path_len`, the path, then the 2 byte `data_type` | +| `CMD_ADD_UPDATE_CONTACT` | 9 | 136 | `updateContactFromFrame()` reads to offset 135 | +| `CMD_REMOVE_CONTACT` | 15 | 33 | `pub_key[1..32]` | +| `CMD_SHARE_CONTACT` | 16 | 33 | `pub_key[1..32]` | +| `CMD_GET_CONTACT_BY_KEY` | 30 | 33 | `pub_key[1..32]` | +| `CMD_SET_RADIO_PARAMS` | 11 | 11 | `freq`, `bw`, `sf`, `cr`; the repeat flag stays optional | +| `CMD_SET_RADIO_TX_POWER` | 12 | 2 | `power` | +| `CMD_SET_TUNING_PARAMS` | 21 | 9 | `rx_delay_base`, `airtime_factor`, neither range checked | +| `CMD_SET_OTHER_PARAMS` | 38 | 2 | `manual_add_contacts`; the rest stays optional | +| `CMD_SET_PATH_HASH_MODE` | 61 | 3 | operand order only, the constant was already there | +| `CMD_REBOOT` | 19 | 7 | `memcmp` against `"reboot"` | +| `CMD_FACTORY_RESET` | 51 | 6 | `memcmp` against `"reset"` | +| `CMD_SET_AUTOADD_CONFIG` | 58 | 2 | `autoadd_config`; `autoadd_max_hops` stays optional | +| `CMD_SEND_PATH_DISCOVERY_REQ` | 52 | 34 | operand order only, the constant was already there | + +`CMD_ADD_UPDATE_CONTACT` is the only branch whose existing constant is raised, 36 +to 136. `updateContactFromFrame()` reads `type`, `flags`, `out_path_len`, 64 bytes +of `out_path`, 32 bytes of `name` and the 4 byte `last_advert_timestamp` without +testing `len`; only `gps_lat`, `gps_lon` and `lastmod` are optional +(`MyMesh.cpp:2048-2070`). No valid frame can be shorter: the reverse direction +`writeContactRespFrame()` writes the same fields in the same order and sends 148 +bytes. + +## Fix + +Twelve branches gain `&& len >= N` in the condition, so a short frame falls +through to the existing final `else` and is answered the way the 35 already +guarded branches answer it. `CMD_SET_PATH_HASH_MODE` and +`CMD_SEND_PATH_DISCOVERY_REQ` only have their existing length test moved in front +of the `cmd_frame[1]` test. `CMD_SEND_CHANNEL_DATA` keeps its explicit `len < 4` +check and gains a second one for the `data_type` behind the path, whose offset is +only known once `path_len` has been parsed; that is the shape +`CMD_SEND_RAW_DATA` already uses in the same function. + +### Alternatives considered + +- **A central table of minimum lengths**, checked once at the top of + `handleCmdFrame()` and answering `ERR_CODE_ILLEGAL_ARG`. The better long term + shape: one place for the minimums, nothing for the next command to forget, and + host testable. Left out of this fix because it changes the answer for the 35 + branches that already guard, from `ERR_CODE_UNSUPPORTED_CMD` to + `ERR_CODE_ILLEGAL_ARG`, which is a visible protocol change and does not belong + in the same change as the guards themselves. Worth proposing on its own. +- **Clearing `cmd_frame` between frames.** Rejected: it hides the bug instead of + fixing it. A command would then act on zeros rather than on the previous + frame, which for `CMD_SET_TUNING_PARAMS` means writing 0 to the prefs and for + `CMD_REMOVE_CONTACT` means a lookup for the all zero key. Both are still wrong, + and the cost is a memset per frame. +- **Answering `ERR_CODE_ILLEGAL_ARG` from each new guard** through an explicit + check and early return, as `CMD_SEND_CHANNEL_DATA` does. Rejected for the + twelve: it adds twelve new code paths where extending the condition adds none, + and it makes this change inconsistent with the 35 neighbours. + +## Verification + +### No host test, and that was measured + +There is no host test for this change. The `native_chat` environment added for +the group message length does not build `MyMesh.cpp`, and compiling it on the +host would need stand-ins for `target.h`, the board, the radio driver, the +sensors, the telemetry and the store. + +That the host suite cannot see this file was measured rather than assumed: with +`len >= 7` in the neighbouring branch mutated to `len >= 1`, `native_chat` still +reports 8 of 8 passing. The suites that do exist stay green: + +``` +native test_mesh_tables PASSED +native test_config_serializer PASSED +native test_utils PASSED +native test_utf8_helpers PASSED +native test_routing_policy PASSED +native_kiss_modem test_kiss_modem PASSED +54 test cases: 54 succeeded in 00:00:51.743 +``` + +### Hardware, before and after + +LilyGo T-Echo `techo-3dcf`, USB companion build, images marked through +`FIRMWARE_BUILD_DATE` and confirmed by `ver` before each run: `f1-dev` for +`dev` 428a95c6, `f1-fix2` for this branch. Every step first sends a longer frame, +so the stale tail is known, and then a frame too short for the command. + +| Step | On `dev` | On this branch | +|---|---|---| +| `[21]` after a CLI frame carrying `"get name"` | tuning (0, 1000) becomes (544499136, 1701667200) | `ERR_CODE_UNSUPPORTED_CMD`, tuning stays (0, 1000) | +| `[12]` after `GET_CHANNEL 5` | tx_power 22 becomes 5 | error, tx_power stays 22 | +| `[38]` after `GET_CHANNEL 0` | manual_add 1 becomes 0 | error, manual_add stays 1 | +| `[58]` after `GET_CHANNEL 0` | autoadd (5, 0) becomes (0, 0) | error, autoadd stays (5, 0) | +| `[30]` after a full keyed frame | a complete contact record is returned | error, nothing returned | +| `[62]` with len 4 | `RESP_CODE_OK`, a datagram goes out with a `data_type` built from a stale byte | `ERR_CODE_ILLEGAL_ARG` | +| `[9]` with len 36 after a 141 byte CLI frame of `"Z"` | a contact is created with `out_path` = 5a5a..., `name` = "ZZZZ...Z", `last_advert_timestamp` = 0x5a5a5a5a | error, no contact created | +| `[19]` after a CLI frame carrying `"reboot"` | uptime 258 s becomes 19 s, the node rebooted | error, uptime 68 s becomes 73 s, no restart | +| `[61]`, `[52]` with len 1 | `ERR_CODE_UNSUPPORTED_CMD` | unchanged, as expected: the only fault there is one read above `len` | + +The `[21]` numbers are worth spelling out, because they identify the source +exactly. The stale bytes are `"get "` and `"name"`, which as little endian +uint32 are 544499047 and 1701667182. The prefs hold them as float and the read +multiplies by 1000 again; the same round trip on the host gives 544499072 and +1701667200. `"name"` matches to the byte, `"get "` is one float32 step out, which +is what the nRF52 build allows because it compiles with `-Ofast` +(KB `nrf52-ofast-nan.md`). + +`CMD_FACTORY_RESET` was deliberately not triggered on hardware. It formats the +file system and takes identity, prefs, contacts and channels with it. It is the +same mechanism as `CMD_REBOOT`, which is shown above: a frame carrying the text +at offset 1, then a one byte frame with the command code. + +Valid frames still work on this branch, checked at the exact minimum where the +command allows it: `[21]` with 9 bytes, `[12]` with 2, `[58]` with 3, `[61]` with +3, `[15]` with 33 and `[30]` with 33 all answered as before, and the node stayed +up throughout. + +Afterwards every value was set back and read again: tuning (0, 1000), tx_power +22, manual_add 1, freq 869618, sf 8, cr 8, autoadd (5, 0), path hash mode 2, BLE +pin 207263, default scope "hansemesh" with its key, 68 contacts, and all three +test contacts removed. Fifteen checks, all matching the state recorded before the +runs. + +### What the hardware runs do not cover, and how it is covered instead + +The runs show that a short frame is refused. They do not show, for every command, +that a frame of exactly the minimum length is still accepted; that was exercised +for six of them (above) and not for the rest, because the board had to go back to +another session. Three further readings close that gap: + +- Two of the fourteen are not new constants at all. `CMD_SET_PATH_HASH_MODE` + already required `len >= 3` and `CMD_SEND_PATH_DISCOVERY_REQ` already required + `len >= 2 + PUB_KEY_SIZE`; only the operand order changed, so no number can be + wrong there. +- Every sender in this repository was checked against the minimums. + `tools/refactoring/nodetool.py:141` sends `CMD_REBOOT` as exactly seven bytes, + `[19]` followed by `"reboot"`, which is exactly the minimum: a guard one higher + would have broken it. `docs/companion_protocol.md` gives + `3E 01 FF FF FF A1 B2 C3` for `CMD_SEND_CHANNEL_DATA` (8 bytes against a + minimum of 5) and `03 00 01 D2 02 96 49 48 65 6C 6C 6F` for + `CMD_SEND_CHANNEL_TXT_MSG` (12 against 7). +- For the rest the constant is, by construction, the offset just past the last + byte the branch reads without testing `len`. A frame that contains the + command's own mandatory fields satisfies it. The one raised constant, + `CMD_ADD_UPDATE_CONTACT`, is additionally pinned by the reverse direction + described above. + +### Builds + +The 18 environments of the review series plus both T-Echo companion environments +were built from `dev` (428a95c6) and from this branch with the same toolchain. + +All 20 build on both sides. 14 report the same flash and RAM size as `dev`. + +| Environment | Flash `dev` | Flash fix | Flash delta | RAM delta | +|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | 1,174,449 | 1,174,449 | +0 | +0 | +| `Heltec_v3_room_server` | 1,169,705 | 1,169,705 | +0 | +0 | +| `Heltec_v3_companion_radio_ble` | 1,666,437 | 1,666,393 | -44 | +0 | +| `Heltec_v3_companion_radio_wifi` | 1,102,677 | 1,102,633 | -44 | +0 | +| `Heltec_v3_sensor` | 1,180,593 | 1,180,593 | +0 | +0 | +| `RAK_4631_repeater` | 440,952 | 440,952 | +0 | +0 | +| `RAK_4631_room_server` | 434,088 | 434,088 | +0 | +0 | +| `RAK_4631_companion_radio_ble` | 462,548 | 462,372 | -176 | +0 | +| `RAK_4631_sensor` | 437,544 | 437,544 | +0 | +0 | +| `PicoW_repeater` | 491,972 | 491,972 | +0 | +0 | +| `wio-e5-mini_repeater` | 203,552 | 203,552 | +0 | +0 | +| `wio-e5-mini_sensor` | 202,904 | 202,904 | +0 | +0 | +| `RAK_3112_repeater_bridge_espnow` | 1,175,717 | 1,175,717 | +0 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | 240,368 | 240,368 | +0 | +0 | +| `ProMicro_repeater` | 345,424 | 345,424 | +0 | +0 | +| `ProMicro_room_server` | 338,456 | 338,456 | +0 | +0 | +| `ProMicro_companion_radio_usb` | 357,300 | 357,124 | -176 | +0 | +| `ProMicro_sensor` | 341,900 | 341,900 | +0 | +0 | +| `LilyGo_T-Echo_companion_radio_ble` | 374,096 | 373,920 | -176 | +0 | +| `LilyGo_T-Echo_companion_radio_usb` | 366,704 | 366,528 | -176 | +0 | + + +The six that changed are exactly the six that build `MyMesh.cpp`, read from their +`build_src_filter` rather than guessed from the numbers. The other fourteen are +repeater, room server and sensor roles, which do not build the companion example +at all. Their images are not byte identical even so, because the stock +environments do not set `SOURCE_DATE_EPOCH`; the differing bytes are the compile +time string and the two hashes. + +Those 20 cover two of the five platform entries that build `MyMesh.cpp`. Measured +with `pio project config --json-output`, the file is compiled by 222 companion +environments across `nordicnrf52` (93), `platformio/espressif32@6.11.0` (112), +`maxgerhardt/platform-raspberrypi` (7), +`pioarduino/platform-espressif32` 53.03.13-1 (6) and `ststm32@19.7.1` (4). The +20 above only reach the first two, and the fork CI builds the other three +toolchains with repeater environments, which do not compile this file. One +companion environment per missing platform was therefore built as well. + +One companion environment per platform that the 20 above do not cover. All 3 build on both sides. + +| Environment | Flash `dev` | Flash fix | Flash delta | RAM delta | +|---|---:|---:|---:|---:| +| `PicoW_companion_radio_ble` | 633,124 | 633,028 | -96 | -32 | +| `LilyGo_Tlora_C6_companion_radio_ble_` | 1,446,483 | 1,446,549 | +66 | +0 | +| `wio-e5-mini_companion_radio_usb` | 208,024 | 207,992 | -32 | +0 | + From ec04c8c630aeb8cc05fffc3a3900c687533dcd79 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:26:13 +0200 Subject: [PATCH 060/136] docs(fixes): record that the LLCC68 fix is already in upstream PR #3496 The change is character for character in meshcore-dev/MeshCore#3496, which is open and changes 24 files. A separate one line pull request for a line that PR already contains would compete with it, so the note now says not to propose this upstream and points at a comment on #3496 instead. Worth recording how it was found, because a file based search missed it: the GitHub API text search covers only the pull request text, not the files a pull request changes. Searching for the function name sx126xResetAGC turned up nothing, and doResetAGC turned up #3496 only once its diff was read rather than its title. --- docs/fixes/llcc68-reset-agc-args.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/fixes/llcc68-reset-agc-args.md b/docs/fixes/llcc68-reset-agc-args.md index c94fcb9ef1c..679a44c1e3d 100644 --- a/docs/fixes/llcc68-reset-agc-args.md +++ b/docs/fixes/llcc68-reset-agc-args.md @@ -45,6 +45,26 @@ void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMo The line is now character for character the same as in the other three wrappers, which is the point: the four members of one chip family should not differ here. +## Upstream + +Do not propose this as its own upstream pull request. The identical change is +already in the open PR meshcore-dev/MeshCore#3496, "KISS modem: AGC, FEM gain and +RX boosted gain control", character for character: + +``` +- void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } ++ void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } +``` + +That PR is open, not merged, so `dev` still carries the defect and this fork still +needs the fix. But #3496 changes 24 files, and a separate one line PR for a line +it already contains would compete with it. If the fix is wanted upstream sooner, +the useful move is a comment on #3496, not a new pull request. + +Found by searching the upstream repository for the function name rather than the +file: the API text search covers only the pull request text, not the files a pull +request changes, so a search by file name misses this. + ## Alternatives considered - **Pass a literal `false`.** Shorter, but wrong: the other three re-apply the From f8f350b124a7ef4e3aa2d9e3129fe79dd1121dec Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:33:01 +0200 Subject: [PATCH 061/136] docs(fixes): note for persisting extra_sf and the discovery timestamp Records that the discovery timestamp is a regression of the move to /prefs.json, since the binary loader still reads it at offset 162, why the spread factors are stored as a hex blob and written on every board, and the evidence: host tests against the real NodePrefs, five mutations, 21 builds whose flash cost splits exactly along which builds use this NodePrefs, and the board run in both directions. Also records what the board cannot show, with the reason for each, and the follow-up it uncovered: configSideDetectors() is never called at boot, so a persisted extra_sf is not put back on the radio after a restart. The note is a separate commit so it can be left out of an upstream pull request. --- docs/fixes/prefs-extra-sf-disc.md | 224 ++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/fixes/prefs-extra-sf-disc.md diff --git a/docs/fixes/prefs-extra-sf-disc.md b/docs/fixes/prefs-extra-sf-disc.md new file mode 100644 index 00000000000..142ebdc6b27 --- /dev/null +++ b/docs/fixes/prefs-extra-sf-disc.md @@ -0,0 +1,224 @@ +# extra_sf and the discovery timestamp were never persisted + +## Context + +`NodePrefs` holds two fields that no loader and no saver ever touched: + +| Field | Written by | Read by | +|---|---|---| +| `discovery_mod_timestamp` | `src/helpers/CommonCLI.cpp:914`, whenever the node is modified | `examples/simple_repeater/MyMesh.cpp:806` and `examples/simple_sensor/SensorMesh.cpp:730`, to answer "has this node changed since" | +| `extra_sf[4]` | `src/helpers/CommonCLI.cpp:687`, the `extra.sf` command | `:826`, to print the value back | + +Neither appears in `NodePrefs::structure()` or in any of its sub objects +(`src/helpers/CommonCLI.h:209-225` on `upstream/dev` 428a95c6), so both are lost +on every reboot. + +For `discovery_mod_timestamp` that is a regression rather than an oversight. The +binary loader still reads it, at offset 162 (`src/helpers/CommonCLI.cpp:97`), so +the field survived as long as a node booted from `/com_prefs`. Since 1.17 every +node boots from `/prefs.json`, which means every server role has been reporting +itself as never modified since then. + +`extra_sf` was never persisted in either format. + +## Decision + +Both fields are serialised: + +- `discovery_mod_timestamp` as `disc_mod`, at the top level next to the advert + intervals, which is where it belongs: it is a property of the node, not of its + radio. +- `extra_sf` as `extra_sf` inside the `radio` object, hex encoded through the + `def(key, void*, len)` overload. That overload exists for exactly this, a + fixed size byte array, and the companion already uses it for its default scope + key (`examples/companion_radio/NodePrefs.h:159`). + +The constructor zeroes `extra_sf`. It is a plain array member with no +initialiser, and now that it is written out and read back it needs a defined +starting value. + +### Why both keys are written on every board + +`extra.sf` is compiled in only for `USE_LR2021` boards and implemented only by +the repeater, so most builds can never set the field. The keys are written +anyway, which keeps `/prefs.json` portable between roles and boards. That is +also how the bridge settings are handled: `BridgePrefs::structure()` writes its +six keys unconditionally although the feature sits behind `WITH_BRIDGE`. + +## Alternatives considered + +- **Four separate `uint8` keys, or a comma separated string.** Rejected. The + hex blob is the overload the serializer already offers for a fixed size array, + and it is what the one precedent in the tree uses. +- **Guard the keys with `#if defined(USE_LR2021)`.** Rejected. It would make the + shape of `/prefs.json` depend on the board, which is exactly what the + downgrade test relies on not happening, and it would differ from how the + bridge keys are treated. +- **Also apply the side detectors at boot.** Out of scope here and reported + separately, see the follow-up below. It is a behaviour change in the repeater + boot path, which by the rules of this series belongs in its own branch. +- **Sanitise the two new fields in the range check.** Not done. A timestamp has + no meaningful range, and the spread factors are validated where they are + applied, against the primary spread factor + (`src/helpers/radiolib/CustomLR2021Wrapper.h:37-39`), so a limit in the loader + would have to be recomputed whenever `sf` changes. + +## Compatibility + +- **Upgrade.** A `/prefs.json` without the two keys loads as before: the + serializer leaves a field untouched when its key is absent, so both keep + whatever the firmware put there. +- **Downgrade.** A firmware that does not know the keys skips them, the way it + skips any unknown key. +- The file grows by one number and one eight character string. + +## Verification + +### Host tests + +New environment `native_node_prefs` covers the serialisation of the real +`NodePrefs` against stream doubles. `NodePrefs` lives in `CommonCLI.h`, whose +filesystem types only exist for a platform, so the environment selects the nRF52 +branch and takes the types from a mock filesystem. + +Six of the nine cases are red on `upstream/dev` 428a95c6 and green with the +change: both save cases, both load cases, the round trip, and the case that +mixes the new keys with unknown ones. The other three pin what must not change: + +| Case | Pins | +|---|---| +| `ExtraSfStartsZeroed` | a fresh `NodePrefs` starts from a defined `extra_sf` | +| `LoadWithoutTheNewKeysLeavesThemAlone` | the upgrade path, and that the keys which are present still arrive | +| `UnknownKeysAreSkipped` | the downgrade path, from the reading side | + +`ExtraSfStartsZeroed` is listed as pinning rather than as red, because before +the change the array is uninitialised: the result there is undefined, not +reliably a failure. + +`pio test -e native -e native_kiss_modem -e native_node_prefs` reports 62 of 62. + +### Mutations + +Five deliberate errors, each had to turn the suite red: + +| Mutation | Caught by | +|---|---| +| `extra_sf` not serialised | both `extra_sf` cases, the round trip, the unknown key case | +| `disc_mod` not serialised | both timestamp cases, the round trip, the unknown key case | +| blob one byte short | the save and load of `extra_sf`, the unknown key case | +| constructor fills `extra_sf` with ones | `ExtraSfStartsZeroed` | +| timestamp under a different JSON key | both timestamp cases, the unknown key case | + +### Builds + +All 20 environments of the matrix build on this commit, and so does +`upstream/dev` 428a95c6 in its own worktree with the same toolchain. +`Heltec_v3_terminal_chat` was built as well, to show the one role with its own +prefs structure is untouched. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +0 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +0 | +| `Heltec_v3_repeater` | OK | +0 | +188 | +| `Heltec_v3_room_server` | OK | +0 | +152 | +| `Heltec_v3_sensor` | OK | +0 | +176 | +| `LilyGo_T-Echo_kiss_modem` | OK | +0 | +0 | +| `LilyGo_T-Echo_room_server` | OK | +0 | +224 | +| `PicoW_repeater` | OK | +0 | +184 | +| `ProMicro_companion_radio_usb` | OK | +0 | +0 | +| `ProMicro_repeater` | OK | +0 | +208 | +| `ProMicro_room_server` | OK | +0 | +224 | +| `ProMicro_sensor` | OK | +0 | +208 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +192 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +0 | +| `RAK_4631_repeater` | OK | +0 | +208 | +| `RAK_4631_room_server` | OK | +0 | +208 | +| `RAK_4631_sensor` | OK | +0 | +208 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +184 | +| `wio-e5-mini_repeater` | OK | +0 | +184 | +| `wio-e5-mini_sensor` | OK | +0 | +176 | +| `Heltec_v3_terminal_chat` | OK | +0 | +0 | + +RAM is unchanged in all 21. Flash splits cleanly along which builds use this +`NodePrefs`, which is a useful check that the change lands exactly where it +should: + +- The 15 server role builds, every repeater, room server and sensor, grow by 152 + to 224 bytes. That is the hex encoding and decoding of the blob plus the + second key. +- The other 6 are byte identical to dev: the four companion builds, which have + their own `NodePrefs` in `examples/companion_radio/NodePrefs.h`, + `LilyGo_T-Echo_kiss_modem`, which adds only `examples/kiss_modem/` to its + sources, and `Heltec_v3_terminal_chat`, whose + `examples/simple_secure_chat` keeps a `NodePrefs` struct of its own. + +So no build that cannot reach these fields pays for them. + +### Hardware + +techo-b43b as a room server, both images marked and each marker confirmed in the +image before flashing and reported by `ver` on the board. Ten settings were read +back at each step. + +**Upgrade.** Values set on `dev-428a95c6`, which writes a `/prefs.json` without +the new keys, then rebooted, then the new build flashed onto the same file: + +| | `dev-428a95c6` | `extra-sf-disc-wt` | +|---|---|---| +| `get af` | `> 3.0` | `> 3.0` | +| `get dutycycle` | `> 25.0%` | `> 25.0%` | +| `get txdelay` | `> 0.6999999` | `> 0.6999999` | +| `get direct.txdelay` | `> 0.3999999` | `> 0.3999999` | +| `get flood.max` | `> 32` | `> 32` | +| `get multi.acks` | `> 1` | `> 1` | +| `get allow.read.only` | `> on` | `> on` | +| `get name` | `> T-Echo Sensor` | `> T-Echo Sensor` | +| `get radio` | `> 869.6179809,62.5,8,8` | `> 869.6179809,62.5,8,8` | +| `get tx` | `> 22` | `> 22` | + +**Downgrade.** Then other values set on the new build and rebooted, so the file +was rewritten with both new keys, and `dev-428a95c6` flashed back onto it: + +| | `extra-sf-disc-wt` | `dev-428a95c6` | +|---|---|---| +| `get af` | `> 4.0` | `> 4.0` | +| `get dutycycle` | `> 20.0%` | `> 20.0%` | +| `get txdelay` | `> 0.8` | `> 0.8` | +| `get direct.txdelay` | `> 0.5` | `> 0.5` | +| `get flood.max` | `> 48` | `> 48` | +| `get multi.acks` | `> 0` | `> 0` | +| `get allow.read.only` | `> off` | `> off` | +| `get name` | `> T-Echo Sensor` | `> T-Echo Sensor` | +| `get radio` | `> 869.6179809,62.5,8,8` | `> 869.6179809,62.5,8,8` | +| `get tx` | `> 22` | `> 22` | + +Identical in both directions, `ver` being the only difference. + +**What the board does not show.** Neither new field can be observed through the +CLI on this hardware, so the round trip of the values themselves rests on the +host tests: + +- `extra.sf` is compiled in only for `USE_LR2021` boards, and `USE_LR2021` + appears in two variants in the tree, `meshnology_w12` and `meshtracker_x1`. + Neither was available, and a T-Echo is an SX1262 board. +- `discovery_mod_timestamp` has no CLI reader at all. + +What the board does show is the part that only hardware can show: that a file +written by one firmware is read without complaint by the other, in both +directions, with every readable setting intact. + +## Follow-ups + +- **The side detectors are never applied at boot.** + `configSideDetectors()` is called from one place only, the `extra.sf` command + (`src/helpers/CommonCLI.cpp:686`, through + `examples/simple_repeater/MyMesh.cpp:1087` into the radio driver). No boot path + calls it, so a persisted `extra_sf` is not put back on the radio after a + restart. Persisting it is still right, because `get extra.sf` now answers + truthfully and the value is there to be applied, but the apply is missing and + wants its own change: it is a behaviour change in the repeater boot path, and + it cannot be verified on hardware without an LR2021 board. +- The stored spread factors can go stale. `CustomLR2021Wrapper` validates them + against the primary `sf` when they are applied, so a file that was valid for + one `sf` can hold values that are refused after `sf` changes. From 8eed55e1aa2c6f77ef045beab460ada5efd67eca Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:43:56 +0200 Subject: [PATCH 062/136] test(prefs): cover the two new prefs keys in the characterization suite The merge of the extra_sf and discovery timestamp fix added two keys to /prefs.json and left WritesTheKnownPrefsJson pinning the old text, so that test was red in native and native_ofast. My mistake: the push went out in the same command as the test run instead of waiting for its result. Rather than only correcting the expected string, fillAll() now sets both fields, so the suite covers them: the written text is pinned with real values and ReadsEveryPersistedFieldBack checks both read back. --- test/test_common_node_prefs/test_common_node_prefs.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/test_common_node_prefs/test_common_node_prefs.cpp b/test/test_common_node_prefs/test_common_node_prefs.cpp index 0bcb7bb4e01..1546439b69f 100644 --- a/test/test_common_node_prefs/test_common_node_prefs.cpp +++ b/test/test_common_node_prefs/test_common_node_prefs.cpp @@ -23,6 +23,8 @@ void fillAll(NodePrefs& p) { strcpy(p.owner_info, "owner \"quoted\" info"); p.advert_interval = 60; p.flood_advert_interval = 12; + p.discovery_mod_timestamp = 1760000000; + p.extra_sf[0] = 9; p.extra_sf[1] = 10; p.extra_sf[2] = 11; p.extra_sf[3] = 0; p.node_lat = 53.551086; p.node_lon = 9.993682; p.bridge_enabled = 1; @@ -44,7 +46,8 @@ void fillAll(NodePrefs& p) { // /prefs.json as written by v1.18.0 for the values of fillAll(). const char* kPrefsJson = "{name:\"Test Repeater\",pass:\"admin-pw\",guest:\"guest-pw\"," - "owner:\"owner \\\"quoted\\\" info\",adv_int:60,f_adv_int:12,lat:53.551086," + "owner:\"owner \\\"quoted\\\" info\",adv_int:60,f_adv_int:12," + "disc_mod:1760000000,extra_sf:\"090A0B00\",lat:53.551086," "lon:9.993682,radio:{freq:869.6180,bw:62.5000,sf:8,cr:6,cad:1," "int_thr:14,rxgain:1,fem_rxgain:1,fem_txgain:2,tx:21,af:1.5000," "rxdelay:3.2500,f_txdelay:0.7500,d_txdelay:0.2500,agc_int:5,hash_mode:2," @@ -79,6 +82,8 @@ TEST(CommonNodePrefs, ReadsEveryPersistedFieldBack) { EXPECT_STREQ(expected.owner_info, loaded.owner_info); EXPECT_EQ(expected.advert_interval, loaded.advert_interval); EXPECT_EQ(expected.flood_advert_interval, loaded.flood_advert_interval); + EXPECT_EQ(expected.discovery_mod_timestamp, loaded.discovery_mod_timestamp); + EXPECT_EQ(0, memcmp(expected.extra_sf, loaded.extra_sf, sizeof(expected.extra_sf))); EXPECT_DOUBLE_EQ(expected.node_lat, loaded.node_lat); EXPECT_DOUBLE_EQ(expected.node_lon, loaded.node_lon); EXPECT_EQ(expected.bridge_enabled, loaded.bridge_enabled); From 927c645523f510f3794ec1c23c3de43f6555bbd9 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:47:19 +0200 Subject: [PATCH 063/136] docs(fixes): note for the companion UI guard and leak Describes both faults, the reachability check that narrows the display one to a T-Echo Card whose panel does not answer, why the status bar needs nothing when there is no display, the alternatives including the NullDisplayDriver shape that is left for later, and the builds across all 26 environments that contain the changed code. Records plainly that neither change is verified at runtime and why, and chases down the size split instead of waving it away: nRF52 loses 48 to 64 bytes because this was the only strdup user and the linker drops it, ESP32 is unchanged or 4 bytes larger because something else there still calls strdup. Measured with nm and with the ELF sections, not with the padded image size. Kept in its own commit so an upstream pull request can leave it out. --- docs/fixes/companion-ui-robustness.md | 174 ++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/fixes/companion-ui-robustness.md diff --git a/docs/fixes/companion-ui-robustness.md b/docs/fixes/companion-ui-robustness.md new file mode 100644 index 00000000000..b5aa4440309 --- /dev/null +++ b/docs/fixes/companion-ui-robustness.md @@ -0,0 +1,174 @@ +# A node without a display faults in the ui-tiny status bar + +Status: fixed. NOT verified at runtime, see the limits at the end. Behaviour +change: a companion with no working display starts instead of faulting. + +## Symptom + +`UITask::begin()` in `ui-tiny` dereferences `_display` one line below the guard +that was written for exactly that pointer +(`examples/companion_radio/ui-tiny/UITask.cpp:447-450`): + +```c + if (_display != NULL) { + _display->turnOn(); + } + _statusBar.begin(_display->width()); +``` + +`main.cpp` sets `disp` to NULL and only assigns it when the driver starts +(`examples/companion_radio/main.cpp:143-146`), then passes it in unchanged +(`main.cpp:285`). So on a node whose panel does not answer, `_display` is NULL +and `setup()` faults in the status bar call. The node never joins the mesh at +all, and a user cannot tell a dead display apart from a dead node. + +`ui-orig` has a second, much smaller fault in the same function: the `strdup` of +`FIRMWARE_VERSION` at `ui-orig/UITask.cpp:55` is never freed. `begin()` runs once +per boot, so nothing grows; it is a handful of bytes that stay allocated. + +## Reachability + +The display fault needs `display.begin()` to return false. Measured, not assumed: + +- `U8g2Display::begin()` returns what `_u8g2.begin()` returns + (`src/helpers/ui/U8g2Display.h:45-57`), so an unpopulated, dead or + wrongly addressed I2C panel is enough. +- `variants/lilygo_techo_card/platformio.ini:20` sets + `DISPLAY_CLASS=U8g2Display`, and that variant is the only one that compiles + `ui-tiny`. +- `NullDisplayDriver::begin()` always returns false + (`src/helpers/ui/NullDisplayDriver.h:8`) and 16 variants use it, which would be + a second path into the same fault. None of those 16 compiles `ui-tiny`, so that + path does not reach this line. `ui-orig`, which several of them do compile, has + the guard right. + +So the fault is reachable on a LilyGo T-Echo Card whose panel fails to +initialise, and nowhere else today. What makes it an oversight rather than a +decision is that every other `_display` use in the same file tests the pointer +first: lines 517, 532, 699, 746, 760, 775 and 783. + +## Fix + +The status bar call moves inside the guard that is already there. Nothing is lost +when there is no display: + +- The status bar is only ever drawn through `*_display`, inside + `if (_display != NULL && _display->isOn())` in `loop()`. +- `ScrollingStatusBar`'s constructor initialises every member, including + `_display_width(72)` (`ui-tiny/ScrollingStatusBar.h:35`), so the object stays in + a defined state when `begin()` is not called. Nothing reads uninitialised + memory in the headless case. + +Checked by walking the block depth of all four `_statusBar` uses rather than by +eye: each one now sits under an `if (_display != NULL ...)`. + +For `ui-orig`, the `strdup` becomes a local array sized to the field it feeds. +The copy only has to outlive the `sprintf` two lines later. `StrHelper::strzcpy` +bounds it, which also removes the chance of a long `FIRMWARE_VERSION` walking +past `_version_info[32]`. + +### Alternatives considered + +- **Use `NullDisplayDriver` for the headless case** instead of a NULL pointer, + and drop the guards. That is the shape the codebase is reaching for; the driver + exists and already returns false from `begin()`. Rejected here: it changes + `main.cpp` and every UI task for all 222 companion environments, which is a + refactor and not a fix, and it would hide this defect rather than close it. + Worth proposing on its own. +- **Guard inside `ScrollingStatusBar::begin()`** by passing the pointer instead + of the width. Rejected: it moves the NULL question into a class that otherwise + knows nothing about the display, and the other three call sites would still + need the guard. +- **Free the `strdup`** instead of removing it. Rejected: there is no reason to + allocate at all for a copy that lives for two lines. + +## Verification + +### Builds + +All 26 environments that compile `ui-tiny` or `ui-orig` were built from `dev` +(428a95c6) and from this branch with the same toolchain. That is every +environment that contains the changed code, not a sample: measured with +`pio project config --json-output`, the two files are compiled by 18 +`nordicnrf52` environments, 7 `platformio/espressif32@6.11.0` and 1 +`ststm32@19.7.1`, and all 26 are in the list below. + +All 26 build on both sides, and RAM is unchanged everywhere. + +| Environment | Flash `dev` | Flash fix | Flash delta | RAM delta | +|---|---:|---:|---:|---:| +| `LilyGo_T-Echo_Card_companion_radio_ble` | 347,840 | 347,840 | +0 | +0 | +| `LilyGo_T-Echo_Card_companion_radio_usb` | 342,096 | 342,096 | +0 | +0 | +| `MeshTracker_X1_companion_radio_ble` | 323,004 | 322,940 | -64 | +0 | +| `MeshTracker_X1_companion_radio_usb` | 317,324 | 317,276 | -48 | +0 | +| `Minewsemi_me25ls01_companion_radio_ble` | 317,516 | 317,452 | -64 | +0 | +| `Minewsemi_me25ls01_companion_radio_usb` | 311,804 | 311,756 | -48 | +0 | +| `R1Neo_companion_radio_ble` | 383,688 | 383,640 | -48 | +0 | +| `R1Neo_companion_radio_usb` | 376,280 | 376,232 | -48 | +0 | +| `RAK_3112_companion_radio_ble` | 1,649,285 | 1,649,285 | +0 | +0 | +| `RAK_3112_companion_radio_usb` | 638,353 | 638,353 | +0 | +0 | +| `RAK_3112_companion_radio_wifi` | 1,087,681 | 1,087,681 | +0 | +0 | +| `RAK_WisMesh_Tag_companion_radio_ble` | 383,992 | 383,928 | -64 | +0 | +| `RAK_WisMesh_Tag_companion_radio_usb` | 376,568 | 376,520 | -48 | +0 | +| `ThinkNode_M3_companion_radio_ble` | 318,048 | 318,000 | -48 | +0 | +| `ThinkNode_M3_companion_radio_usb` | 312,344 | 312,296 | -48 | +0 | +| `ThinkNode_M4_companion_radio_ble` | 318,416 | 318,368 | -48 | +0 | +| `ThinkNode_M4_companion_radio_usb` | 312,712 | 312,648 | -64 | +0 | +| `ThinkNode_M7_companion_radio_ble` | 1,624,645 | 1,624,649 | +4 | +0 | +| `ThinkNode_M7_companion_radio_ethernet` | 1,057,533 | 1,057,537 | +4 | +0 | +| `ThinkNode_M7_companion_radio_usb` | 584,381 | 584,381 | +0 | +0 | +| `ThinkNode_M7_companion_radio_wifi` | 1,023,861 | 1,023,861 | +0 | +0 | +| `Xiao_nrf52_companion_radio_ble` | 378,152 | 378,088 | -64 | +0 | +| `Xiao_nrf52_companion_radio_usb` | 372,440 | 372,392 | -48 | +0 | +| `t1000e_companion_radio_ble` | 309,180 | 309,116 | -64 | +0 | +| `t1000e_companion_radio_usb` | 303,508 | 303,460 | -48 | +0 | +| `wio-e5-mini_companion_radio_usb` | 208,024 | 207,976 | -48 | +0 | + +The sizes split by platform, and the split was chased down rather than waved +away. The 18 `nordicnrf52` environments lose 48 to 64 bytes, the ESP32 ones are +unchanged, and two of them gain 4 bytes. That is not image padding: comparing the +ELF sections instead of the padded image shows `.text` identical on both sides for +`RAK_3112_companion_radio_ble` (1,318,998) and genuinely 4 bytes larger for +`ThinkNode_M7_companion_radio_ble` (1,297,174 to 1,297,178). + +`nm` explains it. On `t1000e_companion_radio_ble` the `strdup` symbol is present +on `dev` and gone on this branch: this call site was its only user, so the linker +drops the function and its dependencies. On `RAK_3112_companion_radio_ble` the +symbol is present on both sides, because something else in the ESP32 build still +calls it, so removing one call site frees nothing and the replacement costs a few +bytes. The `ui-tiny` change, which only moves a call inside a guard that is +already there, leaves `.text` identical (346,648 on both sides). + +The 18 environments of the review series plus both T-Echo companion environments +were built as well, to show that nothing else moved. None of them compiles +`ui-tiny` or `ui-orig`. + +All 20 build on both sides and every one of them reports the same flash and +RAM size as `dev`, with not a single byte of difference. That is the expected +result and it was stated before the builds ran: none of the 20 has `ui-tiny` or +`ui-orig` in its `build_src_filter`, so none of them contains the changed code. +Any deviation would have meant something else moved. + +### What is not verified + +- **No automated test.** `UITask` pulls in the display driver, the board and the + mesh, so it does not build in a native environment. A host harness for it would + be a great deal larger than the change it guards. +- **No hardware run.** The display path needs a LilyGo T-Echo Card whose panel + fails to initialise. No board within reach of this work runs `ui-tiny` at all: + the T-Echo used for the other two fixes in this series builds `ui-new`. + The fix is therefore **NOT verified at runtime**. The evidence is the source + chain above, the reachability check, and the builds. +- The `ui-orig` change is verified only in the sense that it produces the same + string: both implementations were run against each other on the host for twelve + inputs, including a leading dash, several dashes and none, and the resulting + `_version_info` is identical in every case. A leak of a few bytes once per boot + has no runtime symptom to observe. + +### Observation, not fixed here + +`sprintf(_version_info, "%s (%s)", version, FIRMWARE_BUILD_DATE)` at +`ui-orig/UITask.cpp:62` can still write past `_version_info[32]` if +`FIRMWARE_VERSION` and `FIRMWARE_BUILD_DATE` are long enough together. This +change bounds the version part; the format as a whole is unchanged and out of +scope here. From 70e642393194936c2468e30ab4c84361613821af Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:11:09 +0200 Subject: [PATCH 064/136] test(mocks): make Print actually format numbers Print::print() for every integer and float type returned 0 without writing anything. Any code that formats a number through Print and writes it to a File therefore produced a file with empty values: a sensor's prefs.json came out as 'freq:,bw:,sf:,f_max:,' with only the handful of fields that go through other overloads carrying a value. That is not a cosmetic problem. It silently invalidates any test that saves and reloads settings: a measurement of the prefs upgrade path read as 'the stored value does not overwrite the new default', which looked reassuring and was an artefact of nothing having been written. The existing prefs tests were unaffected, because test/mocks/PrefsStreams.h brings its own CaptureStream that overrides all of these. Format in the base class and hand the bytes to write(), the way Arduino's Print does it, so every Stream subclass gets it right instead of each mock repeating it. Verification - pio test over all six environments: 127 of 127 test cases pass. The suites that already covered the serializer (test_config_serializer, test_common_node_prefs, test_companion_node_prefs, and the same two under -Ofast) are unchanged. - A sensor's prefs.json now comes out with real values, checked against the file the role writes: radio:{freq:915.0000,bw:250.0000,sf:10,...}. --- test/mocks/Stream.h | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/test/mocks/Stream.h b/test/mocks/Stream.h index 15745b37867..95baf265aa6 100644 --- a/test/mocks/Stream.h +++ b/test/mocks/Stream.h @@ -35,14 +35,36 @@ class Print return write((const uint8_t *) buffer, size); } - virtual size_t print(unsigned char b, int r = DEC) { return 0; } - virtual size_t print(int v, int r = DEC) { return 0; } - virtual size_t print(unsigned int v, int r = DEC) { return 0; } - virtual size_t print(long v, int r = DEC) { return 0; } - virtual size_t print(unsigned long v, int r = DEC) { return 0; } - virtual size_t print(long long v, int r = DEC) { return 0; } - virtual size_t print(unsigned long long v, int r = DEC) { return 0; } - virtual size_t print(double v, int p = 2) { return 0; } + // Arduino's Print formats numbers itself and hands the bytes to write(), so a + // subclass only has to implement write(). These used to return 0 without + // writing anything, which silently produced files with empty numeric values. +protected: + size_t emitSigned(long long v, int base) { + char text[24]; + int n = (base == HEX) ? snprintf(text, sizeof(text), "%llx", (unsigned long long) v) + : snprintf(text, sizeof(text), "%lld", v); + return write((const uint8_t *) text, (size_t) n); + } + size_t emitUnsigned(unsigned long long v, int base) { + char text[24]; + int n = (base == HEX) ? snprintf(text, sizeof(text), "%llx", v) + : snprintf(text, sizeof(text), "%llu", v); + return write((const uint8_t *) text, (size_t) n); + } + +public: + virtual size_t print(unsigned char b, int r = DEC) { return emitUnsigned(b, r); } + virtual size_t print(int v, int r = DEC) { return emitSigned(v, r); } + virtual size_t print(unsigned int v, int r = DEC) { return emitUnsigned(v, r); } + virtual size_t print(long v, int r = DEC) { return emitSigned(v, r); } + virtual size_t print(unsigned long v, int r = DEC) { return emitUnsigned(v, r); } + virtual size_t print(long long v, int r = DEC) { return emitSigned(v, r); } + virtual size_t print(unsigned long long v, int r = DEC) { return emitUnsigned(v, r); } + virtual size_t print(double v, int p = 2) { + char text[48]; + int n = snprintf(text, sizeof(text), "%.*f", p, v); + return write((const uint8_t *) text, (size_t) n); + } size_t print(char c) { return write(c); } size_t print(const char* str) { return write(str); } From 7e27a98d10958802129e3a063357bbcbae9b5f5a Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 01:25:10 +0200 Subject: [PATCH 065/136] fix(identity): verify the identity after closing the file What changes - Both IdentityStore::save() overloads read the file back after closing it and compare what the medium returns: all 96 bytes of the keypair, and the 32 bytes of the display name in the overload that stores one. A save reports success only once the identity can be read back. - New private helpers verifySaved(), and a file local sameIdentity() that compares two identities through the buffer serialisation LocalIdentity already offers, since prv_key is private. - LocalIdentity::writeTo(uint8_t*, size_t) becomes const. It only reads, and the comparison would otherwise need a copy of both identities, which costs 192 bytes of stack on a path that has little to spare. - test/mocks/Adafruit_LittleFS.h gains a commit budget next to its write budget, and corrupt_on_commit. The commit budget is the case where every write reports success and only the close hands the bytes over; corrupt_on_commit returns a different byte while keeping the file length. - test/mocks/Identity.h gains the buffer serialisation of LocalIdentity, copied from src/Identity.cpp the way the stream variants already were. - Ten more cases in test_identity_store, 19 in total. Two of them walk every byte of the keypair and of the name, so a comparison that is one byte short cannot pass. Why - A write that reports success is not proof that the medium took the data, and File::close() is void in every platform branch of IdentityStore.cpp and drops the error it is handed. Measured with the 96 bytes of an identity against both filesystem stacks this file talks to: - littlefs, nRF52 with prog_size 128 and STM32 with prog_size 2048: a full filesystem and a failing erase are reported by lfs_file_write() itself, as LFS_ERR_NOSPC and LFS_ERR_IO. A failing program is not. The two writes return 32 and 64, and the error turns up in lfs_file_close(), whose result Adafruit_LittleFS' void close() drops. - ESP32, which uses SPIFFS in this repository: nothing reaches the caller at write time at all. The core opens files fully buffered, setvbuf(_IOFBF, 4096) in libraries/FS/src/vfs_api.cpp, and VFSFileImpl::write() is fwrite(), so the 96 bytes stay in that buffer until close() calls fclose() and throws its result away. On a full filesystem fwrite() returns 32 and then 64 and fclose() returns -1. SPIFFSImpl derives from VFSImpl, as does LittleFSImpl, so this is a property of the ESP32 file layer rather than of SPIFFS. - So the previous commit closed the gap on two of the four platform branches and only for two of the three failure modes. The read back closes it on all of them. - A read back that cannot be done at all counts as a failed save. That is deliberate: an identity nobody can read is of no use to the next boot. Correction to the previous commit - Its message says that a failed import left the old key to come back at the next boot. The old key does not come back: every platform branch removes or truncates the file before writing, so the next boot finds no identity and generates a new random one. Compatibility - The success path is unchanged. A medium that accepts the write hands the identity back on the read. - The extra work is one open and one read of 96 or 128 bytes, on a path that runs when a node first generates its identity, imports a private key, or takes a new one over set prv.key. save() is never called in a loop. - Stack: about 500 bytes more per save() on each platform branch, measured from the objects of the five builds below. The deepest caller is set prv.key on the nRF52 loop task, whose stack is 4096 bytes; the measured frames there come to about 2.4 KB, and the filesystem chain below them is estimated, not measured. - Flash: 112 bytes more on the two tightest targets in the tree. wio-e5-mini_repeater goes from 203552 to 203664 bytes, 88.7 to 88.8 per cent, with 25712 bytes still free, and wio-e5_repeater from 196360 to 196472. - set prv.key on the server roles still answers "OK, reboot to apply!" whatever the result is, because ICommonCLICallbacks::saveIdentity() is void. Lifting that to bool is a separate change. Evidence - pio test -e native_identity_store: 19 of 19 pass. - pio test -e native -e native_kiss_modem -e native_identity_store: 73 of 73. - Three mutation probes, each caught: with IdentityStore.cpp back at the previous commit, 8 of the 19 cases fail; shortening the keypair comparison by one byte fails the case that walks the keypair; shortening the name comparison to 8 bytes fails the case that walks the name. Restoring the file returns the suite to 19 of 19. - Built for one environment per platform branch: wio_wm1110_repeater (nRF52), wio-e5_repeater and wio-e5-mini_repeater (STM32), PicoW_repeater (RP2040), Heltec_v3_repeater and Heltec_v3_companion_radio_usb (ESP32). All six build, with no new warning in the changed files. Not verified - No hardware run. That the read back right after the close succeeds on each platform is read from the platform sources, not measured on a device. --- src/Identity.cpp | 2 +- src/Identity.h | 2 +- src/helpers/IdentityStore.cpp | 58 ++++++++++ src/helpers/IdentityStore.h | 7 ++ test/mocks/Adafruit_LittleFS.h | 49 +++++++-- test/mocks/Identity.h | 15 +++ .../test_identity_store.cpp | 100 +++++++++++++++++- 7 files changed, 220 insertions(+), 13 deletions(-) diff --git a/src/Identity.cpp b/src/Identity.cpp index 51a01ae71d8..ab54929143d 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -125,7 +125,7 @@ void LocalIdentity::printTo(Stream& s) const { s.print("prv_key: "); Utils::printHex(s, prv_key, PRV_KEY_SIZE); s.println(); } -size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) { +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) const { if (max_len < PRV_KEY_SIZE) return 0; // not big enough if (max_len < PRV_KEY_SIZE + PUB_KEY_SIZE) { // only room for prv_key diff --git a/src/Identity.h b/src/Identity.h index 008f7b5bf63..7f2b1b8c885 100644 --- a/src/Identity.h +++ b/src/Identity.h @@ -90,7 +90,7 @@ class LocalIdentity : public Identity { bool readFrom(Stream& s); bool writeTo(Stream& s) const; void printTo(Stream& s) const; - size_t writeTo(uint8_t* dest, size_t max_len); + size_t writeTo(uint8_t* dest, size_t max_len) const; void readFrom(const uint8_t* src, size_t len); }; diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index ddd7a452608..42b84069e64 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -42,6 +42,56 @@ bool IdentityStore::load(const char *name, mesh::LocalIdentity& id, char display return loaded; } +// Compares two identities over all 96 bytes. prv_key is private, so the +// comparison runs over the buffer serialisation that LocalIdentity offers +// publicly. Its layout does not have to match the file, it only has to be the +// same on both sides. +static bool sameIdentity(const mesh::LocalIdentity& a, const mesh::LocalIdentity& b) { + uint8_t ab[PRV_KEY_SIZE + PUB_KEY_SIZE]; + uint8_t bb[PRV_KEY_SIZE + PUB_KEY_SIZE]; + return a.writeTo(ab, sizeof(ab)) == sizeof(ab) + && b.writeTo(bb, sizeof(bb)) == sizeof(bb) + && memcmp(ab, bb, sizeof(ab)) == 0; +} + +// A write that reports success is not proof that the medium took the data, so +// both overloads read the file back after closing it. File::close() is void in +// every platform branch of this file and drops the error it is handed, which +// is where the gap is. +// +// Measured against the two filesystem stacks this file talks to, with the 96 +// bytes of an identity: +// +// littlefs, on nRF52 with prog_size 128 and on STM32 with prog_size 2048: a +// full filesystem and a failing erase are reported by lfs_file_write() itself, +// as LFS_ERR_NOSPC and LFS_ERR_IO. A failing program is not. The two writes +// return 32 and 64 and the error turns up in lfs_file_close(), whose result +// Adafruit_LittleFS' void close() drops. +// +// ESP32: nothing reaches the caller at write time at all. The core opens files +// fully buffered, setvbuf(_IOFBF, 4096) in libraries/FS/src/vfs_api.cpp, and +// VFSFileImpl::write() is fwrite(), so the 96 bytes stay in that buffer until +// close() calls fclose() and throws its result away. On a full filesystem +// fwrite() returns 32 and then 64 and fclose() returns -1. Both ESP32 +// filesystems are affected, LittleFSImpl and SPIFFSImpl derive from VFSImpl. +// +// A read back that cannot be done at all counts as a failed save. That is +// deliberate: an identity nobody can read is of no use to the next boot. +bool IdentityStore::verifySaved(const char *name, const mesh::LocalIdentity& id) { + mesh::LocalIdentity stored; + if (!load(name, stored)) return false; // file gone, empty or too short + return sameIdentity(id, stored); +} + +bool IdentityStore::verifySaved(const char *name, const mesh::LocalIdentity& id, const uint8_t expected_name[32]) { + mesh::LocalIdentity stored; + char stored_name[32]; + memset(stored_name, 0, sizeof(stored_name)); // load() fills at most 32 bytes + if (!load(name, stored, stored_name, (int) sizeof(stored_name))) return false; + if (!sameIdentity(id, stored)) return false; + return memcmp(stored_name, expected_name, sizeof(stored_name)) == 0; +} + bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { char filename[40]; sprintf(filename, "%s/%s.id", _dir, name); @@ -58,6 +108,10 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { bool success = id.writeTo(file); file.close(); MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err"); + if (success && !verifySaved(name, id)) { + MESH_DEBUG_PRINTLN("IdentityStore::save() verify - Err"); + success = false; + } return success; } MESH_DEBUG_PRINTLN("IdentityStore::save() failed"); @@ -88,6 +142,10 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const file.close(); MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err"); + if (success && !verifySaved(name, id, tmp)) { + MESH_DEBUG_PRINTLN("IdentityStore::save() verify - Err"); + success = false; + } return success; } MESH_DEBUG_PRINTLN("IdentityStore::save() failed"); diff --git a/src/helpers/IdentityStore.h b/src/helpers/IdentityStore.h index d0d7ee457ef..29d404f8c69 100644 --- a/src/helpers/IdentityStore.h +++ b/src/helpers/IdentityStore.h @@ -23,4 +23,11 @@ class IdentityStore { bool load(const char *name, mesh::LocalIdentity& id, char display_name[], int max_name_sz); bool save(const char *name, const mesh::LocalIdentity& id); bool save(const char *name, const mesh::LocalIdentity& id, const char display_name[]); + +private: + // Reads back what save() just wrote and compares it. The comment above the + // definition in IdentityStore.cpp says why the return value of write() is + // not enough on its own. + bool verifySaved(const char *name, const mesh::LocalIdentity& id); + bool verifySaved(const char *name, const mesh::LocalIdentity& id, const uint8_t expected_name[32]); }; diff --git a/test/mocks/Adafruit_LittleFS.h b/test/mocks/Adafruit_LittleFS.h index 6d5368e5c6b..c8a5fd3e41d 100644 --- a/test/mocks/Adafruit_LittleFS.h +++ b/test/mocks/Adafruit_LittleFS.h @@ -4,11 +4,22 @@ // touches is modelled: open, exists, remove, mkdir, and a File that is a // Stream. // -// The write budget is the point of this mock. A real flash lets a file open -// and then refuses the write once the medium is full or worn out, so a test -// needs a file that is valid but whose writes come up short. Print::write() -// loops over write(uint8_t), so a budget that runs out mid buffer returns a -// short count, which is how the real driver reports a partial write. +// Two failure modes are modelled, because the platforms differ in which one +// they show. +// +// The write budget: a real flash lets a file open and then refuses the write +// once the medium is full or worn out, so a test needs a file that is valid but +// whose writes come up short. Print::write() loops over write(uint8_t), so a +// budget that runs out mid buffer returns a short count, which is how littlefs +// reports it on nRF52 and STM32. +// +// The commit budget: on ESP32 the core opens files fully buffered, so write() +// reports success for bytes that are still in a stdio buffer, and only close() +// hands them over. File::close() is void there and drops the error, which +// leaves a short file behind that no return value ever mentioned. A commit +// budget below the number of bytes written reproduces exactly that, and +// corrupt_on_commit covers the worn cell that keeps the length but changes a +// byte. #include #include @@ -27,22 +38,38 @@ namespace Adafruit_LittleFS_Namespace { struct FileData { std::vector bytes; size_t write_budget = std::numeric_limits::max(); + size_t commit_budget = std::numeric_limits::max(); // what close() keeps + size_t corrupt_on_commit = std::numeric_limits::max(); // index close() flips }; class File : public Stream { FileData* _data; size_t _read_pos; bool _open; + bool _writing; public: - File() : _data(nullptr), _read_pos(0), _open(false) { } - explicit File(FileData* data) : _data(data), _read_pos(0), _open(data != nullptr) { } + File() : _data(nullptr), _read_pos(0), _open(false), _writing(false) { } + explicit File(FileData* data, bool writing = false) + : _data(data), _read_pos(0), _open(data != nullptr), _writing(writing) { } // The driver signals "could not open" through the bool conversion, not an // exception or a null return. operator bool() const { return _open; } - void close() { _open = false; } + // Only a file that was opened for writing commits on close. A read never + // changes what is on the medium. + void close() { + if (_open && _writing && _data != nullptr) { + if (_data->bytes.size() > _data->commit_budget) { + _data->bytes.resize(_data->commit_budget); + } + if (_data->corrupt_on_commit < _data->bytes.size()) { + _data->bytes[_data->corrupt_on_commit] ^= 0xFF; + } + } + _open = false; + } // Overriding the single byte write would otherwise hide the buffer overloads // of Print, which is what the callers use. @@ -87,6 +114,8 @@ class Adafruit_LittleFS { public: // Test knobs. size_t write_budget = std::numeric_limits::max(); + size_t commit_budget = std::numeric_limits::max(); + size_t corrupt_on_commit = std::numeric_limits::max(); bool fail_open = false; bool exists(const char* path) { return _files.count(path) > 0; } @@ -101,7 +130,9 @@ class Adafruit_LittleFS { Adafruit_LittleFS_Namespace::FileData& data = _files[path]; data.bytes.clear(); data.write_budget = write_budget; - return Adafruit_LittleFS_Namespace::File(&data); + data.commit_budget = commit_budget; + data.corrupt_on_commit = corrupt_on_commit; + return Adafruit_LittleFS_Namespace::File(&data, true); } auto found = _files.find(path); if (found == _files.end()) return Adafruit_LittleFS_Namespace::File(); diff --git a/test/mocks/Identity.h b/test/mocks/Identity.h index 4ee269d8897..7a793cd233b 100644 --- a/test/mocks/Identity.h +++ b/test/mocks/Identity.h @@ -61,6 +61,21 @@ class LocalIdentity : public Identity { success = success && s.write(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE; return success; } + + // Copy of the buffer serialisation in src/Identity.cpp, private key first. + // IdentityStore compares two identities with it, which keeps it off prv_key, + // private in the real class. + size_t writeTo(uint8_t* dest, size_t max_len) const { + if (max_len < PRV_KEY_SIZE) return 0; // not big enough + + if (max_len < PRV_KEY_SIZE + PUB_KEY_SIZE) { // only room for prv_key + std::memcpy(dest, prv_key, PRV_KEY_SIZE); + return PRV_KEY_SIZE; + } + std::memcpy(dest, prv_key, PRV_KEY_SIZE); + std::memcpy(&dest[PRV_KEY_SIZE], pub_key, PUB_KEY_SIZE); + return PRV_KEY_SIZE + PUB_KEY_SIZE; + } }; } diff --git a/test/test_identity_store/test_identity_store.cpp b/test/test_identity_store/test_identity_store.cpp index bac82bfe8de..9e49caa0834 100644 --- a/test/test_identity_store/test_identity_store.cpp +++ b/test/test_identity_store/test_identity_store.cpp @@ -7,8 +7,10 @@ // it, so a save that reports success without having written anything costs the // node its identity at the next boot. // -// The mock filesystem lets a file open and then runs out of write budget, which -// is how a full or worn flash behaves. +// The mock filesystem reproduces both ways a medium refuses data: a write that +// comes up short, which is what littlefs does on nRF52 and STM32, and a write +// that reports success while only the close hands the bytes over, which is what +// the buffered ESP32 file does. namespace { @@ -106,6 +108,100 @@ TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenTheFileCannotBeOpened) { EXPECT_FALSE(store.save("_main", id, "room")); } +// The second failure mode, and the one that matters on ESP32: every write +// reports success because the bytes are still in a buffer, and the medium only +// sees them when the file is closed. File::close() is void, so nothing in the +// write path can report this. Only reading the file back afterwards can. + +TEST_F(IdentityStoreSave, ReportsFailureWhenTheCommitLosesEverything) { + fs.commit_budget = 0; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_TRUE(fs.contents("/_main.id")->empty()); +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenTheCommitKeepsOnlyThePublicKey) { + fs.commit_budget = PUB_KEY_SIZE; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_EQ(fs.contents("/_main.id")->size(), (size_t) PUB_KEY_SIZE); +} + +// A worn cell that reads back a different byte keeps the file length intact, so +// only comparing the content catches it. This index is inside the private key, +// where a comparison of the public key alone would look away. +TEST_F(IdentityStoreSave, ReportsFailureWhenAByteOfThePrivateKeyComesBackWrong) { + fs.corrupt_on_commit = PUB_KEY_SIZE + 20; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES); // the length is right +} + +TEST_F(IdentityStoreSave, ReportsFailureWhenAByteOfThePublicKeyComesBackWrong) { + fs.corrupt_on_commit = 3; + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id)); +} + +TEST_F(IdentityStoreSave, ReportsSuccessWhenTheCommitKeepsEverything) { + fs.commit_budget = KEYPAIR_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id)); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenTheCommitLosesTheName) { + fs.commit_budget = KEYPAIR_BYTES; // the keypair survives, the name does not + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureWhenAByteOfTheNameComesBackWrong) { + fs.corrupt_on_commit = KEYPAIR_BYTES + 2; // inside "room" + IdentityStore store(fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")); + EXPECT_EQ(fs.contents("/_main.id")->size(), KEYPAIR_BYTES + DISPLAY_NAME_BYTES); +} + +TEST_F(IdentityStoreSaveWithName, ReportsSuccessWhenTheCommitKeepsEverything) { + fs.commit_budget = KEYPAIR_BYTES + DISPLAY_NAME_BYTES; + IdentityStore store(fs, ""); + + EXPECT_TRUE(store.save("_main", id, "room")); +} + +// The three cases above pick single indices. These two nail down that every +// byte of the comparison counts, which is what keeps a shortened comparison +// from passing unnoticed. +TEST_F(IdentityStoreSave, ReportsFailureForEveryByteOfTheKeypairComingBackWrong) { + for (size_t i = 0; i < KEYPAIR_BYTES; i++) { + Adafruit_LittleFS one_fs; + one_fs.corrupt_on_commit = i; + IdentityStore store(one_fs, ""); + + EXPECT_FALSE(store.save("_main", id)) << "byte " << i << " went unnoticed"; + } +} + +TEST_F(IdentityStoreSaveWithName, ReportsFailureForEveryByteOfTheNameComingBackWrong) { + // Byte 31 of the name field is padding that load() forces to zero, so a + // change there cannot be seen. It also does not matter: the name itself is + // shorter and stays intact. + for (size_t i = 0; i + 1 < DISPLAY_NAME_BYTES; i++) { + Adafruit_LittleFS one_fs; + one_fs.corrupt_on_commit = KEYPAIR_BYTES + i; + IdentityStore store(one_fs, ""); + + EXPECT_FALSE(store.save("_main", id, "room")) << "name byte " << i << " went unnoticed"; + } +} + } // namespace int main(int argc, char** argv) { From 26a9bce2a5e1cba259e2218795a1d16fd4b957a2 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 01:50:27 +0200 Subject: [PATCH 066/136] fix(radiolib): score a packet at the spreading factor the radio reports RadioLibWrapper::packetScore() assumed spreading factor 10 for every radio that did not override it: float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 Three of the eight wrappers do not override it, so LR1110, LR1121 and LR2021 boards scored every packet as if they ran at SF10. The score matters because the SNR a packet needs to be decodable rises as the spreading factor falls: packetScoreInt() holds one threshold per factor, from -7.5 dB at SF7 to -20 dB at SF12. Below SF10 the assumption scored too high, above it too low. All eight wrappers already report the factor through getSpreadingFactor(), and in the five that did override packetScore that function reads exactly the same member the override read. For example CustomSX1262Wrapper.h:36 uses ((CustomSX1262 *)_radio)->spreadingFactor inside packetScore and line 39 returns the same expression from getSpreadingFactor(). So those five overrides were, line for line, what the base class could have computed by asking, and the information the other three needed was one virtual call away. The base class now asks, and the five overrides that said the same thing go away. Fixing it in the base rather than adding three overrides is deliberate: a default that guesses reproduces this defect for the next driver, while a default that asks cannot. A new wrapper that reports its factor is scored correctly without doing anything, and one that reports nothing falls back to the build's LORA_SF, which is at least that build's own configuration rather than an unrelated constant. Behaviour change, scoped by measurement rather than by assumption. The score reaches two places: the logging in Dispatcher.cpp:224 and the logRx hook at :238, and the flood rebroadcast delay at :243. The delay is computed by calcRxDelay, and while Dispatcher.cpp:55 uses a base of 10, all four roles override it and use the configured rxdelay as the base instead (companion_radio/MyMesh.cpp:273, simple_repeater/MyMesh.cpp:542, simple_room_server/MyMesh.cpp:277, simple_sensor/SensorMesh.cpp:325): if (_prefs.rx_delay_base <= 0.0f) return 0; return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); And rx_delay_base is 0 by default in every role (NodePrefs.h:29, MyMesh.cpp:892 "turn off by default, was 10.0", MyMesh.cpp:648, SensorMesh.cpp:800; the companion even has the old default commented out at MyMesh.cpp:934 with "enable once new algo fixed"). So on a default configuration the score changes what is logged and nothing else, and the rebroadcast timing of an LR board changes only where an operator has set rxdelay above 0. With rxdelay at 10, the historical default, for a 64 byte packet and 500 ms airtime: at SF7 and -5 dB SNR the score goes from 0.750 to 0.188 and the delay from 129 ms to 1799 ms; at SF8 and -5 dB from 0.750 to 0.375 and 129 ms to 993 ms; at SF12 and -10 dB from 0.375 to 0.750 and 993 ms to 129 ms. At SF10 nothing changes, which is the probe on the arithmetic. Host tests. The native environments covered prefs and tables, not the radio wrappers, so this adds one. native_radio_wrappers builds the real RadioLibWrappers.cpp against a minimal stand-in for RadioLib's PhysicalLayer in test/mocks/RadioLib.h, the same approach the existing native environments take for the Arduino core and Mesh. The code under test is the shipping code; the test subclass only supplies the three pure virtuals and reports a factor. LORA_SF is deliberately 9 in that environment, so a score that silently assumes 10 fails rather than passes by coincidence. test/mocks/Arduino.h gains min, max and the two argument random() that the wrapper needs, and test/mocks/RadioLib.h includes Arduino.h, mirroring the real header. Before the fix, four of the seven cases failed and the two that had to pass did: SF10 is where old and new agree, and the length penalty is independent of the factor. After the fix all seven pass, and the three existing suites with them: 61 of 61 test cases. The new environment is added to the CI test line, otherwise the suite would never run there. Eight deliberate errors were run against that suite. Seven were caught: the factor off by one in each direction, the literal 10 restored, the SF8 threshold moved by 1 dB, the length penalty removed, the clamp removed, and the threshold guard tightened. The eighth, loosening the guard by 1 dB, is not caught and is an equivalent mutant: in that interval the rate is negative and the clamp turns it back into zero. Mutations 4 and 8 were only caught after the suite gained absolute expected values worked out by hand; the first version compared the score against itself and would have let both through. Hardware. Measured on a Seeed T1000-E, which carries an LR1110, with marked images so the running artefact is not in doubt. The board reported its radio as 869.4321289 MHz, 62.5 kHz, SF7, CR5, which is not the build default (869.618, SF8) and so also confirms the stored settings were loaded. A diagnostic command printed the configured factor, the factor the radio reports, and the score at several SNR values scaled by 1000: dev: ver => v1.18.0 (Build: f2dev-428a95c6) cfg=7 rep=7 s0=1000 s5=750 s10=375 s15=0 rx5=129 fix: ver => v1.18.0 (Build: f2fix6-score-sf) cfg=7 rep=7 s0=562 s5=187 s10=0 s15=0 rx5=1798 rep=7 on both images is the point: the LR1110 reports its configured spreading factor at runtime, which is what this fix relies on and which no host test can establish. The scores match the hand calculation for SF7 exactly. rx5 was 0 on both images until rxdelay was raised from its stored 0 to 10, which is how the scoping above was found rather than assumed. rxdelay was set back to 0 afterwards and all 15 settings read back identical to the values read before. Builds: the 18 environment matrix plus all 67 environments of the 11 affected variants, on this commit, against a dev worktree with the same toolchain. LR1110: minewsemi_me25ls01, t1000-e, thinknode_m3, thinknode_m4, thinknode_m7, thinknode_m9, wio_wm1110. LR1121: muzi_base, waveshare_esp32s3_lr1121. LR2021: meshnology_w12, meshtracker_x1. The five SX126x and SX127x family wrappers compute the same score as before, because the removed overrides read the same member getSpreadingFactor() returns on those wrappers. Their binaries shift by a few bytes, since the base class now reaches the factor through a virtual call; the figures are in the note. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 22 ++- src/helpers/radiolib/CustomLLCC68Wrapper.h | 4 - src/helpers/radiolib/CustomSTM32WLxWrapper.h | 4 - src/helpers/radiolib/CustomSX1262Wrapper.h | 4 - src/helpers/radiolib/CustomSX1268Wrapper.h | 4 - src/helpers/radiolib/CustomSX1276Wrapper.h | 4 - src/helpers/radiolib/RadioLibWrappers.h | 6 +- test/mocks/Arduino.h | 11 ++ test/mocks/RadioLib.h | 47 ++++++ .../test_radio_wrappers.cpp | 148 ++++++++++++++++++ 11 files changed, 233 insertions(+), 23 deletions(-) create mode 100644 test/mocks/RadioLib.h create mode 100644 test/test_radio_wrappers/test_radio_wrappers.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..066078f22c5 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_radio_wrappers -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index f296f838213..2d5179a5e8f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,7 +167,9 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = + test_kiss_modem + test_radio_wrappers build_src_filter = -<*> +<../src/Utils.cpp> @@ -177,6 +179,24 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_radio_wrappers] +; The real RadioLibWrapper against a minimal RadioLib stand-in, so the packet +; score can be checked on the host. LORA_SF is deliberately not 10, so a score +; that silently assumes sf=10 shows up as a failure. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks + -D LORA_SF=9 +test_build_src = yes +test_filter = test_radio_wrappers +build_src_filter = + -<*> + +<../src/helpers/radiolib/RadioLibWrappers.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_kiss_modem] platform = native test_framework = googletest diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index ae0fe0a2530..8264260a7ce 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -29,10 +29,6 @@ class CustomLLCC68Wrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomLLCC68 *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomLLCC68 *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomLLCC68 *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomLLCC68 *)_radio)->spreadingFactor; } void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index a4ba4971d04..521a451e384 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -29,10 +29,6 @@ class CustomSTM32WLxWrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomSTM32WLx *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomSTM32WLx *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomSTM32WLx *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomSTM32WLx *)_radio)->spreadingFactor; } void doResetAGC() override { sx126xResetAGC((SX126x *)_radio, getRxBoostedGainMode()); } diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index be3144716bf..c6af5fdbeaa 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -32,10 +32,6 @@ class CustomSX1262Wrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomSX1262 *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } virtual void powerOff() override { ((CustomSX1262 *)_radio)->sleep(false); diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 70f5dabdc6e..0fdc6a98bb3 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -32,10 +32,6 @@ class CustomSX1268Wrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomSX1268 *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomSX1268 *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomSX1268 *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomSX1268 *)_radio)->spreadingFactor; } bool setRxBoostedGainMode(bool en) override { diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index 9d75ce12a1b..71a19962a95 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -28,9 +28,5 @@ class CustomSX1276Wrapper : public RadioLibWrapper { float getLastRSSI() const override { return ((CustomSX1276 *)_radio)->getRSSI(); } float getLastSNR() const override { return ((CustomSX1276 *)_radio)->getSNR(); } - float packetScore(float snr, int packet_len) override { - int sf = ((CustomSX1276 *)_radio)->spreadingFactor; - return packetScoreInt(snr, sf, packet_len); - } uint8_t getSpreadingFactor() const override { return ((CustomSX1276 *)_radio)->spreadingFactor; } }; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116b5..4d8489557f3 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -73,7 +73,11 @@ class RadioLibWrapper : public mesh::Radio { virtual float getLastRSSI() const override; virtual float getLastSNR() const override; - float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 + // The score depends on the spreading factor, because the SNR a packet needs + // to be decodable rises as the spreading factor falls. Ask the driver for it + // rather than assume one: a guess here is wrong on every radio that does not + // happen to run at that factor, and it silently stays wrong. + float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, getSpreadingFactor(), packet_len); } virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 77499fe4145..2a45e7573ea 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include "Stream.h" @@ -7,6 +8,16 @@ inline uint32_t g_mock_millis = 0; using std::isnan; +// Arduino offers these as macros; the host gets the std versions, which +// pick the right overload for the double arithmetic in the core. +using std::max; +using std::min; + +// Arduino's two-argument random(), lower bound inclusive. Deterministic +// here, so a test that mixes it in stays reproducible. +inline long random(long lower, long upper) { + return upper > lower ? lower + (upper - lower) / 2 : lower; +} inline uint32_t millis() { return g_mock_millis; diff --git a/test/mocks/RadioLib.h b/test/mocks/RadioLib.h new file mode 100644 index 00000000000..c5ac0802c36 --- /dev/null +++ b/test/mocks/RadioLib.h @@ -0,0 +1,47 @@ +#pragma once + +// Minimal stand-in for RadioLib's PhysicalLayer, so the real RadioLibWrapper +// can be built and exercised on the host. Only the members the wrapper calls +// are present; each one records or returns test-visible state instead of +// touching hardware. Same idea as the Arduino and Mesh mocks next to it. + +// The real RadioLib.h pulls in the Arduino core, and the wrapper relies on +// that for millis(), min(), max() and random(). Mirror it here. +#include "Arduino.h" + +#include +#include + +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_CHANNEL_FREE (-2) + +class PhysicalLayer { +public: + virtual ~PhysicalLayer() = default; + + // State a test can set up or inspect. + float rssi = -100.0f; + float snr = 0.0f; + uint32_t time_on_air = 1000; + size_t packet_length = 0; + int16_t scan_result = RADIOLIB_CHANNEL_FREE; + size_t preamble_length = 0; + int8_t output_power = 0; + + int16_t sleep() { return RADIOLIB_ERR_NONE; } + int16_t standby() { return RADIOLIB_ERR_NONE; } + int16_t startReceive() { return RADIOLIB_ERR_NONE; } + int16_t startTransmit(uint8_t*, size_t) { return RADIOLIB_ERR_NONE; } + int16_t finishTransmit() { return RADIOLIB_ERR_NONE; } + size_t getPacketLength() { return packet_length; } + int16_t readData(uint8_t*, size_t) { return RADIOLIB_ERR_NONE; } + float getRSSI() { return rssi; } + float getSNR() { return snr; } + uint32_t getTimeOnAir(size_t) { return time_on_air; } + int16_t scanChannel() { return scan_result; } + int16_t setOutputPower(int8_t dbm) { output_power = dbm; return RADIOLIB_ERR_NONE; } + void setPacketReceivedAction(void (*)(void)) { } + int16_t setPreambleLength(size_t len) { preamble_length = len; return RADIOLIB_ERR_NONE; } + uint8_t randomByte() { return 0x5A; } + int32_t random(int32_t upper) { return upper / 2; } +}; diff --git a/test/test_radio_wrappers/test_radio_wrappers.cpp b/test/test_radio_wrappers/test_radio_wrappers.cpp new file mode 100644 index 00000000000..062a7db19cc --- /dev/null +++ b/test/test_radio_wrappers/test_radio_wrappers.cpp @@ -0,0 +1,148 @@ +#include + +#include + +// The packet score drives the flood rebroadcast delay (Dispatcher::calcRxDelay). +// It depends on the spreading factor, because the SNR a packet needs to be +// decodable rises as the spreading factor falls. These tests pin that the score +// a wrapper reports is the score for the spreading factor the radio reports, +// whether or not the wrapper overrides packetScore itself. + +namespace { + +class StubBoard : public mesh::MainBoard { +public: + uint16_t getBattMilliVolts() override { return 4200; } + const char* getManufacturerName() const override { return "stub"; } + void reboot() override { } + uint8_t getStartupReason() const override { return 0; } +}; + +// Stands in for the wrappers that report the spreading factor but leave +// packetScore to the base class (LR1110, LR1121, LR2021 do exactly this). +class ReportingWrapper : public RadioLibWrapper { +public: + uint8_t sf = 10; + + ReportingWrapper(PhysicalLayer& r, mesh::MainBoard& b) : RadioLibWrapper(r, b) { } + + void setParams(float, float, uint8_t, uint8_t) override { } + bool isReceivingPacket() override { return false; } + float getCurrentRSSI() override { return -100.0f; } + + uint8_t getSpreadingFactor() const override { return sf; } + + // packetScoreInt is protected; expose it so a test can state the expected + // value in terms of the formula under test rather than a copy of it. + float scoreAt(float snr, int sf_, int len) { return packetScoreInt(snr, sf_, len); } +}; + +// Stands in for a wrapper that reports nothing, so the base class falls back +// to the compile-time LORA_SF. +class SilentWrapper : public RadioLibWrapper { +public: + SilentWrapper(PhysicalLayer& r, mesh::MainBoard& b) : RadioLibWrapper(r, b) { } + void setParams(float, float, uint8_t, uint8_t) override { } + bool isReceivingPacket() override { return false; } + float getCurrentRSSI() override { return -100.0f; } +}; + +struct RadioWrapperFixture : public ::testing::Test { + PhysicalLayer radio; + StubBoard board; +}; + +TEST_F(RadioWrapperFixture, ScoreFollowsTheReportedSpreadingFactor) { + ReportingWrapper w(radio, board); + for (uint8_t sf = 7; sf <= 12; sf++) { + w.sf = sf; + for (float snr : {0.0f, -5.0f, -10.0f, -15.0f, -18.0f}) { + EXPECT_FLOAT_EQ(w.packetScore(snr, 64), w.scoreAt(snr, sf, 64)) + << "sf=" << (int)sf << " snr=" << snr; + } + } +} + +// The old base class assumed sf=10 for every radio that did not override +// packetScore. These are the cases where that assumption is wrong: at sf=7 it +// scored too high (delay too short), at sf=12 too low (delay too long). +TEST_F(RadioWrapperFixture, DoesNotAssumeSpreadingFactorTen) { + ReportingWrapper w(radio, board); + + w.sf = 7; + EXPECT_NE(w.packetScore(-5.0f, 64), w.scoreAt(-5.0f, 10, 64)); + EXPECT_LT(w.packetScore(-5.0f, 64), w.scoreAt(-5.0f, 10, 64)); + + w.sf = 12; + EXPECT_NE(w.packetScore(-10.0f, 64), w.scoreAt(-10.0f, 10, 64)); + EXPECT_GT(w.packetScore(-10.0f, 64), w.scoreAt(-10.0f, 10, 64)); +} + +// At sf=10 old and new agree, which is the probe on the arithmetic above. +TEST_F(RadioWrapperFixture, AgreesWithTheOldAssumptionAtSpreadingFactorTen) { + ReportingWrapper w(radio, board); + w.sf = 10; + for (float snr : {0.0f, -5.0f, -10.0f, -15.0f, -18.0f}) { + EXPECT_FLOAT_EQ(w.packetScore(snr, 64), w.scoreAt(snr, 10, 64)) << "snr=" << snr; + } +} + +// A wrapper that reports no spreading factor gets the build's LORA_SF, not a +// hardcoded 10. The env builds with LORA_SF=9 so the two differ. +TEST_F(RadioWrapperFixture, FallsBackToTheBuildsSpreadingFactor) { + SilentWrapper silent(radio, board); + ReportingWrapper probe(radio, board); + EXPECT_EQ(silent.getSpreadingFactor(), LORA_SF); + EXPECT_FLOAT_EQ(silent.packetScore(-5.0f, 64), probe.scoreAt(-5.0f, LORA_SF, 64)); +} + +// The score also carries the length penalty, so a longer packet scores lower. +TEST_F(RadioWrapperFixture, LongerPacketsScoreLower) { + ReportingWrapper w(radio, board); + w.sf = 8; + EXPECT_GT(w.packetScore(0.0f, 16), w.packetScore(0.0f, 240)); +} + +// Below the SNR threshold for the reported spreading factor there is no chance +// of success, so the score is zero. +TEST_F(RadioWrapperFixture, BelowThresholdScoresZero) { + ReportingWrapper w(radio, board); + w.sf = 7; + EXPECT_FLOAT_EQ(w.packetScore(-8.0f, 64), 0.0f); + w.sf = 12; + EXPECT_FLOAT_EQ(w.packetScore(-21.0f, 64), 0.0f); +} + +// Absolute values, worked out by hand from the formula, so a shifted SNR +// threshold, a dropped length penalty or a dropped clamp cannot hide behind a +// comparison of the score against itself. +// score = clamp((snr - threshold[sf]) / 10 * (1 - len / 256), 0, 1) +TEST_F(RadioWrapperFixture, ScoreMatchesTheFormulaByHand) { + ReportingWrapper w(radio, board); + + // sf=8 threshold -10 dB: (-5 + 10) / 10 * (1 - 64/256) = 0.5 * 0.75 + w.sf = 8; + EXPECT_FLOAT_EQ(w.packetScore(-5.0f, 64), 0.375f); + + // sf=7 threshold -7.5 dB: (0 + 7.5) / 10 * (1 - 128/256) = 0.75 * 0.5 + w.sf = 7; + EXPECT_FLOAT_EQ(w.packetScore(0.0f, 128), 0.375f); + + // sf=12 threshold -20 dB: (0 + 20) / 10 * 0.75 = 1.5, clamped to 1.0 + w.sf = 12; + EXPECT_FLOAT_EQ(w.packetScore(0.0f, 64), 1.0f); + + // exactly at the threshold the score is zero, and half a dB above it the + // formula already applies: (-14.5 + 15) / 10 * 0.75. Pinning a value this + // close to the threshold is what keeps the threshold itself under test. + w.sf = 10; + EXPECT_FLOAT_EQ(w.packetScore(-15.0f, 64), 0.0f); + EXPECT_FLOAT_EQ(w.packetScore(-14.5f, 64), 0.0375f); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 49ed34bebe6d1db19b41e208d85fda20ac33aa0f Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 01:50:36 +0200 Subject: [PATCH 067/136] docs(fixes): note for the spreading factor in the packet score Records the one line change in the base class, the five overrides it makes redundant, and why the fix goes into the base rather than into the three wrappers that were missing it: a default that guesses reproduces the defect for the next driver, a default that asks cannot. Records the behaviour change with its real scope rather than the easy version. The score reaches the logging and the flood rebroadcast delay, but all four roles override calcRxDelay and use the configured rxdelay as the base, and rxdelay is 0 by default in every role, so on a default configuration only the logging changes. The table of scores and delays is for rxdelay at 10, the historical default, and the hardware measurement confirms both halves of that. Records the hardware run on an LR1110 with marked images: the radio reports the spreading factor it was configured with, which is what the fix relies on and what no host test can establish, and the scores match the hand calculation for SF7. Records the four alternatives that were rejected, the eight mutations with the one equivalent mutant explained, and the weakness the mutations exposed in the first version of the test, which compared the score against itself. Adds the diagnostic patch the hardware run needs. It prints the configured factor, the factor the radio reports and the score at several SNR values; it is a test aid and part of no fix, kept beside nodetool.py like diag-tuning.patch. This commit is documentation only and can be dropped when the fix is proposed upstream. --- docs/fixes/lr-packet-score-sf.md | 313 ++++++++++++++++++++++ tools/refactoring/diag-packet-score.patch | 25 ++ 2 files changed, 338 insertions(+) create mode 100644 docs/fixes/lr-packet-score-sf.md create mode 100644 tools/refactoring/diag-packet-score.patch diff --git a/docs/fixes/lr-packet-score-sf.md b/docs/fixes/lr-packet-score-sf.md new file mode 100644 index 00000000000..c6e37a2678a --- /dev/null +++ b/docs/fixes/lr-packet-score-sf.md @@ -0,0 +1,313 @@ +# Fix: the packet score assumed spreading factor 10 on LR radios + +Status: ready for review. Behaviour change: yes on LR1110, LR1121 and LR2021 +boards, scoped under Behaviour change below. + +## Context + +`Dispatcher::checkRecv()` scores every received packet and uses the score to +decide how long to sit on a flood packet before rebroadcasting it. A node that +received a packet well rebroadcasts sooner, a node that barely heard it waits, so +the better placed neighbour gets the air. `src/Dispatcher.cpp:207` fetches the +score, `:240` restricts it to flood routed packets, `:243` turns it into a delay +and `:244` processes immediately below 50 ms. + +The score depends on the spreading factor, because the SNR a packet needs to be +decodable rises as the spreading factor falls. `RadioLibWrapper::packetScoreInt()` +holds one threshold per factor, from -7.5 dB at SF7 to -20 dB at SF12. + +The base class did not ask for the factor. `RadioLibWrappers.h:76`: + +```cpp +float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 +``` + +Five of the eight wrappers overrode it, all five in the same shape: read the +factor off the radio, then call `packetScoreInt`. Three did not. + +| Wrapper | overrides `packetScore` | overrides `getSpreadingFactor` | +|---------|------------------------|-------------------------------| +| `CustomLLCC68Wrapper.h` | yes, line 32 | yes | +| `CustomSTM32WLxWrapper.h` | yes, line 32 | yes | +| `CustomSX1262Wrapper.h` | yes, line 35 | yes | +| `CustomSX1268Wrapper.h` | yes, line 35 | yes | +| `CustomSX1276Wrapper.h` | yes, line 31 | yes | +| `CustomLR1110Wrapper.h` | **no** | yes, line 44 | +| `CustomLR1121Wrapper.h` | **no** | yes, line 45 | +| `CustomLR2021Wrapper.h` | **no** | yes, line 94 | + +These are all eight subclasses of `RadioLibWrapper`, and `ESPNOWRadio` (which +returns 0) is the only other radio in the tree. + +The decisive detail is in the right hand column: all eight already report the +factor through `getSpreadingFactor()`, and in the five overriding wrappers that +function reads exactly the same member the override reads. For example +`CustomSX1262Wrapper.h:36` uses `((CustomSX1262 *)_radio)->spreadingFactor` inside +`packetScore` and line 39 returns the same expression from `getSpreadingFactor()`. +So the five overrides were, line for line, what the base class could have computed +by asking. + +So the three LR wrappers scored every packet as if the radio ran at SF10, on every +role, while the information needed was one virtual call away. + +## Decision + +The base class asks instead of assuming, and the five overrides that said the same +thing go away: + +```cpp +float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, getSpreadingFactor(), packet_len); } +``` + +Net effect on the tree: five insertions, 21 deletions. + +Two reasons for fixing it in the base rather than adding three overrides: + +- A default that guesses reproduces this defect for the next driver. A default + that asks cannot: a new wrapper that reports its factor is scored correctly + without doing anything, and one that reports nothing falls back to the build's + `LORA_SF`, which is at least that build's actual configuration rather than an + unrelated constant. +- It is what makes the five existing chips provably unchanged. Each removed + override read the same member that `getSpreadingFactor()` on the same wrapper + returns, so the value fed into `packetScoreInt` is identical. Their code is not + byte identical afterwards, because a direct member read becomes a virtual call, + and the build matrix shows that as a handful of bytes. What is unchanged is the + score, and the host test pins that at SF10 and across the range. + +## Alternatives considered + +- **Add the missing override to the three LR wrappers.** The smallest diff that + fixes the symptom, and the shape upstream already uses. Rejected: it leaves the + guessing default in place as a trap, and leaves the same four lines copied eight + times. +- **Keep the literal but make it `LORA_SF`.** Cheaper than a virtual call and + correct for boards that never retune. Rejected: the spreading factor is a + runtime setting (`set radio ...`), so a compile-time constant is wrong as soon + as an operator changes it, which is precisely the case on the board measured + below (configured SF7, build default SF8). +- **Make `packetScore` non-virtual in the base.** Tidier still, but + `mesh::Radio::packetScore` is pure virtual and `ESPNOWRadio` needs its own + answer, so the hook has to stay. +- **Return 0 when no factor is known, to force every driver to report one.** Would + surface future omissions loudly. Rejected: a zero score means maximum delay, so + the failure mode is a node that stops rebroadcasting, which is worse than a + slightly wrong score. + +## Behaviour change + +On LR1110, LR1121 and LR2021 boards the score changes whenever the radio does not +run at SF10. Below SF10 the old code scored too high, because the SF10 threshold +of -15 dB is more generous than the real one, so those nodes rebroadcast too +eagerly. Above SF10 it scored too low and they held back too long. At SF10 nothing +changes, which is the probe on the arithmetic. + +What the score feeds into matters for how far this reaches. `calcRxDelay` in +`src/Dispatcher.cpp:55` uses a base of 10, but **all four roles override it** and +use the configured `rxdelay` as the base instead: + +```cpp +if (_prefs.rx_delay_base <= 0.0f) return 0; +return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); +``` + +(`examples/companion_radio/MyMesh.cpp:273`, `examples/simple_repeater/MyMesh.cpp:542`, +`examples/simple_room_server/MyMesh.cpp:277`, `examples/simple_sensor/SensorMesh.cpp:325`.) + +And `rx_delay_base` is **0 by default in every role**: companion +`NodePrefs.h:29`, repeater `MyMesh.cpp:892` ("turn off by default, was 10.0"), +room server `MyMesh.cpp:648`, sensor `SensorMesh.cpp:800`. The companion even has +the old default commented out at `MyMesh.cpp:934` with "enable once new algo +fixed". + +So on a default configuration the score reaches only the logging +(`Dispatcher.cpp:224` and the `logRx` hook at `:238`) and the delay stays 0. The +rebroadcast timing of an LR board changes only where an operator has set +`rxdelay` above 0. That is a narrower claim than "LR boards rebroadcast at the +wrong time", and it is the one the measurements support. + +With `rxdelay` at 10, the historical default, for a 64 byte packet and 500 ms +airtime: + +| SF | SNR | score old | delay old | score new | delay new | +|----|-----|-----------|-----------|-----------|-----------| +| 7 | 0 dB | 1.000 | 0 ms | 0.562 | 469 ms | +| 7 | -5 dB | 0.750 | 129 ms | 0.188 | 1799 ms | +| 8 | -5 dB | 0.750 | 129 ms | 0.375 | 993 ms | +| 9 | -5 dB | 0.750 | 129 ms | 0.562 | 469 ms | +| 10 | any | unchanged | unchanged | unchanged | unchanged | +| 11 | -15 dB | 0.000 | 3040 ms | 0.188 | 1799 ms | +| 12 | -10 dB | 0.375 | 993 ms | 0.750 | 129 ms | + +The 18 affected variants' environments are listed under Builds. + +## Compatibility + +`/prefs.json` and the CLI are untouched. No setting changes meaning, so upgrade +and downgrade are unaffected. The five SX126x and SX127x family wrappers compute +the same score as before, because the removed overrides read the same member that +`getSpreadingFactor()` returns on those wrappers. Their binaries do shift by a few +bytes, since the base class now reaches the factor through a virtual call; the +build matrix lists the exact figures per environment. + +## Verification + +### Host tests + +The existing native environments cover prefs and tables, not the radio wrappers, +so this fix adds one. `native_radio_wrappers` builds the real +`src/helpers/radiolib/RadioLibWrappers.cpp` against a minimal stand-in for +RadioLib's `PhysicalLayer` in `test/mocks/RadioLib.h`, the same approach the +`native` environments already take for the Arduino core and `Mesh`. The code under +test is the shipping code, not a copy of it: the test subclass only supplies the +three pure virtuals and reports a spreading factor. + +`LORA_SF` is deliberately set to 9 in that environment, so a score that silently +assumes 10 fails rather than passes by coincidence. + +`test/mocks/Arduino.h` gains `min`, `max` and the two argument `random()`, which +the wrapper needs and the existing suites never reached. `test/mocks/RadioLib.h` +includes `Arduino.h`, mirroring the real `RadioLib.h`. + +Before the fix, `pio test -e native_radio_wrappers`: + +``` +RadioWrapperFixture.ScoreFollowsTheReportedSpreadingFactor [FAILED] +RadioWrapperFixture.DoesNotAssumeSpreadingFactorTen [FAILED] +RadioWrapperFixture.AgreesWithTheOldAssumptionAtSpreadingFactorTen [PASSED] +RadioWrapperFixture.FallsBackToTheBuildsSpreadingFactor [FAILED] +RadioWrapperFixture.LongerPacketsScoreLower [PASSED] +RadioWrapperFixture.BelowThresholdScoresZero [FAILED] +``` + +The two that pass are the ones that must: SF10 is where old and new agree, and the +length penalty is independent of the factor. + +After the fix, all seven pass, and the three existing suites with them: + +``` +native test_mesh_tables PASSED +native test_config_serializer PASSED +native test_utils PASSED +native test_utf8_helpers PASSED +native test_routing_policy PASSED +native_radio_wrappers test_radio_wrappers PASSED +native_kiss_modem test_kiss_modem PASSED +================= 61 test cases: 61 succeeded ================= +``` + +`native_core` and `native_ofast` do not exist on this base. + +### Mutation tests + +Eight deliberate errors against the suite above. + +| # | Mutation | Result | +|---|----------|--------| +| 1 | `getSpreadingFactor() + 1` | caught, 6 failures | +| 2 | `getSpreadingFactor() - 1` | caught, 4 failures | +| 3 | back to the literal `10` (the original defect) | caught, 5 failures | +| 4 | SF8 threshold moved from -10 to -11 | caught, 1 failure | +| 5 | length penalty replaced by 1.0 | caught, 2 failures | +| 6 | clamp removed | caught, 1 failure | +| 7 | threshold guard loosened by 1 dB | not caught, equivalent mutant | +| 8 | threshold guard tightened by 1 dB | caught, 1 failure | + +Mutation 7 is not a gap. Loosening the guard lets an SNR between the threshold and +1 dB below it through, where the rate is negative and the clamp turns it back into +zero, so the mutant is behaviourally identical: + +``` +snr=-10.5: rate=-0.050 product=-0.0375 after clamp=0.0 +snr=-10.2: rate=-0.020 product=-0.0150 after clamp=0.0 +``` + +Tightening the guard, mutation 8, is a real change and is caught. Mutations 4 and 8 +were only caught after the suite gained absolute expected values worked out by +hand; the first version compared the score against itself and would have let both +through. That weakness was found by mutation 8 and fixed before this was written. + +### Builds + +`RadioLibWrappers.h` is built by 612 environments across five toolchains, counted +from the resolved configuration (`pio project config --json-output`, environments +carrying `WRAPPER_CLASS`): 315 on espressif32@6.11.0, 240 on nordicnrf52, 25 on +rp2040, 16 on ststm32 and 16 on pioarduino-esp32. The first draft of this change +was built on four of those five, which left the pioarduino platform, with its own +ESP-IDF, entirely untested. All five are covered now. + +Built on this commit against a `dev` worktree with the same toolchain: the 18 +environment matrix, all 67 environments of the 11 affected variants, and all 16 +pioarduino environments. + +LR1110: `minewsemi_me25ls01`, `t1000-e`, `thinknode_m3`, `thinknode_m4`, +`thinknode_m7`, `thinknode_m9`, `wio_wm1110`. LR1121: `muzi_base`, +`waveshare_esp32s3_lr1121`. LR2021: `meshnology_w12`, `meshtracker_x1`. + +Of the 85 environments in the first two groups, 84 built on both sides. Flash +moves between -8 and +48 bytes, and RAM does not move at all in any of them: + +| Change | Environments | +|--------|--------------| +| +48 bytes | 10 | +| +32 bytes | 37 | +| +24 bytes | 1 | +| +16 bytes | 3 | +| +12 bytes | 1 | +| +8 bytes | 28 | +| +4 bytes | 2 | +| unchanged | 1, `wio-e5-mini_sensor` | +| -8 bytes | 1, `wio-e5-mini_repeater` | + +That spread is what replacing a direct member read with a virtual call costs, and +it is the measured answer to the question the first draft of this note got wrong +by claiming the five existing chips stayed byte identical. + +All 16 pioarduino environments built on both sides, with flash moving +12 to +16 +bytes and RAM again not moving: + +| Change | Environments | +|--------|--------------| +| +16 bytes | 1, `LilyGo_Tlora_C6_kiss_modem` | +| +14 bytes | 14 | +| +12 bytes | 1, `M5Stack_Unit_C6L_kiss_modem` | + +`MeshTracker_X1_kiss_modem` is the one environment that does not build, and it +fails identically on both sides with `fatal error: MicroNMEA.h: No such file or +directory`. That is a pre-existing defect of the variant, where only this one of +its five environments does not list the dependency; it is unrelated to this change +and is fixed separately. + +### Hardware + +Measured on a Seeed T1000-E, which carries an LR1110, with marked +`t1000e_companion_radio_usb` images so the running artefact is not in doubt. The +board reports its configured radio settings as 869.4321289 MHz, 62.5 kHz, **SF7**, +CR5. SF7 is the most affected case, and it is not the build default (869.618, +SF8), which also confirms the stored settings were really loaded. + +A diagnostic command, `tools/refactoring/diag-packet-score.patch`, prints the +configured factor, the factor the radio reports and the score at several SNR +values, scaled by 1000. It is a test aid and part of no fix. + +``` +dev: ver => v1.18.0 (Build: f2dev-428a95c6) + diag score => cfg=7 rep=7 s0=1000 s5=750 s10=375 s15=0 rx5=129 +fix: ver => v1.18.0 (Build: f2fix6-score-sf) + diag score => cfg=7 rep=7 s0=562 s5=187 s10=0 s15=0 rx5=1798 +``` + +Three things are settled by this, which no host test could settle: + +- `rep=7` on both images: the LR1110 really does report its configured spreading + factor at runtime. The fix rests on that, and it is now measured rather than + argued from the source. +- The scores match the hand calculation for SF7 exactly: 0.5625, 0.1875 and 0 + against the old 1.0, 0.75 and 0.375. +- `rx5` was 0 on both images until `rxdelay` was raised from its stored 0 to 10, + which is how the scoping above was found rather than assumed. At `rxdelay` 10 the + rebroadcast delay at -5 dB SNR goes from 129 ms to 1798 ms, matching the table. + +`rxdelay` was set back to 0 afterwards and all 15 settings read back identical to +the values read before the measurement. The board was then returned to its stock +`companion_radio_ble` v1.17.1 image. diff --git a/tools/refactoring/diag-packet-score.patch b/tools/refactoring/diag-packet-score.patch new file mode 100644 index 00000000000..dad017fc009 --- /dev/null +++ b/tools/refactoring/diag-packet-score.patch @@ -0,0 +1,25 @@ +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index 99a9aa30..eeec4e39 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -2077,6 +2077,20 @@ void MyMesh::enterCLIRescue() { + } + + bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { ++ if (strcmp(command, "diag score") == 0) { // diagnostic build only, not part of any commit ++ // cfg = spreading factor the prefs ask for, rep = what the radio reports. ++ // sNNN = packetScore at that SNR for a 64 byte packet, scaled by 1000. ++ // rx5 = flood rebroadcast delay at SNR -5 dB and 500 ms airtime. ++ sprintf(reply, "> cfg=%d rep=%d s0=%d s5=%d s10=%d s15=%d rx5=%d", ++ (int)_prefs.sf, ++ (int)radio_driver.getSpreadingFactor(), ++ (int)(radio_driver.packetScore(0.0f, 64) * 1000), ++ (int)(radio_driver.packetScore(-5.0f, 64) * 1000), ++ (int)(radio_driver.packetScore(-10.0f, 64) * 1000), ++ (int)(radio_driver.packetScore(-15.0f, 64) * 1000), ++ calcRxDelay(radio_driver.packetScore(-5.0f, 64), 500)); ++ return true; ++ } + while (*command == ' ') command++; // skip leading spaces + + if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) From 7075e3aa9cc56c5ddbcdeda46f001add70593926 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 10:39:26 +0200 Subject: [PATCH 068/136] test(mocks): make min and max functions, not macros The CI build of the unit tests failed on GCC while the same tree built and passed with clang locally. Cause: the mock defined min() and max() as function-like macros, and helpers/CommonRadioPrefs.cpp uses both words as ordinary identifiers, a struct field 'float max' and a parameter named 'max'. The macro turns those declarations into a syntax error, which is what GCC reported as 'expected unqualified-id before (' at the macro definition. Reproduced before fixing, rather than assumed: compiling helpers/CommonRadioPrefs.cpp with -D'max(a,b)=...' on the command line fails with five errors, and compiles cleanly with the mock's own header included instead. Replace both with inline function templates. Two type parameters, because the call sites mix types: the three roles call min(sp - hex, PUB_KEY_SIZE*2), a pointer difference against an int. Nothing in src/, examples/ or test/ does 'using namespace std', so there is no ambiguity with std::min and std::max. Verification - pio test over all six environments: 122 of 122 test cases pass on this branch. - GCC is what this fixes and it is NOT verified locally; no GCC is installed on the development machine. The fork pull requests are what confirms it. --- test/mocks/Arduino.h | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index 45a11faf783..76eb690c23d 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -59,12 +59,17 @@ inline long random(long howsmall, long howbig) { inline long random(long howbig) { return random(0, howbig); } -#ifndef min - #define min(a, b) ((a) < (b) ? (a) : (b)) -#endif -#ifndef max - #define max(a, b) ((a) > (b) ? (a) : (b)) -#endif +// Arduino's min() and max(), as functions rather than macros. They must not be +// macros: production code uses those words as ordinary identifiers, and +// helpers/CommonRadioPrefs.cpp has both a struct field 'float max' and a parameter +// named 'max', which a function-like macro turns into a syntax error. Two type +// parameters, because the call sites mix types, for example +// min(sp - hex, PUB_KEY_SIZE*2) with a pointer difference and an int. +template +inline auto min(T a, U b) -> decltype(a < b ? a : b) { return a < b ? a : b; } + +template +inline auto max(T a, U b) -> decltype(a > b ? a : b) { return a > b ? a : b; } // Arduino's Serial. It keeps what the firmware printed, so a test can assert on // output the roles write there (the packet log dump, for example). From fed9334cf6119a0370e89a71cb60b469988a8e25 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:26:05 +0200 Subject: [PATCH 069/136] fix(sensor): make multi.acks effective and apply RX boosted gain at boot What changes - multi.acks takes effect: the override that reported no count whatever the setting said is gone, so the core reports the stored value as it does for the repeater and the room server. The branch in SensorMesh::sendAckTo() that was guarded by that count stops being dead code. - RX boosted gain is applied at boot: the pref is initialised under the same guards as the other roles and begin() pushes it to the radio, so a value set over the CLI survives a restart. Mirrors upstream's own room server fix a71cc238. Neither can change what an unreconfigured node does: an extra ACK goes out only when multi.acks is on, which is off by default, and the value pushed at boot is whatever was already stored. What is not here - The flood hop limits, the third part of the alignment. They stay on refactor/sensor-flood-limits until /prefs.json is range checked (Fix 9), because a sensor that has ever saved its settings carries an explicit zero for both, the loader applies it verbatim, and zero means 'never forward'. Marcel's decision of 20 October. Measured and written up in docs/refactoring/0005-sensor-alignment.md. - The retransmit delay, which stays a role parameter: upstream still calls the continuous version experimental. Pinned by a test. - setRxBoostedGain(), which upstream's room server fix included but which nothing calls; it would be dead code here too. Why - Neither deviation has a role specific reason. Each is a change that reached the other two roles and skipped the sensor, traced commit by commit in docs/refactoring/architecture-review-v1.18.md under finding F2. Verification - pio test over all six environments: 123 of 123 test cases pass. The branch that also carries the flood limits has 127; the four extra cases belong to it. - OnlyFloodMaxIsAppliedToday still passes, which is the evidence that the flood behaviour is untouched on this branch. - Hardware: NOT DONE. techo-b43b is not to be claimed until F1 to F4 are merged. It is what would show that a stored radio.rxgain survives a reboot on an SX1262 board; the host environment defines neither USE_SX1262 nor SX126X_RX_BOOSTED_GAIN, so it cannot. --- docs/refactoring/0005-sensor-alignment.md | 105 +++++++++++++++++++++ examples/simple_sensor/SensorMesh.cpp | 13 ++- examples/simple_sensor/SensorMesh.h | 6 +- test/test_sensor_role/test_sensor_role.cpp | 47 +++++++-- 4 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 docs/refactoring/0005-sensor-alignment.md diff --git a/docs/refactoring/0005-sensor-alignment.md b/docs/refactoring/0005-sensor-alignment.md new file mode 100644 index 00000000000..ca3e9ce9ef2 --- /dev/null +++ b/docs/refactoring/0005-sensor-alignment.md @@ -0,0 +1,105 @@ +# 0005: Align the sensor, part one: multi.acks and RX boosted gain + +Status: implemented. The third part of the alignment, the flood hop limits, is +parked on `refactor/sensor-flood-limits` and waits for the `/prefs.json` range +checking; the reason is in "What is deliberately held back" below. + +This is a change of behaviour, not a move, so it is a branch of its own with a note +of its own. + +## Context + +The sensor sketch was copied from the repeater in July 2025 and then missed three +changes that reached the repeater and the room server. The commit by commit history +is in `architecture-review-v1.18.md` under finding F2. None of the deviations has a +role specific reason such as battery life: each one is a change the sensor was left +out of. + +Marcel's decision of 19 October is to align the sensor, with the retransmit delay +excepted and the flood limits deferred. + +## What changes + +- **`multi.acks` takes effect.** The sensor's `getExtraAckTransmitCount()` override + returned 0 with a comment saying it had never sent extra ACKs. Deleting it lets + the core report the stored setting, as it does for the other roles since step 2. + `SensorMesh::sendAckTo()` already had a branch guarded by that count, so the + branch stops being dead code. +- **RX boosted gain is applied at boot.** The sensor initialises the pref under the + same `USE_SX1262 || USE_SX1268` and `SX126X_RX_BOOSTED_GAIN` guards as the other + roles, and `begin()` pushes the stored value to the radio. Before, a value set + over the CLI took effect at once and was gone after a restart. This mirrors + upstream's own fix for the room server, `a71cc238`. + +Neither change can alter what a node that has not been reconfigured does: an extra +ACK is only sent when `multi.acks` is on, which is off by default, and the gain +value pushed at boot is whatever was already stored. + +## What deliberately does not change + +The retransmit delay. The sensor draws from six discrete multiples of the airtime +while the other roles draw uniformly from a continuous range. Upstream moved them +in `8cbcd227`, whose subject calls itself experimental, and has neither revisited +nor confirmed it since. Aligning the sensor would adopt that experiment and change +radio timing on battery nodes, so the two overrides stay and the formula becomes the +one role parameter of the shared base. Pinned by a test, so it cannot be lost by +accident. + +## Not added on purpose + +`setRxBoostedGain()`, which upstream's room server fix also added. The virtual has a +default in `helpers/CommonCLI.h` and overrides in the repeater and the room server, +and **nothing calls it**: the only `radio.rxgain` command handler +(`helpers/CommonRadioPrefs.cpp`) reaches the radio driver directly, and the settings +table lists the field as `CLI_NONE`, which the generic loop skips. Adding it to the +sensor would add dead code. The two existing overrides are vestigial and are left +for a separate clean-up (ledger issue 11). + +## What is deliberately held back + +The flood hop limits. `allowPacketForward()` still checks `flood_max` only, so +`flood.max.unscoped` and `flood.max.advert` remain without effect on the sensor. +The work exists on `refactor/sensor-flood-limits`, with its tests, and is not merged +because of an upgrade hazard that was measured rather than assumed: + +1. A hop limit of zero is written to `prefs.json` as an explicit `0`, not left + empty. +2. Loading such a file applies the 0, because `/prefs.json` is read through + `loadSerial()`, which has no range checking (Fix 9 of the F4 session). +3. The limits are compared with `>=`, so 0 means "never forward". A node booting on + such a file would refuse every un-scoped flood and every advert at hop 0. + +The sensor set no default for either limit, so every sensor that has ever saved its +settings carries `f_max_uns:0, f_max_adv:0`. Giving the constructor the values the +other roles use is not enough, because the stored zero beats the default. + +How far it reaches: forwarding is off by default on the sensor +(`_prefs.disable_fwd = true` in its constructor, as on the room server; only the +repeater forwards out of the box). The hazard therefore only affects sensors where +an operator switched forwarding on, and those are exactly the ones where it matters. + +Marcel's decision of 20 October: wait for Fix 9. Once `/prefs.json` is range +checked, the cause is gone and the flood limits can be aligned without a migration +of their own. Until then only the two changes above land. + +A separate observation, worth its own issue either way (ledger issue 12): because a +0 is stored and applied verbatim, `set flood.max.unscoped 0` is persistable on every +role and the constructor default does not protect against it. That is the setting +upstream's own comment describes repeaters as running. + +## Verification + +The sensor role had no host environment before this. `native_sensor` and +`test/test_sensor_role` were added first, and the behaviour was recorded there +before anything changed (see the preceding commit); the two tests for these changes +now carry the new expectations, so the diff of the test file is the record of what +changed. `OnlyFloodMaxIsAppliedToday` still passes, which is the evidence that the +flood behaviour is untouched on this branch. + +- `pio test` over all six environments: 123 of 123 test cases pass. (The branch + that also carries the flood limits has 127; the four extra cases are its own.) +- Hardware: **not done.** `techo-b43b` is the sensor board and is not to be claimed + until the F1 to F4 fixes are merged. What it should cover for this change: that a + stored `radio.rxgain` really survives a reboot on an SX1262 board, which the host + environment cannot show because it defines neither `USE_SX1262` nor + `SX126X_RX_BOOSTED_GAIN`. diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 7537b1f249e..75fa8b74a94 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -326,10 +326,6 @@ uint32_t SensorMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 6)*t; } -uint8_t SensorMesh::getExtraAckTransmitCount() const { - return 0; // the sensor has never sent extra ACKs, whatever multi.acks says -} - uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) { ClientInfo* client; if (data[0] == 0) { // blank password, just check if sender is in ACL @@ -747,6 +743,14 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.gps_enabled = 0; _prefs.gps_interval = 0; _prefs.advert_loc_policy = ADVERT_LOC_PREFS; + +#if defined(USE_SX1262) || defined(USE_SX1268) +#ifdef SX126X_RX_BOOSTED_GAIN + _prefs.rx_boosted_gain = SX126X_RX_BOOSTED_GAIN; +#else + _prefs.rx_boosted_gain = 1; // enabled by default; +#endif +#endif _prefs.radio_fem_rxgain = 1; _prefs.radio_fem_txgain = 0; @@ -783,6 +787,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_driver.setTxPower(_prefs.tx_power_dbm); + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); board.attachDynamicPrefs(_prefs.getCustom()); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 4fc1d33d4e7..16a048573e3 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -105,11 +105,11 @@ class SensorMesh : public ServerMesh { // Mesh overrides bool allowPacketForward(const mesh::Packet* packet) override; - // The sensor keeps its own retransmit delays and sends no extra ACKs, unlike the - // other roles; aligning it would change its behaviour (see docs/refactoring). + // The sensor keeps its own retransmit delays: it draws from six discrete steps + // while the other roles draw from a continuous range, and upstream still calls + // that change experimental (see docs/refactoring/0005-sensor-alignment.md). uint32_t getRetransmitDelay(const mesh::Packet* packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; - uint8_t getExtraAckTransmitCount() const override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; diff --git a/test/test_sensor_role/test_sensor_role.cpp b/test/test_sensor_role/test_sensor_role.cpp index 8f37c4e3433..f3fc918b931 100644 --- a/test/test_sensor_role/test_sensor_role.cpp +++ b/test/test_sensor_role/test_sensor_role.cpp @@ -8,6 +8,9 @@ #include +#include +#include + #include #include #include @@ -86,12 +89,16 @@ static mesh::Packet floodPacketWithHops(uint8_t route_type, uint8_t hops) { return pkt; } -TEST_F(SensorRoleTest, MultiAcksHasNoEffectToday) { - // The CLI accepts and stores the setting, and SensorMesh::sendAckTo() even has a - // branch guarded by it, but the role never reports a count, so the branch is dead. +TEST_F(SensorRoleTest, MultiAcksTakesEffect) { + // Was: the role reported no count whatever the setting said, so the branch in + // SensorMesh::sendAckTo() was dead and 'set multi.acks on' did nothing. The + // override is gone, so the core reports the stored setting as it does for the + // other two roles. mesh->prefs()->multi_acks = 1; + EXPECT_EQ(mesh->extraAcks(), 1); - EXPECT_EQ(mesh->extraAcks(), 0) << "today the sensor sends no extra ACKs"; + mesh->prefs()->multi_acks = 0; + EXPECT_EQ(mesh->extraAcks(), 0) << "and off still means off"; } TEST_F(SensorRoleTest, OnlyFloodMaxIsAppliedToday) { @@ -125,12 +132,32 @@ TEST_F(SensorRoleTest, ForwardingCanBeTurnedOffEntirely) { EXPECT_FALSE(mesh->forwards(&pkt)); } -TEST_F(SensorRoleTest, TheBoostedGainPrefIsNotAppliedAtBootToday) { - // begin() has already run in SetUp(). The other two roles push the stored value - // to the radio there; the sensor does not, so a value set over the CLI is applied - // at once but is gone after a reboot. - EXPECT_EQ(radio_driver.rx_boost_calls, 0) << "today the sensor applies nothing"; - EXPECT_EQ(mesh->prefs()->rx_boosted_gain, 0) << "and leaves the pref at zero"; +TEST_F(SensorRoleTest, TheBoostedGainPrefIsAppliedAtBoot) { + // Was: the sensor pushed nothing to the radio in begin(), so a value set over the + // CLI applied at once and was gone after a reboot. begin() has already run in + // SetUp(), so the call must have happened by now. + EXPECT_EQ(radio_driver.rx_boost_calls, 1); + + // The pref itself is only defaulted on SX1262 and SX1268 builds, which this host + // environment is not, so it stays at zero here and the applied value is zero. + EXPECT_EQ(mesh->prefs()->rx_boosted_gain, 0); + EXPECT_FALSE(radio_driver.rx_boosted); +} + +TEST_F(SensorRoleTest, AStoredBoostedGainSurvivesAReboot) { + // The point of applying it in begin(): the value has to reach the radio again + // after a restart, not only when the command is given. + const char* stored = "{radio:{rxgain:1}}"; + const std::string text(stored); + fs.files["/prefs.json"] = std::vector(text.begin(), text.end()); + + radio_driver.reset(); + TestSensor rebooted(board, radio_driver, ms, rng, rtc_clock, tables); + rebooted.begin(&fs); + + EXPECT_EQ(rebooted.prefs()->rx_boosted_gain, 1); + EXPECT_EQ(radio_driver.rx_boost_calls, 1); + EXPECT_TRUE(radio_driver.rx_boosted) << "the stored setting reached the radio"; } TEST_F(SensorRoleTest, TheRetransmitDelayDrawsFromSixDiscreteSlots) { From 83e854df6b330aaebd95dbc2b086000285af9588 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:14:38 +0200 Subject: [PATCH 070/136] docs(refactoring): two findings from the sensor alignment - setRxBoostedGain() is a virtual with a default, two overrides and no callers. The radio.rxgain handler reaches the radio driver directly and the settings table marks the field CLI_NONE, which the generic loop skips. Noted rather than cleaned up, and the reason the sensor did not get a third copy of it. - A hop limit of zero is stored and applied verbatim, so it persists and the constructor default cannot protect against it. Together with the missing range checking on /prefs.json (issue 7) that is how a node can boot into a state where it forwards nothing. This is the open question of 0005. --- docs/refactoring/architecture-review-v1.18.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md index 561e59539da..fa36f01a9c1 100644 --- a/docs/refactoring/architecture-review-v1.18.md +++ b/docs/refactoring/architecture-review-v1.18.md @@ -140,3 +140,5 @@ issue or fix. | 8 | Role fallback radio defaults (915 / 250 / SF10) differ from the root `platformio.ini` (869.618 / 62.5 / SF8) | `examples/simple_repeater/MyMesh.cpp:7-13`, `platformio.ini` | | 9 | nRF52 firmware is built with `-Ofast` (`-ffast-math`), so float comparisons may assume no NaN; whether a range check rejects `nan` then depends on how it is written. `set af nan` is accepted on the boards. (Found during step 3, measured on a T-Echo) | `src/helpers/CommonRadioPrefs.cpp` | | 10 | The two flood route types disagree about a denied Region. `onRecvPacket()` checks `REGION_DENY_FLOOD` itself for an un-scoped flood, while a scoped flood goes through `RegionMap::findMatch()`, whose transport-code-0 shortcut returns the wildcard before the mask is applied. So denying flood on the wildcard stops an un-scoped flood but not a scoped one carrying no code. (Found during step 4a, pinned in test_server_base) | `src/helpers/ServerMesh.cpp`, `src/helpers/RegionMap.cpp:findMatch()` | +| 11 | `setRxBoostedGain()` is a virtual nobody calls: a default in `helpers/CommonCLI.h`, an override in the repeater and in the room server, and no caller anywhere. The only `radio.rxgain` handler reaches the radio driver directly, and the settings table lists the field as `CLI_NONE`, which the generic loop skips. Two dead overrides. (Found during the sensor alignment) | `src/helpers/CommonCLI.h`, `examples/simple_repeater/MyMesh.cpp`, `examples/simple_room_server/MyMesh.cpp` | +| 12 | A hop limit of zero is written to `prefs.json` as an explicit `0` and applied verbatim on load, so `set flood.max.unscoped 0` persists and the constructor default cannot protect against it. Since the limits are compared with `>=`, zero means "never forward", which is what upstream's own comment describes repeaters as running. Combined with issue 7 (no range checking on `/prefs.json`) this is how a node can boot into a state that silently forwards nothing. Measured in `test_sensor_role` | `src/helpers/ConfigSerializer.cpp`, `src/helpers/RoutingPolicy.h` | From 3ff46b17d513bf650ddc4fb9c86ae5d8578fe960 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:17:32 +0200 Subject: [PATCH 071/136] docs(refactoring): record that step 4f is ready to start The sensor alignment closed the RX boosted gain gap, which was 4f's precondition. Measured afterwards: the room server's and the sensor's begin() are now identical at 44 lines each, and the repeater differs only by its WITH_BRIDGE block and one debug print, so the boot sequence is a move with a single hook. allowPacketForward() is likewise identical in the room server and the sensor now, with the repeater adding a Region check and loop detection on top of the same two leading checks. Noted as a candidate for a later step rather than folded into this cut. 4f itself is deliberately not started here: the build matrix is running against the current tip, and beginning the boot path would make those numbers stale. --- docs/refactoring/0004-shared-server-base.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md index 11e037a37c1..811a5b5a63a 100644 --- a/docs/refactoring/0004-shared-server-base.md +++ b/docs/refactoring/0004-shared-server-base.md @@ -167,9 +167,15 @@ instead of three times. The host suite grew from 82 to 115 test cases. What is still shared between all three roles, and why it stayed: -- `begin()` is step 4f, which waits on the sensor's RX boosted gain gap being - closed first, so the three boot paths can become one. -- `allowPacketForward()` is part of the sensor alignment, not of a move. +- `begin()` is step 4f. Its precondition is met since the sensor alignment closed + the RX boosted gain gap: measured after that change, the room server's and the + sensor's `begin()` are now **identical**, 44 lines each, and the repeater differs + only by its `WITH_BRIDGE` block and one debug print. So 4f is a move with one + hook, and the measurement is in place for whoever picks it up. +- `allowPacketForward()` is now **identical** in the room server and the sensor, 8 + lines each, after the alignment. The repeater adds a Region check and loop + detection on top of the same two leading checks. A later step could share the + common part behind a hook; it was not part of this cut. - `createSelfAdvert()` is a role hook by design now: only the advert type differs. - `handleCommand()`, `handleRequest()`, `onAnonDataRecv()`, `onPeerDataRecv()` and `loop()` differ by role and are out of scope, as stated above. From d070993c9bb1147e30f63942a0e60cd1e03574d4 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 00:18:46 +0200 Subject: [PATCH 072/136] docs(server): bring the base class comment up to date After five steps the class comment still described only the first one, and the note on the public section claimed main.cpp needs all of them. - List what the base actually holds now, and what a role keeps. - State the access reason accurately: only sendSelfAdvertisement() is called from a role's main.cpp (checked: the setTxPower calls in examples/ are on radio_driver in kiss_modem and simple_secure_chat, which are not server roles). The rest were public on the roles and stay public so nothing outside changes. - Include helpers/ClientACL.h explicitly instead of relying on CommonCLI.h to pull it in, since the class names the type itself. --- src/helpers/ServerMesh.h | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h index ad86c070ffd..7d3bd0c2e13 100644 --- a/src/helpers/ServerMesh.h +++ b/src/helpers/ServerMesh.h @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -12,10 +13,18 @@ * and the sensor. * * The three roles grew as separate sketches and ended up carrying the same code - * three times. What lives here is the part that was byte for byte identical in all - * three: resolving the Region a packet arrived on, choosing the scope of a flooded - * reply, and loading, saving and applying the Region table. A role keeps whatever is - * genuinely its own. + * three times. What lives here was identical in all three, or identical apart from + * formatting: + * + * - the Region a packet arrived on, and the scope a flooded reply goes out with + * - loading, saving and applying the Region table + * - the advertisement schedule, with the advert itself left to the role + * - the temporary radio parameters a 'set radio ...' command installs + * - the identity file, the file system erase, the TX power and the stats replies + * - the client ACL and the peer lookup a packet's sender hash goes through + * + * A role keeps whatever is genuinely its own: what it announces, how it answers a + * request, and in the repeater's case its bridge and loop detection. * * The members are protected rather than private because the role code reaches them * directly, as it did when they were its own fields. @@ -102,9 +111,10 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { void onDefaultRegionChanged(const RegionEntry* r) override; public: - // Public because each role's main.cpp advertises on its own after boot, and - // because CommonCLI reaches them through its CommonCLICallbacks pointer. They - // were public in all three roles before they moved here. + // These were public on all three roles and stay public, so nothing outside the + // roles changes. Only sendSelfAdvertisement() needs it: each role's main.cpp + // advertises once after boot. The rest are reached through the + // CommonCLICallbacks pointer, where public access comes from that interface. void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; void setTxPower(int8_t power_dbm) override; bool formatFileSystem() override; From e300f406edb7d4747d34c73b8b389494b3acf7be Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 01:18:01 +0200 Subject: [PATCH 073/136] docs(refactoring): the build matrix for steps 4a to 4e All 18 environments built in two worktrees on the exact commits, same toolchain, sequentially: 36 of 36 builds succeeded, none failed. Measured against the series base 98ffa5c1, so the numbers isolate this series; the state measured also carried both parts of the sensor alignment. What the numbers say, with code separated from layout rather than reported as one figure: - The three server roles gained about 300 to 370 bytes of text each, which is what the refactoring costs: calls that went straight into the role now go through the base, some of them virtually. - The companion gained no code. Its .flash.text is byte for byte identical, which is right, since a companion build compiles examples/companion_radio/ and nothing it compiles changed. Its .flash.rodata still moved 616 bytes, more than any role's did, and the only semantic difference between the two binaries is the build timestamp: that is linker layout from one more object file in the link. ServerMesh is absent from the companion image, checked with nm (37 symbols in the repeater, 0 in the companion), and two of the four companion environments report no change at all. - So the flash column of the raw table is an upper bound, not a measure of added code. RAM is +32 on each of the four sensor environments and unchanged elsewhere but one. The sensor figure is exact: step 4e replaced its 24 entry peer index array with one sized MAX_CLIENTS, which is 32, so eight more ints. That is the cost of the sensor no longer truncating its peer search at 24 matches. RAK_3112_repeater_bridge_espnow reports -8 bytes of RAM. Not explained, most likely padding after the fields moved into the base. Recorded as unexplained rather than claimed. --- docs/refactoring/0004-shared-server-base.md | 66 +++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md index 811a5b5a63a..5d02b960154 100644 --- a/docs/refactoring/0004-shared-server-base.md +++ b/docs/refactoring/0004-shared-server-base.md @@ -182,6 +182,72 @@ What is still shared between all three roles, and why it stayed: - `onPeerPathRecv()` is 90 percent shared between the repeater and the room server but only about 30 percent with the sensor, so it was never part of this cut. +## The build matrix + +Run on 20 October, all 18 environments in two worktrees on the exact commits, same +toolchain, sequentially. **36 of 36 builds succeeded, none failed.** Measured against +the series base `98ffa5c1`, so the numbers isolate steps 4a to 4e; the state measured +also carried both parts of the sensor alignment, which is the content of +`refactor/sensor-flood-limits`. + +| environment | RAM | delta | Flash | delta | +|---|---|---|---|---| +| `Heltec_v3_repeater` | 62512 | +0 | 1173301 | +552 | +| `Heltec_v3_room_server` | 66200 | +0 | 1168625 | +528 | +| `Heltec_v3_companion_radio_ble` | 194844 | +0 | 1665389 | +616 | +| `Heltec_v3_companion_radio_wifi` | 172384 | +0 | 1101561 | +616 | +| `Heltec_v3_sensor` | 60568 | +32 | 1179737 | +664 | +| `RAK_4631_repeater` | 37240 | +0 | 440520 | +336 | +| `RAK_4631_room_server` | 40928 | +0 | 433704 | +336 | +| `RAK_4631_companion_radio_ble` | 149768 | +0 | 462196 | +0 | +| `RAK_4631_sensor` | 35296 | +32 | 437416 | +256 | +| `PicoW_repeater` | 85368 | +0 | 491244 | +912 | +| `wio-e5-mini_repeater` | 17172 | +0 | 202944 | +664 | +| `wio-e5-mini_sensor` | 15244 | +32 | 202424 | +688 | +| `RAK_3112_repeater_bridge_espnow` | 64080 | -8 | 1174609 | +516 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | 26704 | +0 | 239648 | +920 | +| `ProMicro_repeater` | 33892 | +0 | 345008 | +304 | +| `ProMicro_room_server` | 37588 | +0 | 338072 | +352 | +| `ProMicro_companion_radio_usb` | 99468 | +0 | 356964 | +0 | +| `ProMicro_sensor` | 31956 | +32 | 341772 | +240 | + +### What those numbers mean, and what they do not + +The flash column mixes two things, so it is an upper bound rather than a measure of +added code. Separating them with the section sizes, on the Heltec v3 family: + +| environment | `.flash.text` | `.flash.rodata` | `.dram0.bss` | +|---|---|---|---| +| `Heltec_v3_repeater` | +316 | +236 | 0 | +| `Heltec_v3_room_server` | +292 | +236 | 0 | +| `Heltec_v3_sensor` | +372 | +292 | +32 | +| `Heltec_v3_companion_radio_ble` | **0** | +616 | 0 | + +- **The three server roles gained real code**, about 300 to 370 bytes of text each. + That is the price of the refactoring: calls that went straight into the role now + go through the base, and some of them virtually. +- **The companion gained no code at all.** Its `.flash.text` is byte for byte + identical, which is what it should be: a companion build compiles + `examples/companion_radio/`, and nothing it compiles changed. Its `.flash.rodata` + still moved by 616 bytes, more than any role's did, and the only semantic + difference between the two binaries is the build timestamp. That is linker layout, + caused by one more object file in the link; `ServerMesh` itself is absent from the + companion image (checked with `nm`: 37 symbols in the repeater, 0 in the + companion). Two of the four companion environments report +0 flash, which fits. +- So of the flash deltas in the first table, roughly 240 to 370 bytes per server role + is code and the rest is layout, and for every non-role environment all of it is + layout. + +### The RAM figures + +`+32` on each of the four sensor environments, `+0` everywhere else but one. The +sensor's is exact and expected: step 4e replaced its 24 entry peer index array with +one sized `MAX_CLIENTS`, which is 32, so 8 more ints at 4 bytes each. That is the +cost of the sensor no longer truncating its peer search at 24 matches. + +`RAK_3112_repeater_bridge_espnow` reports **-8** bytes of RAM. Not explained; +most likely padding after the fields moved into the base. Noted rather than claimed. + ## Verification of the steps Marcel's decision of 19 October: the 18 environment build matrix and the flash and From 0a93bc403e7f3922a703237f861944a8bfbe825d Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 01:21:38 +0200 Subject: [PATCH 074/136] docs(refactoring): tie the matrix numbers to the current branch tips The matrix built e37ef13e, and the tips carry one further source change it did not build: an explicit include of helpers/ClientACL.h in ServerMesh.h, plus comment rewrites. Instead of assuming that is size neutral, Heltec_v3_repeater was rebuilt on the current tip and its sections compared with the recorded ones: .flash.text 802931, .flash.rodata 274096, .dram0.bss 39064, identical in both. So the recorded numbers apply to the tips as they stand. --- docs/refactoring/0004-shared-server-base.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md index 5d02b960154..51658331b50 100644 --- a/docs/refactoring/0004-shared-server-base.md +++ b/docs/refactoring/0004-shared-server-base.md @@ -238,6 +238,15 @@ added code. Separating them with the section sizes, on the Heltec v3 family: is code and the rest is layout, and for every non-role environment all of it is layout. +### Which commit the numbers belong to + +The matrix built `e37ef13e`. The branch tips carry one further source change that it +did not build: an explicit `#include ` in `ServerMesh.h`, plus +comment rewrites. Rather than assume that is size neutral, `Heltec_v3_repeater` was +rebuilt on the current tip and its sections compared with the recorded ones: +`.flash.text` 802931, `.flash.rodata` 274096, `.dram0.bss` 39064, byte for byte the +same in both. So the numbers below apply to the tips as they stand. + ### The RAM figures `+32` on each of the four sensor environments, `+0` everywhere else but one. The From 83bce79bc61884fcc91be2952e04880d1c2905ec Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 13:02:00 +0200 Subject: [PATCH 075/136] test(server): pin the boot sequence of all three roles before it moves begin() is the last body the repeater, the room server and the sensor carry in triplicate. Before step 4f moves it into ServerMesh, this records what it does and in which order, on the old code, so the move can be shown to land exactly where it was meant to. One set of expectations (test/mocks/BootSequenceChecks.h) is included by the suite of each role, so the three are held to the same behaviour: - Mesh::begin() runs first, and so sizes the duty cycle budget from the constructor's airtime factor, not the stored one (recorded as observed, not endorsed; it is why the first two lines cannot be reordered) - prefs, then the ACL, then the Regions are loaded, all before the radio is touched; then the radio parameters, TX power and boosted gain, then the board's dynamic prefs and ADC multiplier, each exactly once - stored radio settings, boosted gain, ADC multiplier and advert intervals reach the radio, the board and the timers; the stored ACL and Regions load - a stored default Region becomes the default scope; without one there is none (or, with DEFAULT_FLOOD_SCOPE_NAME, the named Region is created with flood allowed, reused with its flags when stored, and never written to flash by booting) - booting an empty file system writes nothing - with ENV_INCLUDE_GPS, the stored GPS setting is the last thing pushed - repeater only: the bridge starts after the loading and before the radio is configured, and not at all when disabled; the debug build logs the gain mode it applied, after applying it To make the order observable the mocks keep one ordered event log (test/mocks/EventLog.h): file system lookups, radio, board and sensor manager calls append a tag. The sensor manager mock now records the settings pushed to it, and a stand-in RS232 bridge records its start. The repeater had no host environment because it shares the name MyMesh with the room server. native_repeater adds it, and native_repeater_full runs the same suite with the bridge, GPS, a configured default scope and the debug log compiled in. Both are added to the CI test run and to the generic native env's ignore list. Evidence, on the unchanged begin() bodies: native_server, native_sensor, native_repeater and native_repeater_full green (20, 20, 20 and 28 boot cases); all eight native environments 211 of 211. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 60 ++ test/mocks/BootSequenceChecks.h | 524 ++++++++++++++++++ test/mocks/EventLog.h | 31 ++ test/mocks/FS.h | 7 +- .../bridge/helpers/bridges/RS232Bridge.h | 33 ++ test/mocks/target.h | 36 +- test/test_repeater_role/identity_stubs.cpp | 98 ++++ .../test_repeater_role/test_boot_sequence.cpp | 13 + .../test_repeater_role/test_repeater_role.cpp | 12 + test/test_sensor_role/test_boot_sequence.cpp | 24 + test/test_server_base/test_boot_sequence.cpp | 9 + 12 files changed, 841 insertions(+), 8 deletions(-) create mode 100644 test/mocks/BootSequenceChecks.h create mode 100644 test/mocks/EventLog.h create mode 100644 test/mocks/bridge/helpers/bridges/RS232Bridge.h create mode 100644 test/test_repeater_role/identity_stubs.cpp create mode 100644 test/test_repeater_role/test_boot_sequence.cpp create mode 100644 test/test_repeater_role/test_repeater_role.cpp create mode 100644 test/test_sensor_role/test_boot_sequence.cpp create mode 100644 test/test_server_base/test_boot_sequence.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 369c0ec84a9..ad57e6c6f6b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -e native_sensor -vv + run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -e native_sensor -e native_repeater -e native_repeater_full -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index 32320098a93..648cd671483 100644 --- a/platformio.ini +++ b/platformio.ini @@ -172,6 +172,7 @@ test_ignore = test_core_tuning test_server_base test_sensor_role + test_repeater_role build_src_filter = -<*> +<../src/Utils.cpp> @@ -270,6 +271,65 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_repeater] +; The repeater on the host, in its own environment: the repeater and the room server +; both define `MyMesh`, so each needs a binary of its own. Built the way most +; firmware is, with none of the optional blocks that begin() contains. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I src + -I test/mocks + -I examples/simple_repeater +test_build_src = yes +test_filter = test_repeater_role +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/ClientACL.cpp> + +<../src/helpers/CommonCLI.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/IdentityStore.cpp> + +<../src/helpers/RegionMap.cpp> + +<../src/helpers/ServerMesh.cpp> + +<../src/helpers/StaticPoolPacketManager.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> + +<../examples/simple_repeater/MyMesh.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_repeater_full] +; The same repeater with every optional block of begin() compiled in: a bridge (the +; stand-in in test/mocks/bridge, which has to come ahead of src on the include +; path), the GPS toggle, a configured default flood scope, and the debug log (which +; the Arduino core only compiles in when ARDUINO is set). Runs the same suite. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I test/mocks/bridge + -I src + -I test/mocks + -I examples/simple_repeater + -D WITH_RS232_BRIDGE=0 + -D ENV_INCLUDE_GPS=1 + -D DEFAULT_FLOOD_SCOPE_NAME='"scope-test"' + -D MESH_DEBUG=1 + -D ARDUINO=1 +test_build_src = yes +test_filter = test_repeater_role +build_src_filter = ${env:native_repeater.build_src_filter} +lib_deps = + google/googletest @ 1.17.0 + [env:native_core] platform = native test_framework = googletest diff --git a/test/mocks/BootSequenceChecks.h b/test/mocks/BootSequenceChecks.h new file mode 100644 index 00000000000..738b47f12b4 --- /dev/null +++ b/test/mocks/BootSequenceChecks.h @@ -0,0 +1,524 @@ +#pragma once + +// What a server role's begin() does, in the order it does it. Included by the suite +// of each role (repeater, room server, sensor) after it has named the concrete role +// class as `BootRole`, so the same expectations run against all three. That is the +// point of the boot sequence step in docs/refactoring/0007-boot-sequence.md: the +// three begin() bodies were meant to be one body, and one set of checks says whether +// they still are. +// +// Everything is observed from outside: the file system lookups, the calls on the +// radio, the board and the sensor manager (all recorded in g_events, see EventLog.h), +// and the role's state as the base class holds it. A test builds a node, stores some +// state, then "reboots" by building a second node on the same file system, which is +// what the firmware does between runs. +// +// Compile-time options the sequence depends on are covered where they are compiled +// in: DEFAULT_FLOOD_SCOPE_NAME, ENV_INCLUDE_GPS and, for the repeater, WITH_BRIDGE +// and MESH_DEBUG (the repeater's one log line). The default environments leave them +// out, as most firmware builds do; the "full" repeater environment turns all of them +// on. A suite defines BOOT_ROLE_LOGS_RX_GAIN before including this file if its role +// logs the gain mode it applied. + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace boot_checks { + +constexpr uint32_t T0 = 1000; + +// The role with the shared base's state opened up. Nothing else in the firmware +// reports any of it. +class BootProbe : public BootRole { +public: + using BootRole::BootRole; + + FILESYSTEM* fileSystem() const { return _fs; } + ClientACL& clients() { return acl; } + RegionMap& regions() { return region_map; } + NodePrefs& prefs() { return _prefs; } + const TransportKey& defaultScope() const { return default_scope; } + unsigned long nextLocalAdvert() const { return next_local_advert; } + unsigned long nextFloodAdvert() const { return next_flood_advert; } + const mesh::LocalIdentity& identity() const { return self_id; } + + // Dispatcher::begin() sizes the duty cycle budget from this, so the value it is + // asked for first is the value the node booted its dispatcher with. + float getAirtimeBudgetFactor() const override { + float f = BootRole::getAirtimeBudgetFactor(); + if (!asked) { asked = true; first_airtime_factor = f; } + return f; + } + mutable bool asked = false; + mutable float first_airtime_factor = -1.0f; +}; + +class BootSequenceTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + + void SetUp() override { + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + g_events.clear(); + } + + std::unique_ptr build() { + return std::unique_ptr(new BootProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + } + + // Boot a node on the shared file system, let `store` change what it holds, then + // write all of it to flash the way the CLI and the role do at run time. + template + void storeState(Fn store) { + auto node = build(); + node->begin(&fs); + store(*node); + node->savePrefs(); + node->clients().save(&fs); + node->regions().save(&fs); + } + + // Boot the node under test and keep only the events begin() itself produced. + std::unique_ptr bootFresh() { + auto node = build(); + g_events.clear(); + node->begin(&fs); + return node; + } + + mesh::Identity identityStarting(uint8_t first, uint8_t tag) { + uint8_t pub[PUB_KEY_SIZE]; + memset(pub, tag, sizeof(pub)); + pub[0] = first; + return mesh::Identity(pub); + } + + // The first time the named file was looked at. + static int firstLookupOf(const char* file) { + return eventIndex(std::string("exists:") + file); + } +}; + +// ---- the order ------------------------------------------------------------- + +TEST_F(BootSequenceTest, TheDispatcherStartsBeforeAnythingIsLoaded) { + bootFresh(); + + ASSERT_EQ(eventIndex("radio.begin"), 0) << "Mesh::begin() is the first thing begin() does"; + EXPECT_EQ(radio_driver.begin_calls, 1); +} + +TEST_F(BootSequenceTest, TheDispatcherSizesItsBudgetFromTheConstructorDefaultsNotTheStoredPrefs) { + // Recorded as observed behaviour, not endorsed: Mesh::begin() runs before the + // stored prefs are loaded, so the duty cycle budget is computed from the airtime + // factor the constructor set and the stored one only counts from then on. It is the + // reason the order of the first two lines of begin() cannot be changed in a move. + storeState([](BootProbe& n) { n.prefs().airtime_factor = 3.0f; }); + + auto node = bootFresh(); + + ASSERT_TRUE(node->asked); + EXPECT_FLOAT_EQ(node->first_airtime_factor, 1.0f) << "the constructor default"; + EXPECT_FLOAT_EQ(node->prefs().airtime_factor, 3.0f) << "the stored value, loaded afterwards"; +} + +TEST_F(BootSequenceTest, PrefsAreLoadedBeforeTheAclAndTheAclBeforeTheRegions) { + storeState([&](BootProbe& n) { + n.clients().putClient(identityStarting(0xAA, 1), 1); + n.regions().putRegion("#alpha", 0); + }); + + bootFresh(); + + const int prefs_at = firstLookupOf("/prefs.json"); + const int acl_at = firstLookupOf("/s_contacts"); + const int regions_at = firstLookupOf("/regions2"); + ASSERT_GE(prefs_at, 0); + ASSERT_GE(acl_at, 0); + ASSERT_GE(regions_at, 0); + EXPECT_LT(prefs_at, acl_at); + EXPECT_LT(acl_at, regions_at); + EXPECT_LT(eventIndex("radio.begin"), prefs_at); +} + +TEST_F(BootSequenceTest, TheRadioIsConfiguredOnlyAfterEverythingIsLoaded) { + bootFresh(); + + const int regions_at = firstLookupOf("/regions2"); + const int params_at = eventIndex("radio.setParams"); + ASSERT_GE(regions_at, 0); + ASSERT_GE(params_at, 0); + EXPECT_LT(regions_at, params_at); +} + +TEST_F(BootSequenceTest, TheRadioIsSetUpInTheOrderParamsPowerGainThenTheBoard) { + bootFresh(); + + const int params = eventIndex("radio.setParams"); + const int power = eventIndex("radio.setTxPower"); + const int gain = eventIndex("radio.setRxBoostedGainMode"); + const int attach = eventIndex("board.attachDynamicPrefs"); + const int adc = eventIndex("board.setAdcMultiplier"); + ASSERT_GE(params, 0); + ASSERT_GE(power, 0); + ASSERT_GE(gain, 0); + ASSERT_GE(attach, 0); + ASSERT_GE(adc, 0); + EXPECT_LT(params, power); + EXPECT_LT(power, gain); + EXPECT_LT(gain, attach); + EXPECT_LT(attach, adc); +} + +TEST_F(BootSequenceTest, EachStepOfTheSetUpHappensExactlyOnce) { + bootFresh(); + + EXPECT_EQ(eventCount("radio.begin"), 1); + EXPECT_EQ(eventCount("radio.setParams"), 1); + EXPECT_EQ(eventCount("radio.setTxPower"), 1); + EXPECT_EQ(eventCount("radio.setRxBoostedGainMode"), 1); + EXPECT_EQ(eventCount("board.attachDynamicPrefs"), 1); + EXPECT_EQ(eventCount("board.setAdcMultiplier"), 1); +} + +// ---- what is loaded and what it is used for -------------------------------- + +TEST_F(BootSequenceTest, TheFileSystemIsTheOneBeginWasGiven) { + auto node = bootFresh(); + + EXPECT_EQ(node->fileSystem(), &fs); +} + +TEST_F(BootSequenceTest, TheRoleCanSaveItsPrefsRightAfterBegin) { + // Saving goes through the file system pointer begin() stored, so this fails if + // that assignment is lost or comes too late. + auto node = bootFresh(); + ASSERT_FALSE(fs.exists("/prefs.json")); + + node->savePrefs(); + + EXPECT_TRUE(fs.exists("/prefs.json")); +} + +TEST_F(BootSequenceTest, BeginWritesNothingOnAnEmptyFileSystem) { + bootFresh(); + + EXPECT_TRUE(fs.files.empty()) << "booting is read only, nothing is created"; +} + +TEST_F(BootSequenceTest, StoredRadioSettingsReachTheRadio) { + storeState([](BootProbe& n) { + n.prefs().freq = 869.525f; + n.prefs().bw = 250.0f; + n.prefs().sf = 9; + n.prefs().cr = 6; + n.prefs().tx_power_dbm = 11; + n.prefs().rx_boosted_gain = 0; // the opposite of the default + }); + radio_driver.reset(); + + bootFresh(); + + EXPECT_FLOAT_EQ(radio_driver.freq, 869.525f); + EXPECT_FLOAT_EQ(radio_driver.bw, 250.0f); + EXPECT_EQ(radio_driver.sf, 9); + EXPECT_EQ(radio_driver.cr, 6); + EXPECT_EQ(radio_driver.tx_power, 11); + EXPECT_FALSE(radio_driver.rx_boosted); +} + +TEST_F(BootSequenceTest, StoredBoostedGainIsAppliedWhenItIsOn) { + storeState([](BootProbe& n) { n.prefs().rx_boosted_gain = 1; }); + radio_driver.reset(); + + bootFresh(); + + EXPECT_TRUE(radio_driver.rx_boosted); + EXPECT_EQ(radio_driver.rx_boost_calls, 1); +} + +TEST_F(BootSequenceTest, TheStoredAdcMultiplierReachesTheBoard) { + storeState([](BootProbe& n) { n.prefs().adc_multiplier = 1.5f; }); + board.reset(); + + bootFresh(); + + EXPECT_FLOAT_EQ(board.adc_multiplier, 1.5f); +} + +TEST_F(BootSequenceTest, AdvertTimersCountFromTheStoredIntervals) { + storeState([](BootProbe& n) { + n.prefs().advert_interval = 7; // units of two minutes + n.prefs().flood_advert_interval = 5; // hours + }); + + auto node = bootFresh(); + + EXPECT_EQ(node->nextLocalAdvert(), T0 + 7UL * 2 * 60 * 1000); + EXPECT_EQ(node->nextFloodAdvert(), T0 + 5UL * 60 * 60 * 1000); +} + +TEST_F(BootSequenceTest, AdvertTimersOfAFreshNodeFollowWhateverTheConstructorSet) { + // The roles do not share their advert defaults (the sensor starts without a flood + // advert), so this does not name them: it checks that begin() schedules from the + // prefs the node actually holds, which on a fresh file system are the constructor's. + auto node = bootFresh(); + + const NodePrefs& p = node->prefs(); + const unsigned long local = + p.advert_interval > 0 ? T0 + (unsigned long)p.advert_interval * 2 * 60 * 1000 : 0; + const unsigned long flood = + p.flood_advert_interval > 0 ? T0 + (unsigned long)p.flood_advert_interval * 60 * 60 * 1000 : 0; + EXPECT_EQ(node->nextLocalAdvert(), local); + EXPECT_EQ(node->nextFloodAdvert(), flood); +} + +TEST_F(BootSequenceTest, ZeroIntervalsLeaveBothTimersOff) { + storeState([](BootProbe& n) { + n.prefs().advert_interval = 0; + n.prefs().flood_advert_interval = 0; + }); + + auto node = bootFresh(); + + EXPECT_EQ(node->nextLocalAdvert(), 0UL); + EXPECT_EQ(node->nextFloodAdvert(), 0UL); +} + +TEST_F(BootSequenceTest, TheStoredAclIsLoaded) { + storeState([&](BootProbe& n) { + ClientInfo* c = n.clients().putClient(identityStarting(0xAA, 1), 3); + ASSERT_NE(c, nullptr); + memset(c->shared_secret, 7, PUB_KEY_SIZE); + }); + + auto node = bootFresh(); + + ASSERT_EQ(node->clients().getNumClients(), 1); + const ClientInfo* c = node->clients().getClientByIdx(0); + EXPECT_EQ(c->id.pub_key[0], 0xAA); + EXPECT_EQ(c->permissions, 3); + + // The secret is recalculated from this node's own identity, not read back. + uint8_t expected[PUB_KEY_SIZE]; + node->identity().calcSharedSecret(expected, c->id.pub_key); + EXPECT_EQ(memcmp(c->shared_secret, expected, PUB_KEY_SIZE), 0); +} + +TEST_F(BootSequenceTest, TheStoredRegionsAreLoaded) { + storeState([](BootProbe& n) { + n.regions().putRegion("#alpha", 0); + n.regions().putRegion("#beta", 0); + }); + + auto node = bootFresh(); + + EXPECT_NE(node->regions().findByName("#alpha"), nullptr); + EXPECT_NE(node->regions().findByName("#beta"), nullptr); +} + +// ---- the default scope ----------------------------------------------------- + +TEST_F(BootSequenceTest, AStoredDefaultRegionBecomesTheDefaultScope) { + storeState([](BootProbe& n) { + RegionEntry* r = n.regions().putRegion("#probe", 0); + ASSERT_NE(r, nullptr); + n.regions().setDefaultRegion(r); + }); + + auto node = bootFresh(); + + RegionEntry* r = node->regions().findByName("#probe"); + ASSERT_NE(r, nullptr); + ASSERT_EQ(node->regions().getDefaultRegion(), r); + TransportKey expected; + ASSERT_EQ(node->regions().getTransportKeysFor(*r, &expected, 1), 1); + EXPECT_FALSE(node->defaultScope().isNull()); + EXPECT_EQ(memcmp(node->defaultScope().key, expected.key, sizeof(expected.key)), 0); +} + +#ifndef DEFAULT_FLOOD_SCOPE_NAME + +TEST_F(BootSequenceTest, WithoutAStoredDefaultRegionThereIsNoDefaultScope) { + auto node = bootFresh(); + + EXPECT_TRUE(node->defaultScope().isNull()); + EXPECT_EQ(node->regions().getDefaultRegion(), nullptr); + EXPECT_EQ(node->regions().getCount(), 0); +} + +#else // a build that names a default scope + +TEST_F(BootSequenceTest, TheConfiguredDefaultScopeIsCreatedWhenNoneIsStored) { + auto node = bootFresh(); + + RegionEntry* r = node->regions().findByName(DEFAULT_FLOOD_SCOPE_NAME); + ASSERT_NE(r, nullptr) << "created on first boot"; + EXPECT_EQ(r->flags, 0) << "allow flood"; + EXPECT_EQ(r->parent, 0); + EXPECT_EQ(node->regions().getDefaultRegion(), r); + + TransportKey expected; + ASSERT_EQ(node->regions().getTransportKeysFor(*r, &expected, 1), 1); + EXPECT_FALSE(node->defaultScope().isNull()); + EXPECT_EQ(memcmp(node->defaultScope().key, expected.key, sizeof(expected.key)), 0); + EXPECT_EQ(node->regions().getCount(), 1); +} + +TEST_F(BootSequenceTest, TheConfiguredDefaultScopeIsNotWrittenToFlashByBooting) { + bootFresh(); + + EXPECT_FALSE(fs.exists("/regions2")); +} + +TEST_F(BootSequenceTest, AStoredDefaultRegionWinsOverTheConfiguredName) { + storeState([](BootProbe& n) { + // The first boot on an empty file system already created the configured Region, + // so take it out again: what is stored is #probe, and #probe is the default. + RegionEntry* configured = n.regions().findByName(DEFAULT_FLOOD_SCOPE_NAME); + ASSERT_NE(configured, nullptr); + ASSERT_TRUE(n.regions().removeRegion(*configured)); + RegionEntry* r = n.regions().putRegion("#probe", 0); + ASSERT_NE(r, nullptr); + n.regions().setDefaultRegion(r); + }); + + auto node = bootFresh(); + + EXPECT_EQ(node->regions().findByName(DEFAULT_FLOOD_SCOPE_NAME), nullptr) << "not created"; + EXPECT_EQ(node->regions().getDefaultRegion(), node->regions().findByName("#probe")); + EXPECT_EQ(node->regions().getCount(), 1); +} + +TEST_F(BootSequenceTest, AStoredRegionOfTheConfiguredNameIsReusedWithItsFlags) { + storeState([](BootProbe& n) { + // Stored as a Region that denies flood and is not the default. + RegionEntry* r = n.regions().findByName(DEFAULT_FLOOD_SCOPE_NAME); + ASSERT_NE(r, nullptr); + r->flags = REGION_DENY_FLOOD; + n.regions().setDefaultRegion(nullptr); + }); + + auto node = bootFresh(); + + RegionEntry* r = node->regions().findByName(DEFAULT_FLOOD_SCOPE_NAME); + ASSERT_NE(r, nullptr); + EXPECT_EQ(node->regions().getCount(), 1) << "reused, not duplicated"; + EXPECT_EQ(node->regions().getDefaultRegion(), r) << "and made the default again"; + EXPECT_TRUE(r->flags & REGION_DENY_FLOOD) << "only a region created at boot is opened up"; +} + +#endif // DEFAULT_FLOOD_SCOPE_NAME + +// ---- optional blocks ------------------------------------------------------- + +#if ENV_INCLUDE_GPS == 1 + +TEST_F(BootSequenceTest, TheStoredGpsSettingIsPushedToTheSensorManagerLast) { + storeState([](BootProbe& n) { n.prefs().gps_enabled = 1; }); + + bootFresh(); + + ASSERT_EQ(eventCount("sensors.setSettingValue gps=1"), 1); + EXPECT_EQ(eventCount("sensors.setSettingValue gps=0"), 0); + EXPECT_EQ(eventIndex("sensors.setSettingValue gps=1"), (int)g_events.size() - 1) + << "the last thing begin() does"; + EXPECT_GT(eventIndex("sensors.setSettingValue gps=1"), eventIndex("board.setAdcMultiplier")); +} + +TEST_F(BootSequenceTest, AStoredGpsOffIsPushedAsOff) { + storeState([](BootProbe& n) { n.prefs().gps_enabled = 0; }); + + bootFresh(); + + EXPECT_EQ(eventCount("sensors.setSettingValue gps=0"), 1); + EXPECT_EQ(eventCount("sensors.setSettingValue gps=1"), 0); +} + +#else + +TEST_F(BootSequenceTest, WithoutGpsSupportNothingIsPushedToTheSensorManager) { + bootFresh(); + + for (const std::string& e : g_events) { + EXPECT_NE(e.rfind("sensors.", 0), 0u) << e; + } +} + +#endif // ENV_INCLUDE_GPS + +#if defined(BOOT_ROLE_LOGS_RX_GAIN) && MESH_DEBUG + +// Only the repeater says what it did to the radio, and only in a debug build. The +// line reports the mode the radio is in at that moment, so it also shows that the +// message comes after the gain was applied and not before. +TEST_F(BootSequenceTest, TheRepeaterLogsTheGainModeAfterApplyingIt) { + storeState([](BootProbe& n) { n.prefs().rx_boosted_gain = 1; }); + radio_driver.reset(); // a radio that starts with the boost off + Serial.reset(); + + bootFresh(); + + EXPECT_EQ(Serial.out.find("DEBUG: RX Boosted Gain Mode: Enabled\n"), 0u) + << "the first thing the debug build prints, and it reports the applied mode"; + size_t lines = 0; + for (size_t at = Serial.out.find("RX Boosted Gain Mode"); at != std::string::npos; + at = Serial.out.find("RX Boosted Gain Mode", at + 1)) { + lines++; + } + EXPECT_EQ(lines, 1u) << "and only once"; +} + +TEST_F(BootSequenceTest, TheRepeaterLogsADisabledGainModeAsDisabled) { + storeState([](BootProbe& n) { n.prefs().rx_boosted_gain = 0; }); + radio_driver.reset(); + Serial.reset(); + + bootFresh(); + + EXPECT_NE(Serial.out.find("DEBUG: RX Boosted Gain Mode: Disabled\n"), std::string::npos); +} + +#endif // BOOT_ROLE_LOGS_RX_GAIN + +#ifdef WITH_BRIDGE + +TEST_F(BootSequenceTest, TheBridgeStartsAfterTheLoadingAndBeforeTheRadioIsConfigured) { + bootFresh(); + + const int started = eventIndex("bridge.begin"); + ASSERT_GE(started, 0); + EXPECT_EQ(eventCount("bridge.begin"), 1); + EXPECT_GT(started, firstLookupOf("/regions2")); + EXPECT_LT(started, eventIndex("radio.setParams")); +} + +TEST_F(BootSequenceTest, ADisabledBridgeIsNotStarted) { + storeState([](BootProbe& n) { n.prefs().bridge_enabled = 0; }); + + bootFresh(); + + EXPECT_EQ(eventCount("bridge.begin"), 0); + EXPECT_EQ(eventCount("radio.setParams"), 1) << "the rest of the sequence still runs"; +} + +#endif // WITH_BRIDGE + +} // namespace boot_checks diff --git a/test/mocks/EventLog.h b/test/mocks/EventLog.h new file mode 100644 index 00000000000..de023f81182 --- /dev/null +++ b/test/mocks/EventLog.h @@ -0,0 +1,31 @@ +#pragma once + +// One ordered record of the calls a role makes on its collaborators while it boots: +// the file system lookups, the radio and board set-up, the sensor manager and (in the +// repeater with a bridge) the bridge. Each mock appends a short tag when it is +// called, so a test can say which call came before which, which no single mock can +// tell on its own. A test clears the log before the step it wants to watch. + +#include +#include + +inline std::vector g_events; + +inline void logEvent(const std::string& what) { g_events.push_back(what); } + +// Position of the first event that equals `what`, or -1 if it never happened. +inline int eventIndex(const std::string& what) { + for (size_t i = 0; i < g_events.size(); i++) { + if (g_events[i] == what) return (int)i; + } + return -1; +} + +// How many times `what` was logged. +inline int eventCount(const std::string& what) { + int n = 0; + for (const std::string& e : g_events) { + if (e == what) n++; + } + return n; +} diff --git a/test/mocks/FS.h b/test/mocks/FS.h index 012eefa55eb..2f0b594c190 100644 --- a/test/mocks/FS.h +++ b/test/mocks/FS.h @@ -18,6 +18,7 @@ #include #include +#include "EventLog.h" #include "Stream.h" #ifndef FILE_O_WRITE @@ -73,6 +74,7 @@ class FS { File open(const char* name) { return open(name, "r"); } File open(const char* name, const char* mode, bool create = false) { + logEvent(std::string("open:") + name); std::string n(name); bool writing = (mode[0] == 'w' || mode[0] == 'a'); if (!writing) { @@ -95,7 +97,10 @@ class FS { // identity store, and a flat map needs no directory to hold "/dir/file". bool mkdir(const char* path) { return true; } - bool exists(const char* name) { return files.find(std::string(name)) != files.end(); } + bool exists(const char* name) { + logEvent(std::string("exists:") + name); + return files.find(std::string(name)) != files.end(); + } bool remove(const char* name) { return files.erase(std::string(name)) > 0; } bool format() { files.clear(); return true; } diff --git a/test/mocks/bridge/helpers/bridges/RS232Bridge.h b/test/mocks/bridge/helpers/bridges/RS232Bridge.h new file mode 100644 index 00000000000..238c8f2b463 --- /dev/null +++ b/test/mocks/bridge/helpers/bridges/RS232Bridge.h @@ -0,0 +1,33 @@ +#pragma once + +// Stand-in for the repeater's RS232 bridge, so the repeater builds on the host with +// WITH_RS232_BRIDGE set. It keeps the real bridge's interface and does nothing but +// remember that it was started, in the shared event log, so a test can see when the +// repeater's begin() starts the bridge relative to the rest of the boot. +// +// It sits in a directory of its own that the "full" repeater environment puts ahead +// of src on the include path, so `helpers/bridges/RS232Bridge.h` resolves here and +// nowhere else sees it. + +#include +#include +#include +#include + +#include + +class RS232Bridge : public AbstractBridge { + bool _running = false; + +public: + // WITH_RS232_BRIDGE names a serial port on a board; on the host it is just a + // number, and the stand-in does not use it. + RS232Bridge(NodePrefs* prefs, int serial, mesh::PacketManager* mgr, mesh::RTCClock* rtc) { } + + void begin() override { _running = true; logEvent("bridge.begin"); } + void end() override { _running = false; logEvent("bridge.end"); } + bool isRunning() const override { return _running; } + void loop() override { } + void sendPacket(mesh::Packet* packet) override { } + void onPacketReceived(mesh::Packet* packet) override { } +}; diff --git a/test/mocks/target.h b/test/mocks/target.h index 092321a9782..c9aa29212ee 100644 --- a/test/mocks/target.h +++ b/test/mocks/target.h @@ -4,6 +4,8 @@ #include +#include "EventLog.h" + // A variant's target.h brings in the platform file system; the native env gets the // in-memory stand-in, so FILESYSTEM and File are defined for the server helpers. #include "FS.h" @@ -26,12 +28,20 @@ struct MockRadioDriver : public mesh::Radio { float last_rssi = -90.0f, last_snr = 5.0f; uint32_t packets_recv = 0, packets_sent = 0, packets_recv_errors = 0; - void setTxPower(int8_t dbm) { tx_power = dbm; tx_power_calls++; } - bool setRxBoostedGainMode(bool en) { rx_boosted = en; rx_boost_calls++; return rx_boost_supported; } + int begin_calls = 0; + void begin() override { begin_calls++; logEvent("radio.begin"); } + + void setTxPower(int8_t dbm) { tx_power = dbm; tx_power_calls++; logEvent("radio.setTxPower"); } + bool setRxBoostedGainMode(bool en) { + rx_boosted = en; rx_boost_calls++; + logEvent("radio.setRxBoostedGainMode"); + return rx_boost_supported; + } bool getRxBoostedGainMode() const { return rx_boosted; } void setParams(float f, float b, uint8_t s, uint8_t c) { freq = f; bw = b; sf = s; cr = c; set_params_calls++; + logEvent("radio.setParams"); } float getLastRSSI() const { return last_rssi; } @@ -69,6 +79,7 @@ struct MockRadioDriver : public mesh::Radio { tx_power = 0; tx_power_calls = 0; rx_boosted = false; rx_boost_calls = 0; rx_boost_supported = true; freq = bw = 0; sf = cr = 0; set_params_calls = 0; + begin_calls = 0; send_succeeds = true; send_calls = 0; last_send_len = 0; last_rssi = -90.0f; last_snr = 5.0f; packets_recv = packets_sent = packets_recv_errors = 0; @@ -95,7 +106,11 @@ class MockBoard : public mesh::MainBoard { uint16_t getBattMilliVolts() override { return batt_milli_volts; } float getMCUTemperature() override { return mcu_temperature; } - bool setAdcMultiplier(float m) override { adc_multiplier = m; return true; } + bool setAdcMultiplier(float m) override { + adc_multiplier = m; + logEvent("board.setAdcMultiplier"); + return true; + } float getAdcMultiplier() const override { return adc_multiplier; } const char* getManufacturerName() const override { return "Mock"; } void reboot() override { reboot_calls++; } @@ -105,7 +120,7 @@ class MockBoard : public mesh::MainBoard { // Not on MainBoard: the concrete variant boards declare it, and the role code // calls it on the concrete type (see helpers/NRF52Board.h and its siblings). - void attachDynamicPrefs(KeyValueStore* prefs) { } + void attachDynamicPrefs(KeyValueStore* prefs) { logEvent("board.attachDynamicPrefs"); } void reset() { batt_milli_volts = 4100; @@ -126,5 +141,14 @@ class MockRTCClock : public mesh::RTCClock { inline MockBoard board; inline MockRTCClock rtc_clock; -// SensorManager is concrete, so the real class serves as the stand-in. -inline SensorManager sensors; +// SensorManager is concrete, so the real class serves as the stand-in; this one only +// adds a record of the settings the role pushes to it (the GPS toggle at boot). +class MockSensorManager : public SensorManager { +public: + bool setSettingValue(const char* name, const char* value) override { + logEvent(std::string("sensors.setSettingValue ") + name + "=" + value); + return true; + } +}; + +inline MockSensorManager sensors; diff --git a/test/test_repeater_role/identity_stubs.cpp b/test/test_repeater_role/identity_stubs.cpp new file mode 100644 index 00000000000..abfef43e210 --- /dev/null +++ b/test/test_repeater_role/identity_stubs.cpp @@ -0,0 +1,98 @@ +// Link-time stand-ins for the parts of mesh::Identity that the core and the server +// helpers reference. The real implementations need the board crypto libraries, +// which do not exist on the host. +// +// These are deterministic, NOT cryptographic. The roles sign every advertisement +// they schedule, so a stub that refused to run would turn ordinary operation into a +// test failure; instead each operation derives its output from its inputs, so a +// round trip is self consistent and a test stays repeatable. No test in this suite +// asserts a cryptographic property, and none may: a passing signature here says +// nothing about Ed25519. + +#include +#include + +#include + +namespace { + +// A stable byte mix, so output depends on every input byte. +void derive(uint8_t* dest, size_t dest_len, const uint8_t* a, size_t a_len, + const uint8_t* b, size_t b_len) { + uint8_t acc = 0x5A; + for (size_t i = 0; i < a_len; i++) acc = (uint8_t)(acc * 31 + a[i]); + for (size_t i = 0; i < b_len; i++) acc = (uint8_t)(acc * 31 + b[i]); + for (size_t i = 0; i < dest_len; i++) { + acc = (uint8_t)(acc * 31 + (uint8_t)i); + dest[i] = acc; + } +} + +} // namespace + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + derive(sig, SIGNATURE_SIZE, prv_key, PRV_KEY_SIZE, message, (size_t)msg_len); +} + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + // Cannot be checked without the private key, so accept anything. A test that + // needs to tell a good signature from a bad one must not use this stub. + return true; +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + derive(secret, PUB_KEY_SIZE, prv_key, PRV_KEY_SIZE, other_pub_key, PUB_KEY_SIZE); +} + +// The identity file and the identity CLI commands are not exercised here, so these +// only have to satisfy the linker, and any call is a test failure. + +bool Identity::readFrom(Stream& s) { + ADD_FAILURE() << "Identity::readFrom must not be called by the repeater role tests"; + return false; +} + +bool Identity::writeTo(Stream& s) const { + ADD_FAILURE() << "Identity::writeTo must not be called by the repeater role tests"; + return false; +} + +void Identity::printTo(Stream& s) const { + ADD_FAILURE() << "Identity::printTo must not be called by the repeater role tests"; +} + +bool LocalIdentity::readFrom(Stream& s) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the repeater role tests"; + return false; +} + +bool LocalIdentity::writeTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the repeater role tests"; + return false; +} + +void LocalIdentity::printTo(Stream& s) const { + ADD_FAILURE() << "LocalIdentity::printTo must not be called by the repeater role tests"; +} + +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) { + ADD_FAILURE() << "LocalIdentity::writeTo must not be called by the repeater role tests"; + return 0; +} + +void LocalIdentity::readFrom(const uint8_t* src, size_t len) { + ADD_FAILURE() << "LocalIdentity::readFrom must not be called by the repeater role tests"; +} + +bool LocalIdentity::validatePrivateKey(const uint8_t prv[64]) { + ADD_FAILURE() << "LocalIdentity::validatePrivateKey must not be called by the repeater role tests"; + return false; +} + +} // namespace mesh diff --git a/test/test_repeater_role/test_boot_sequence.cpp b/test/test_repeater_role/test_boot_sequence.cpp new file mode 100644 index 00000000000..3dc5e3e7b12 --- /dev/null +++ b/test/test_repeater_role/test_boot_sequence.cpp @@ -0,0 +1,13 @@ +// The boot sequence of the repeater, the role whose begin() differs from the other +// two: it starts the bridge when one is built in, and logs the RX gain it applied. +// The expectations live in test/mocks/BootSequenceChecks.h, shared with the room +// server and the sensor; main() is in test_repeater_role.cpp. + +#include "MyMesh.h" + +using BootRole = MyMesh; + +// Of the three roles only the repeater logs the gain mode it applied (debug builds). +#define BOOT_ROLE_LOGS_RX_GAIN + +#include diff --git a/test/test_repeater_role/test_repeater_role.cpp b/test/test_repeater_role/test_repeater_role.cpp new file mode 100644 index 00000000000..f2de1b47e7b --- /dev/null +++ b/test/test_repeater_role/test_repeater_role.cpp @@ -0,0 +1,12 @@ +// The repeater on the host. The repeater and the room server both define `MyMesh`, +// so each needs a binary of its own, and this is the repeater's. It exists so the +// boot sequence of all three server roles can be pinned (see +// test/mocks/BootSequenceChecks.h and docs/refactoring/0007-boot-sequence.md); the +// repeater's other behaviour is not characterized here. + +#include + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_sensor_role/test_boot_sequence.cpp b/test/test_sensor_role/test_boot_sequence.cpp new file mode 100644 index 00000000000..23ed6c31b51 --- /dev/null +++ b/test/test_sensor_role/test_boot_sequence.cpp @@ -0,0 +1,24 @@ +// The boot sequence of the sensor, which step 4f moves into the shared base. The +// expectations live in test/mocks/BootSequenceChecks.h, shared with the repeater and +// the room server; main() is in test_sensor_role.cpp. + +#include "SensorMesh.h" + +// SensorMesh is abstract on purpose: the application supplies what to read and how +// to answer a series query. This is the smallest concrete role, and it adds nothing +// to the boot sequence under test. +class BootSensor : public SensorMesh { +public: + using SensorMesh::SensorMesh; + +protected: + void onSensorDataRead() override { } + int querySeriesData(uint32_t start_secs_ago, uint32_t end_secs_ago, + MinMaxAvg dest[], int max_num) override { + return 0; + } +}; + +using BootRole = BootSensor; + +#include diff --git a/test/test_server_base/test_boot_sequence.cpp b/test/test_server_base/test_boot_sequence.cpp new file mode 100644 index 00000000000..b02d183d2d7 --- /dev/null +++ b/test/test_server_base/test_boot_sequence.cpp @@ -0,0 +1,9 @@ +// The boot sequence of the room server, which step 4f moves into the shared base. +// The expectations live in test/mocks/BootSequenceChecks.h, shared with the repeater +// and the sensor; main() is in test_server_base.cpp. + +#include "MyMesh.h" + +using BootRole = MyMesh; + +#include From 133fe68b6195e5bec4019712684c0ac8a87b4917 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 13:05:00 +0200 Subject: [PATCH 076/136] refactor(server): share the boot sequence Moves begin() of the repeater, the room server and the sensor into ServerMesh. It was the last body the three roles carried in triplicate: 53, 44 and 44 lines, identical in structure, differing only where one role has more to do. The order is unchanged (pinned by the preceding commit): the dispatcher starts first, then the prefs, the ACL and the Regions are loaded, the default scope is established, the bridge is started, the radio gets its parameters, TX power and boosted gain, then the board its dynamic prefs, the advert timers are set, the ADC multiplier is applied and the GPS toggle is pushed. The differences between the roles become three hooks on ServerMesh, plus one function that moves with the body: - loadPrefs() is pure virtual. A role owns its CommonCLI, so it says where the prefs are loaded from; each role's override is the line it had. - beginBridge() does nothing by default; the repeater overrides it with its WITH_BRIDGE block, which is the block it had. - onRadioConfigured() does nothing by default; the repeater overrides it with its one debug print of the applied gain mode, called right after the gain is applied, as before. - applyGpsPrefs() was the same inline line in all three role headers and is only used by begin(); it moves to ServerMesh and stays protected. begin() itself stays public on the base, because each role's main.cpp calls it. The hooks are protected on the base and overridden in the role's non-public section, so no visibility changes for anything outside the roles. The room server and the sensor gain two empty virtual calls; nothing else about them changes. The "TODO: key_store.begin()" comment, which only the repeater carried, now stands in the shared body, where it applies to all three. Diff proof, old begin() of each role against the shared one (diff -w -B): the room server and the sensor differ by the class name and star spacing, loadPrefs() for _cli.loadPrefs(_fs), the added TODO comment, and the two empty hook calls. The repeater differs by the class name, loadPrefs(), and its two blocks replaced by beginBridge() and onRadioConfigured(), whose overrides in MyMesh.h hold the exact lines they replaced. Evidence: the boot tests added in the preceding commit pass on the new code in all four environments (native_server, native_sensor, native_repeater, native_repeater_full), unchanged. Mutation tests and the firmware build matrix follow in the series note. --- examples/simple_repeater/MyMesh.cpp | 54 ---------------------- examples/simple_repeater/MyMesh.h | 15 +++++-- examples/simple_room_server/MyMesh.cpp | 45 ------------------- examples/simple_room_server/MyMesh.h | 8 +--- examples/simple_sensor/SensorMesh.cpp | 45 ------------------- examples/simple_sensor/SensorMesh.h | 8 +--- src/helpers/ServerMesh.cpp | 62 ++++++++++++++++++++++++++ src/helpers/ServerMesh.h | 43 +++++++++++++++++- 8 files changed, 118 insertions(+), 162 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a0aa94b8693..c0b36e26061 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -867,60 +867,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc } -void MyMesh::begin(FILESYSTEM *fs) { - mesh::Mesh::begin(); - _fs = fs; - // load persisted prefs - _cli.loadPrefs(_fs); - acl.load(_fs, self_id); - // TODO: key_store.begin(); - region_map.load(_fs); - - // establish default-scope - { - RegionEntry* r = region_map.getDefaultRegion(); - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { -#ifdef DEFAULT_FLOOD_SCOPE_NAME - r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME); - if (r == NULL) { - r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0); // auto-create the default scope region - if (r) { r->flags = 0; } // Allow-flood - } - if (r) { - region_map.setDefaultRegion(r); - region_map.getTransportKeysFor(*r, &default_scope, 1); - } -#endif - } - } - -#if defined(WITH_BRIDGE) - if (_prefs.bridge_enabled) { - bridge.begin(); - } -#endif - - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - radio_driver.setTxPower(_prefs.tx_power_dbm); - - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); - MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", - radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); - - board.attachDynamicPrefs(_prefs.getCustom()); - - updateAdvertTimer(); - updateFloodAdvertTimer(); - - board.setAdcMultiplier(_prefs.adc_multiplier); - -#if ENV_INCLUDE_GPS == 1 - applyGpsPrefs(); -#endif -} - void MyMesh::dumpLogFile() { #if defined(RP2040_PLATFORM) File f = _fs->open(PACKET_LOG_FILE, "r"); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 4d4c5aa8595..50b0d61cf9a 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -121,11 +121,19 @@ class MyMesh : public ServerMesh { void logTx(mesh::Packet* pkt, int len) override; void logTxFail(mesh::Packet* pkt, int len) override; -#if ENV_INCLUDE_GPS == 1 - void applyGpsPrefs() { - sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); + // ServerMesh hooks for begin() + void loadPrefs() override { _cli.loadPrefs(_fs); } +#if defined(WITH_BRIDGE) + void beginBridge() override { + if (_prefs.bridge_enabled) { + bridge.begin(); + } } #endif + void onRadioConfigured() override { + MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", + radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); + } void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); @@ -136,7 +144,6 @@ class MyMesh : public ServerMesh { public: MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); - void begin(FILESYSTEM* fs); void sendNodeDiscoverReq(); const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 3bed7d7e6cc..0970ef5b4d5 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -639,51 +639,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc } -void MyMesh::begin(FILESYSTEM *fs) { - mesh::Mesh::begin(); - _fs = fs; - // load persisted prefs - _cli.loadPrefs(_fs); - - acl.load(_fs, self_id); - region_map.load(_fs); - - // establish default-scope - { - RegionEntry* r = region_map.getDefaultRegion(); - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { -#ifdef DEFAULT_FLOOD_SCOPE_NAME - r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME); - if (r == NULL) { - r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0); // auto-create the default scope region - if (r) { r->flags = 0; } // Allow-flood - } - if (r) { - region_map.setDefaultRegion(r); - region_map.getTransportKeysFor(*r, &default_scope, 1); - } -#endif - } - } - - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - radio_driver.setTxPower(_prefs.tx_power_dbm); - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); - - board.attachDynamicPrefs(_prefs.getCustom()); - - updateAdvertTimer(); - updateFloodAdvertTimer(); - - board.setAdcMultiplier(_prefs.adc_multiplier); - -#if ENV_INCLUDE_GPS == 1 - applyGpsPrefs(); -#endif -} - void MyMesh::dumpLogFile() { #if defined(RP2040_PLATFORM) File f = _fs->open(PACKET_LOG_FILE, "r"); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 97036c1e4e9..b6806b0fd4e 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -127,16 +127,12 @@ class MyMesh : public ServerMesh { bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; -#if ENV_INCLUDE_GPS == 1 - void applyGpsPrefs() { - sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); - } -#endif + // ServerMesh hook for begin() + void loadPrefs() override { _cli.loadPrefs(_fs); } public: MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); - void begin(FILESYSTEM* fs); void addSystemPost(const char* postData); const char* getFirmwareVer() override { return FIRMWARE_VERSION; } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 75fa8b74a94..66359bdb82d 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -756,51 +756,6 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise } -void SensorMesh::begin(FILESYSTEM* fs) { - mesh::Mesh::begin(); - _fs = fs; - // load persisted prefs - _cli.loadPrefs(_fs); - - acl.load(_fs, self_id); - region_map.load(_fs); - - // establish default-scope - { - RegionEntry* r = region_map.getDefaultRegion(); - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } else { -#ifdef DEFAULT_FLOOD_SCOPE_NAME - r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME); - if (r == NULL) { - r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0); // auto-create the default scope region - if (r) { r->flags = 0; } // Allow-flood - } - if (r) { - region_map.setDefaultRegion(r); - region_map.getTransportKeysFor(*r, &default_scope, 1); - } -#endif - } - } - - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - radio_driver.setTxPower(_prefs.tx_power_dbm); - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); - - board.attachDynamicPrefs(_prefs.getCustom()); - - updateAdvertTimer(); - updateFloodAdvertTimer(); - - board.setAdcMultiplier(_prefs.adc_multiplier); - -#if ENV_INCLUDE_GPS == 1 - applyGpsPrefs(); -#endif -} - float SensorMesh::getTelemValue(uint8_t channel, uint8_t type) { return findTelemValue(telemetry.getBuffer(), telemetry.getSize(), channel, type, 0.0f); } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 16a048573e3..da4a9742e92 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -49,7 +49,6 @@ class SensorMesh : public ServerMesh { public: SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); - void begin(FILESYSTEM* fs); void loop(); void handleCommand(ClientInfo* from, uint32_t sender_timestamp, char* command, char* reply); @@ -134,9 +133,6 @@ class SensorMesh : public ServerMesh { void sendAlert(const ClientInfo* c, Trigger* t); - #if ENV_INCLUDE_GPS == 1 - void applyGpsPrefs() { - sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); - } -#endif + // ServerMesh hook for begin() + void loadPrefs() override { _cli.loadPrefs(_fs); } }; diff --git a/src/helpers/ServerMesh.cpp b/src/helpers/ServerMesh.cpp index de85589ab62..1b03ebe4e36 100644 --- a/src/helpers/ServerMesh.cpp +++ b/src/helpers/ServerMesh.cpp @@ -207,3 +207,65 @@ void ServerMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); } } + +// The boot sequence. The three roles each had this as their own begin(); the bodies +// were identical except for four things, which are now hooks or shared code: +// - the repeater's bridge start (beginBridge) +// - the repeater's log line about the gain mode it applied (onRadioConfigured) +// - where the prefs are loaded from, because a role owns its CommonCLI (loadPrefs) +// - the GPS toggle, three copies of one line (applyGpsPrefs, now here) +// The order below is the order they ran in, and the tests pin it. +void ServerMesh::begin(FILESYSTEM* fs) { + mesh::Mesh::begin(); + _fs = fs; + // load persisted prefs + loadPrefs(); + acl.load(_fs, self_id); + // TODO: key_store.begin(); + region_map.load(_fs); + + // establish default-scope + { + RegionEntry* r = region_map.getDefaultRegion(); + if (r) { + region_map.getTransportKeysFor(*r, &default_scope, 1); + } else { +#ifdef DEFAULT_FLOOD_SCOPE_NAME + r = region_map.findByName(DEFAULT_FLOOD_SCOPE_NAME); + if (r == NULL) { + r = region_map.putRegion(DEFAULT_FLOOD_SCOPE_NAME, 0); // auto-create the default scope region + if (r) { r->flags = 0; } // Allow-flood + } + if (r) { + region_map.setDefaultRegion(r); + region_map.getTransportKeysFor(*r, &default_scope, 1); + } +#endif + } + } + + beginBridge(); + + radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + radio_driver.setTxPower(_prefs.tx_power_dbm); + + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + onRadioConfigured(); + + board.attachDynamicPrefs(_prefs.getCustom()); + + updateAdvertTimer(); + updateFloodAdvertTimer(); + + board.setAdcMultiplier(_prefs.adc_multiplier); + +#if ENV_INCLUDE_GPS == 1 + applyGpsPrefs(); +#endif +} + +#if ENV_INCLUDE_GPS == 1 +void ServerMesh::applyGpsPrefs() { + sensors.setSettingValue("gps", _prefs.gps_enabled?"1":"0"); +} +#endif diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h index 7d3bd0c2e13..61df6600721 100644 --- a/src/helpers/ServerMesh.h +++ b/src/helpers/ServerMesh.h @@ -22,6 +22,8 @@ * - the temporary radio parameters a 'set radio ...' command installs * - the identity file, the file system erase, the TX power and the stats replies * - the client ACL and the peer lookup a packet's sender hash goes through + * - the boot sequence: begin() loads what the node stored, then gives the radio and + * the board the settings it holds, always in the same order * * A role keeps whatever is genuinely its own: what it announces, how it answers a * request, and in the repeater's case its bridge and loop detection. @@ -74,6 +76,32 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { memset(default_scope.key, 0, sizeof(default_scope.key)); } + /** + * \brief The hooks begin() calls into the role. A role owns its CommonCLI, so it + * says where the prefs are loaded from; the other two are for the one role + * that has more to do at those points, and do nothing by default. + */ + virtual void loadPrefs() = 0; + + /** + * \brief Start the role's bridge, if it has one and it is enabled. Called after + * everything is loaded and before the radio is configured. + */ + virtual void beginBridge() { } + + /** + * \brief Called once the radio has been given the node's parameters, TX power + * and boosted gain mode. + */ + virtual void onRadioConfigured() { } + +#if ENV_INCLUDE_GPS == 1 + /** + * \brief Push the stored GPS on/off setting to the sensor manager. + */ + void applyGpsPrefs(); +#endif + /** * \brief Build this node's own advertisement. * @@ -111,9 +139,20 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { void onDefaultRegionChanged(const RegionEntry* r) override; public: + /** + * \brief Bring the node up: start the dispatcher, load the stored prefs, ACL and + * Regions, establish the default scope, then configure the radio and the + * board from the prefs and start the advert timers. + * + * The order is part of the behaviour and is pinned by test/mocks/BootSequenceChecks.h. + * In particular the dispatcher starts first, so it sizes its duty cycle budget from + * the constructor's airtime factor and not the stored one. + */ + void begin(FILESYSTEM* fs); + // These were public on all three roles and stay public, so nothing outside the - // roles changes. Only sendSelfAdvertisement() needs it: each role's main.cpp - // advertises once after boot. The rest are reached through the + // roles changes. begin() and sendSelfAdvertisement() need it: each role's main.cpp + // calls them once after boot. The rest are reached through the // CommonCLICallbacks pointer, where public access comes from that interface. void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; void setTxPower(int8_t power_dbm) override; From b3d608650a441c59a94ccd9f2a45f49975ff6882 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 13:56:53 +0200 Subject: [PATCH 077/136] fix(RegionMap): let the wildcard's flags decide a scoped flood with transport code 0 What RegionMap::findMatch() returned the wildcard for transport code 0 before it looked at the mask. It now returns the wildcard only when the wildcard allows what the mask asks about, and NULL otherwise, which is how an un-scoped flood is already handled in onRecvPacket() of the repeater, the room server and the sensor. Why `region denyf *` takes the Region away from an un-scoped flood (ROUTE_TYPE_FLOOD): onRecvPacket() tests REGION_DENY_FLOOD on the wildcard itself. A ROUTE_TYPE_TRANSPORT_FLOOD packet goes through findMatch() instead, and since 01f9293b ("treat transport code 0 the same as un-scoped", PR 3568) that function starts with `if (transport_codes[0] == 0) return &wildcard;`. The wildcard is not in regions[], so the mask never reached it: a scoped flood carrying code 0 kept the wildcard as its Region whatever `denyf *` said, and the repeater forwarded it (allowPacketForward() refuses a flood only when the Region is NULL). The title of 01f9293b asks for "the same as un-scoped"; the pull request has no description, no discussion and no test, and docs/cli_commands.md says `denyf *` drops packets without region transport codes. The firmware does not build a code 0 frame itself (calcTransportCode() maps 0 to 1), but CMD_SEND_RAW_PACKET lets a companion client put one on the air. This is not a security boundary, since region keys come from the Region name; it is a way past `denyf *` that needs no name. Evidence - New test/test_region_map (env native_region) drives the real findMatch(). Before the change 2 of 8 cases are red (code 0 with a denying wildcard, and code 0 with a mask other than REGION_DENY_FLOOD), after it 8 of 8 are green. - Seven deliberate errors each turn the suite red. - pio test -e native -e native_kiss_modem -e native_region: green (62 of 62 cases). The workflow runs native_region too. - All 18 environments of the build matrix build, as does upstream/dev. RAM is unchanged, flash +0 to +16 bytes. - Not measured: nothing was run on a board or on the air, and the repeater's forwarding gate is read from the source. Related, not changed here: isFloodHopLimitExceeded() counts only ROUTE_TYPE_FLOOD as un-scoped, so a code 0 flood also passes flood.max.unscoped. === PHASE 1: BEFORE CODE === SYMPTOM: With `region denyf *` a repeater drops an un-scoped flood but forwards a scoped flood (ROUTE_TYPE_TRANSPORT_FLOOD) with transport code 0. EVIDENCE: - fw cite: src/helpers/RegionMap.cpp:191 `if (packet->transport_codes[0] == 0) return &wildcard;` (01f9293b, PR 3568); the mask is applied only inside the loop (:193) and the wildcard is not in regions[]. - fw cite: the un-scoped branch tests the wildcard itself (examples/simple_repeater/MyMesh.cpp:559-564, simple_room_server/MyMesh.cpp: 312-317, simple_sensor/SensorMesh.cpp:526-531); the scoped branch delegates to findMatch(pkt, REGION_DENY_FLOOD) (:557-558). - fw cite: only the repeater drops on a NULL Region (simple_repeater/MyMesh.cpp: 440); the room server and the sensor use it for the reply scope only (src/helpers/RoutingPolicy.h:60-66). - fw cite: docs/cli_commands.md:796 "Setting on wildcard `*` drops packets without region transport codes". - fw cite: stock firmware never builds code 0 (TransportKeyStore.cpp:12-16, four sendFlood(pkt, codes, ...) callers), but CMD_SEND_RAW_PACKET (companion_radio/MyMesh.cpp:2045-2056) forwards a host supplied frame. - runtime cite: RegionResolutionTest.TransportCodeZeroIgnoresTheWildcardsFloodDenial PASSED on refactor/sensor-align 0a93bc40 (pio test -e native_server), i.e. the old behaviour is pinned through the real room server onRecvPacket(). HYPOTHESIS: The code 0 shortcut added by 01f9293b skips the mask, so the wildcard's own flags never apply to a scoped flood without a scope. CAUSE->SYMPTOM CHAIN: frame with route type 0x00 and code 0 -> onRecvPacket() calls findMatch(pkt, REGION_DENY_FLOOD) -> line 191 returns &wildcard without testing wildcard.flags -> recv_pkt_region != NULL -> the gate at simple_repeater/MyMesh.cpp:440 does not fire -> the flood is forwarded. FALSIFICATION: (1) A deliberate exception: not found. The commit message is the title alone, the pull request is empty and was merged without a review, and the title says "the same as un-scoped". Intent is undocumented (unverified). (2) Occurrence on the air: not measured; stock firmware does not build it, a raw packet client can. (3) A duplicate or a competing change upstream: of the 139 open pull requests that touch the region, role, Mesh, Packet or Dispatcher sources, none changes line 191 or reports the asymmetry. 2960 replaces the flag test in the loop with effectiveFlags() and has to be matched by the check added here. ROOT VS PROXIMATE: Proximate: the shortcut skips the mask. Root: "un-scoped" is decided in three places independently (the wildcard test in each role's onRecvPacket(), isFloodHopLimitExceeded() at RoutingPolicy.h:19, the reply scope); 01f9293b made code 0 equal to un-scoped in the last only. The hop limit is a separate defect and is not changed here. FIX PLAN: files: src/helpers/RegionMap.cpp (findMatch), test/test_region_map (new), platformio.ini (env native_region), docs/fixes/region-code0-deny.md (own commit). parity scope: n/a, firmware only; the companion does not call findMatch(). test plan: host test of findMatch() (red before, green after); mutations; all native suites; 18 environment build matrix against upstream/dev; the pinned server base test run against the change on a scratch copy. === PHASE 2: AFTER CODE === PATCH: src/helpers/RegionMap.cpp findMatch(): if (packet->transport_codes[0] == 0) { return (wildcard.flags & mask) == 0 ? &wildcard : NULL; } Plus test/test_region_map (8 cases, own stubs), env native_region in platformio.ini, and native_region added to the unit test workflow. BINARY-VERIFY: No literal was added, so the check is on the code. Heltec_v3_repeater (ESP32-S3): RegionMap::findMatch is 0x70 bytes on upstream/dev and 0x79 bytes with the change. RAK_4631_repeater (nRF52): 0xda bytes in both, the disassembly differs from the fifth instruction on (`mov r8, r2` at 393c2). The build tree held the same RegionMap.cpp and platformio.ini blobs as this commit (4b62d41974, 166c51b549). RUNTIME-VERIFY: - findMatch() with a denying wildcard: native_region, 2 of 8 red on upstream/dev's findMatch, 8 of 8 green with the change. - Seven mutations: each red. - pio test -e native -e native_kiss_modem -e native_region: green. - refactor/sensor-align 0a93bc40 with the change on a scratch copy: native_server 33 tests, 32 green and RegionResolutionTest.TransportCodeZeroIgnoresTheWildcards FloodDenial red (expected, the test pins the old behaviour); native, native_core, native_kiss_modem, native_ofast, native_sensor all green. - 18 environment build matrix: 18 of 18 build, RAM +0, flash +0 to +16. - NOT VERIFIED: forwarding of a code 0 flood on the air; the repeater gate at MyMesh.cpp:440 is read from the source; the reply scope change in the room server and the sensor is read from chooseReplyScope(). PARITY: n/a, firmware only. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 27 ++++- src/helpers/RegionMap.cpp | 4 +- test/test_region_map/stubs/Arduino.h | 16 +++ test/test_region_map/stubs/FS.h | 29 ++++++ test/test_region_map/stubs/SHA256.h | 44 ++++++++ test/test_region_map/stubs/Stream.h | 39 +++++++ test/test_region_map/test_region_map.cpp | 123 +++++++++++++++++++++++ 8 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 test/test_region_map/stubs/Arduino.h create mode 100644 test/test_region_map/stubs/FS.h create mode 100644 test/test_region_map/stubs/SHA256.h create mode 100644 test/test_region_map/stubs/Stream.h create mode 100644 test/test_region_map/test_region_map.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..74af018e2ee 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_region -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index f296f838213..166c51b549c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -167,7 +167,9 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = + test_kiss_modem + test_region_map build_src_filter = -<*> +<../src/Utils.cpp> @@ -177,6 +179,29 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_region] +; RegionMap on the host. It reads packets and keys and writes nothing but its own +; table, so it builds against the small stubs in test/test_region_map/stubs, which are +; listed first so they take the place of the shared mocks for this suite only. +; RP2040_PLATFORM selects the Arduino FS.h file system type that RegionMap names. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -I test/test_region_map/stubs + -I src + -I test/mocks +test_build_src = yes +test_filter = test_region_map +build_src_filter = + -<*> + +<../src/Packet.cpp> + +<../src/helpers/RegionMap.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_kiss_modem] platform = native test_framework = googletest diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index d8d773803b1..4b62d419748 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -188,7 +188,9 @@ int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[], } RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) { - if (packet->transport_codes[0] == 0) return &wildcard; + if (packet->transport_codes[0] == 0) { // no scope: handled like an un-scoped flood... + return (wildcard.flags & mask) == 0 ? &wildcard : NULL; // ...so the wildcard's own flags decide + } for (int i = 0; i < num_regions; i++) { auto region = ®ions[i]; if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param) diff --git a/test/test_region_map/stubs/Arduino.h b/test/test_region_map/stubs/Arduino.h new file mode 100644 index 00000000000..147b4b8e301 --- /dev/null +++ b/test/test_region_map/stubs/Arduino.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "Stream.h" + +// TxtDataHelpers.cpp is built for StrHelper::strncpy() and also holds code that +// calls Arduino's ltoa(), which is not in the C library. Decimal is all it needs here. +inline char* ltoa(long value, char* buf, int) { + snprintf(buf, 21, "%ld", value); + return buf; +} diff --git a/test/test_region_map/stubs/FS.h b/test/test_region_map/stubs/FS.h new file mode 100644 index 00000000000..705f65915b2 --- /dev/null +++ b/test/test_region_map/stubs/FS.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +// RegionMap::load() and save() name the file system types, so they have to exist for +// the file to compile. The test never loads or saves, hence a file system with +// nothing in it. +namespace fs { + +class File { +public: + explicit operator bool() const { return false; } + int read(uint8_t*, size_t) { return 0; } + size_t write(const uint8_t*, size_t) { return 0; } + void close() {} +}; + +class FS { +public: + bool exists(const char*) { return false; } + File open(const char*, const char* = "r", bool = false) { return File(); } + bool remove(const char*) { return false; } + bool mkdir(const char*) { return false; } +}; + +} // namespace fs + +using fs::File; diff --git a/test/test_region_map/stubs/SHA256.h b/test/test_region_map/stubs/SHA256.h new file mode 100644 index 00000000000..c43de75f63f --- /dev/null +++ b/test/test_region_map/stubs/SHA256.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +// A deterministic stand-in for the SHA256 class RegionMap and TransportKeyStore use. +// It is not cryptographic. It only has to give a different key per region name and a +// different transport code per key, so a test can tell one region from another. The +// shared mock in test/mocks leaves finalizeHMAC() empty, which would hand +// calcTransportCode() an uninitialised code. +class SHA256 { + uint8_t _state[32]; + size_t _len; + + void mix(const uint8_t* bytes, size_t len) { + for (size_t i = 0; i < len; i++) { + uint8_t b = bytes[i]; + _state[_len % 32] ^= b; + _state[(_len + 1) % 32] += (uint8_t)((b >> 1) | (b << 7)); + _len++; + } + } + +public: + SHA256() : _len(0) { memset(_state, 0, sizeof(_state)); } + + void update(const void* data, size_t len) { mix((const uint8_t*)data, len); } + + void finalize(void* hash, size_t hashLen) { + for (size_t i = 0; i < hashLen; i++) ((uint8_t*)hash)[i] = _state[i % 32]; + } + + void resetHMAC(const void* key, size_t keyLen) { + memset(_state, 0, sizeof(_state)); + _len = 0; + mix((const uint8_t*)key, keyLen); + } + + void finalizeHMAC(const void* key, size_t keyLen, void* hash, size_t hashLen) { + mix((const uint8_t*)key, keyLen); + finalize(hash, hashLen); + } +}; diff --git a/test/test_region_map/stubs/Stream.h b/test/test_region_map/stubs/Stream.h new file mode 100644 index 00000000000..5908787e10d --- /dev/null +++ b/test/test_region_map/stubs/Stream.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Just enough of Arduino's Print and Stream for RegionMap's exporters to compile. +// The test never exports, so nothing here keeps what is written. +class Print { +public: + virtual ~Print() = default; + virtual size_t write(uint8_t) { return 1; } + virtual size_t write(const uint8_t* buffer, size_t size) { + size_t n = 0; + for (size_t i = 0; i < size; i++) n += write(buffer[i]); + return n; + } + size_t print(char c) { return write((uint8_t)c); } + size_t print(const char* s) { return write((const uint8_t*)s, strlen(s)); } + size_t println(const char* s) { return print(s) + print('\n'); } + size_t printf(const char* fmt, ...) { + char buf[96]; + va_list args; + va_start(args, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + return n > 0 ? write((const uint8_t*)buf, (size_t)n) : 0; + } + virtual void flush() {} +}; + +class Stream : public Print { +public: + virtual int available() { return 0; } + virtual int read() { return -1; } + virtual int peek() { return -1; } +}; diff --git a/test/test_region_map/test_region_map.cpp b/test/test_region_map/test_region_map.cpp new file mode 100644 index 00000000000..f97746417b8 --- /dev/null +++ b/test/test_region_map/test_region_map.cpp @@ -0,0 +1,123 @@ +#include +#include + +// RegionMap::findMatch() decides which Region a scoped flood arrives on. The servers +// call it with REGION_DENY_FLOOD and forward the packet only when it returns a Region. +// An un-scoped flood is filtered by the roles themselves, on the wildcard's own flag. +// A scoped flood that carries transport code 0 has no scope at all and is meant to be +// handled like an un-scoped one, so the wildcard's flag has to apply to it as well. + +namespace { + +mesh::Packet scopedFlood(uint16_t code) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = ROUTE_TYPE_TRANSPORT_FLOOD; + pkt.transport_codes[0] = code; + return pkt; +} + +class FindMatchTest : public ::testing::Test { +protected: + TransportKeyStore store; + RegionMap regions{store}; + + RegionEntry& wildcard() { return regions.getWildcard(); } + + // A Region that allows flood, together with the code a sender scoping to it uses. + RegionEntry* allowedRegion(const char* name, mesh::Packet& pkt) { + RegionEntry* region = regions.putRegion(name, 0); + region->flags &= ~REGION_DENY_FLOOD; // putRegion() denies flood by default + + TransportKey keys[4]; + EXPECT_GT(regions.getTransportKeysFor(*region, keys, 4), 0); + pkt.transport_codes[0] = keys[0].calcTransportCode(&pkt); + return region; + } +}; + +} // namespace + +TEST_F(FindMatchTest, CodeZeroArrivesOnTheWildcardWhenItAllowsFlood) { + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), &wildcard()); +} + +TEST_F(FindMatchTest, CodeZeroHasNoRegionWhenTheWildcardDeniesFlood) { + wildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), nullptr); +} + +TEST_F(FindMatchTest, CodeZeroIsOnlyHeldBackByTheFlagTheMaskNames) { + // The wildcard denies flood, but the caller asks about a different flag, and + // asking about none at all matches everything. So the shortcut has to apply the + // mask it is given, not a fixed flag. + wildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_DIRECT), &wildcard()); + EXPECT_EQ(regions.findMatch(&pkt, 0), &wildcard()); + + wildcard().flags = REGION_DENY_DIRECT; + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_DIRECT), nullptr); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), &wildcard()); +} + +TEST_F(FindMatchTest, AnUnknownCodeHasNoRegionEvenWhenTheWildcardAllowsFlood) { + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0x1234); + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), nullptr); +} + +TEST_F(FindMatchTest, ACodeThatBelongsToNoRegionHasNoRegionWhileRegionsExist) { + // With a Region in the table the lookup really compares codes, so a code that is + // not that Region's has to come back empty. + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + allowedRegion("#probe", pkt); + pkt.transport_codes[0]++; // the Region's code is never 0xFFFF, so this stays non-zero + + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), nullptr); +} + +TEST_F(FindMatchTest, TheCodeOfAnAllowedRegionFindsThatRegion) { + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + RegionEntry* region = allowedRegion("#probe", pkt); + ASSERT_NE(pkt.transport_codes[0], 0); + + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), region); +} + +TEST_F(FindMatchTest, ARegionThatDeniesFloodIsSkippedForItsOwnCode) { + wildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + RegionEntry* region = allowedRegion("#probe", pkt); + region->flags |= REGION_DENY_FLOOD; + + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), nullptr); +} + +TEST_F(FindMatchTest, ADeniedWildcardDoesNotHoldBackAScopeThatIsAllowed) { + // Only a packet with no scope falls back on the wildcard. A packet scoped to a + // Region that allows flood is that Region's business, whatever the wildcard says. + wildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = scopedFlood(0); + RegionEntry* region = allowedRegion("#probe", pkt); + + EXPECT_EQ(regions.findMatch(&pkt, REGION_DENY_FLOOD), region); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 201ec9ce073f3ff419bc543ab7fdad8982876a42 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 13:57:06 +0200 Subject: [PATCH 078/136] docs(fixes): note for the transport code 0 wildcard fix Records what the defect was, where the code 0 shortcut came from, who can put such a frame on the air, the alternatives that were rejected, the compatibility consequences, the measurements (host test, mutations, build matrix, effect on the server base series) and what was not measured. Kept as its own commit so it can be left out of an upstream pull request. --- docs/fixes/region-code0-deny.md | 232 ++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/fixes/region-code0-deny.md diff --git a/docs/fixes/region-code0-deny.md b/docs/fixes/region-code0-deny.md new file mode 100644 index 00000000000..b903b1acef2 --- /dev/null +++ b/docs/fixes/region-code0-deny.md @@ -0,0 +1,232 @@ +# A scoped flood with transport code 0 ignored `region denyf *` + +## Context + +`RegionMap::findMatch()` decides which Region a `ROUTE_TYPE_TRANSPORT_FLOOD` +packet belongs to. The three server roles call it from `onRecvPacket()` with +`REGION_DENY_FLOOD` as the mask and keep the result in `recv_pkt_region` +(`examples/simple_repeater/MyMesh.cpp:558`, +`examples/simple_room_server/MyMesh.cpp:311`, +`examples/simple_sensor/SensorMesh.cpp:525`, all on `upstream/dev` 428a95c6). + +An un-scoped flood (`ROUTE_TYPE_FLOOD`) never goes through `findMatch()`. The +same `onRecvPacket()` looks at the wildcard itself: + +```cpp +} else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { + recv_pkt_region = NULL; + } else { + recv_pkt_region = ®ion_map.getWildcard(); + } +} +``` + +So `region denyf *` takes the Region away from an un-scoped flood. Since +`01f9293b` ("treat transport code 0 the same as un-scoped", meshcore-dev/MeshCore +PR 3568, merged 13 Oct 2026 and part of the v1.18.0 releases) `findMatch()` starts +with + +```cpp +if (packet->transport_codes[0] == 0) return &wildcard; +``` + +(`src/helpers/RegionMap.cpp:191`). The wildcard is not in `regions[]`, so the +mask is applied to every Region but never to it, and a scoped flood that carries +code 0 keeps the wildcard as its Region however the wildcard is set. Before that +commit code 0 fell through the loop, no Region owns it, and the result was NULL. + +The commit message is the title alone, the pull request has no description and +no discussion, and nothing else explains the difference. The title says "the +same as un-scoped", and an un-scoped flood respects the denial. The CLI +reference says the same about the setting: `region denyf *` "drops packets +without region transport codes" (`docs/cli_commands.md:796`), and +`flood.max.unscoped` applies to "a packet without a scope" (`:696`). + +### What the Region is used for + +Only the repeater acts on it when forwarding: `allowPacketForward()` refuses a +flood whose `recv_pkt_region` is NULL (`examples/simple_repeater/MyMesh.cpp:440`). +The room server and the sensor forward without looking at the Region +(`simple_room_server/MyMesh.cpp:300`, `SensorMesh.cpp:319`). In all three roles +`sendFloodReply()` uses it to pick the scope of a flooded reply, through +`chooseReplyScope()` (`src/helpers/RoutingPolicy.h:60`): a request that arrived +on the wildcard is answered un-scoped, one that arrived on no Region is answered +on the default scope when there is one. + +### Who sends code 0 + +The firmware does not build such a frame itself. `TransportKey::calcTransportCode()` +maps a computed 0x0000 to 0x0001 (`src/helpers/TransportKeyStore.cpp:12`), and +the four places that call `sendFlood(pkt, codes, ...)` all sit behind it +(`companion_radio/MyMesh.cpp:498`, `simple_repeater/MyMesh.cpp:1006`, +`simple_room_server/MyMesh.cpp:748`, `simple_sensor/SensorMesh.cpp:916`). +`CMD_SEND_RAW_PACKET` of the companion (`companion_radio/MyMesh.cpp:2045`) hands a +host supplied frame to `sendPacket()` after `tryParsePacket()`, which reads the +four code bytes of a route type 0x00 frame as they are. Any companion client can +therefore put a code 0 flood on the air. Whether anyone does is not measured. + +This is not a security boundary. Region keys come from the Region name, so +anyone can scope a flood to a Region that is allowed. What the code 0 route +changes is that no name has to be known: it gets past `denyf *` unscoped. + +## Decision + +Keep the shortcut and let the mask apply to it, the way it applies to every +Region in the loop: + +```cpp +if (packet->transport_codes[0] == 0) { // no scope: handled like an un-scoped flood... + return (wildcard.flags & mask) == 0 ? &wildcard : NULL; // ...so the wildcard's own flags decide +} +``` + +With the wildcard allowing flood (the default) nothing changes. With +`denyf *` a code 0 flood now ends up where an un-scoped flood does: no Region. +The mask is the one the caller passed, not a fixed `REGION_DENY_FLOOD`, so the +function keeps meaning "does this Region allow what the caller asks about". + +## Alternatives considered + +- **Check the wildcard in each role's `onRecvPacket()`.** Rejected. It is three + copies on `upstream/dev` (identical apart from the class name), the callers + already pass the mask for exactly this purpose, and the next role would have + to remember it. +- **Remove the shortcut again.** Rejected. Code 0 would be dropped by every + repeater whatever the wildcard says, which is the opposite of what the title + of `01f9293b` asks for. +- **One predicate for "is un-scoped" used by `findMatch()`, the hop limit and + the reply scope.** The right shape in the long run, because "un-scoped" is + decided in three places independently (see below). It needs a new method on + `Packet` and changes in every role, which is a larger change than the defect + asks for. Not done here. + +## Compatibility + +- Settings, files and the protocol are untouched, so there is nothing to migrate + and a downgrade needs nothing. +- Repeaters with the wildcard allowing flood behave as before. On a repeater + with `region denyf *` a flood with code 0 is no longer forwarded. +- Room servers and sensors forward as before. Their reply to a code 0 request + under `denyf *` goes out on the default scope when one is set, which is what a + reply to an un-scoped request does today, instead of un-scoped. +- In a mixed mesh a repeater with this change drops what v1.18.0 repeaters with + `denyf *` still forward, which is what happens to un-scoped floods already. +- The companion does not call `findMatch()` and is not affected. + +## Verification + +### Host tests + +New `test/test_region_map` drives `RegionMap::findMatch()` with the real +`RegionMap.cpp`, `TransportKeyStore.cpp` and `Packet.cpp`. Eight cases: code 0 +with the wildcard allowing and denying flood, code 0 with other masks, an unknown +code with and without a Region in the table, the code of an allowed Region, a +Region that denies flood, and a denied wildcard in front of an allowed scope. +It has its own small stubs (`test/test_region_map/stubs`), listed first on the +include path, because the shared SHA256 mock leaves `finalizeHMAC()` empty and +would hand `calcTransportCode()` an uninitialised code. + +| | before the change | after | +|---|---|---| +| `native_region` | 2 of 8 red | 8 of 8 green | + +The six cases that pass on the old code are guards: they pin what the change must +not touch (the loop, the mask, an allowed wildcard). + +All suites of this branch, `pio test -e native -e native_kiss_modem -e native_region`, +come out green (PlatformIO reports 62 of 62 cases). The workflow now runs `native_region` too; without that the new +suite would not have run in CI. + +### Mutations + +Seven deliberate errors, each had to turn the suite red: + +| Mutation | Red test | +|---|---| +| shortcut ignores the mask again (the old code) | `CodeZeroHasNoRegionWhenTheWildcardDeniesFlood`, `CodeZeroIsOnlyHeldBackByTheFlagTheMaskNames` | +| mask test inverted | three tests | +| code 0 never matches | `CodeZeroArrivesOnTheWildcardWhenItAllowsFlood`, `CodeZeroIsOnlyHeldBackByTheFlagTheMaskNames` | +| fixed `REGION_DENY_FLOOD` instead of the given mask | `CodeZeroIsOnlyHeldBackByTheFlagTheMaskNames` | +| wildcard denial applied to every scoped flood | `ADeniedWildcardDoesNotHoldBackAScopeThatIsAllowed` | +| loop matches any code | `ACodeThatBelongsToNoRegionHasNoRegionWhileRegionsExist` | +| loop ignores the mask | `ARegionThatDeniesFloodIsSkippedForItsOwnCode` | + +### Effect on the server base series + +The server base series (`refactor/server-base`, `refactor/sensor-align`) pins the +old behaviour in `test/test_server_base/test_server_base.cpp`, +`RegionResolutionTest.TransportCodeZeroIgnoresTheWildcardsFloodDenial`. With this +change applied to a copy of `refactor/sensor-align` 0a93bc40, `native_server` +runs 33 tests, 32 green and that one red (the second expectation, "scoped flood +with code 0 does not"); `native`, `native_core`, `native_kiss_modem`, `native_ofast` +and `native_sensor` stay green (PlatformIO reports 90 of 90 cases). That test has to be turned around when +the two lines of work meet: it should expect no Region for the scoped flood, and +its name no longer fits. `RegionMap.cpp` is the same blob on `upstream/dev`, +`integration`, `refactor/sensor-align` and `refactor/server-base`, so the change +merges without a text conflict in that file. + +### Builds + +All 18 environments of the matrix build with this change, and so does +`upstream/dev` 428a95c6 in its own worktree with the same toolchain. They are the +roles that link `RegionMap.cpp` (repeater, room server, sensor, companion) on every +platform of the matrix. + +| Environment | Build | RAM against dev | Flash against dev | +|---|---|---|---| +| `Heltec_v3_companion_radio_ble` | OK | +0 | +0 | +| `Heltec_v3_companion_radio_wifi` | OK | +0 | +0 | +| `Heltec_v3_repeater` | OK | +0 | +12 | +| `Heltec_v3_room_server` | OK | +0 | +8 | +| `Heltec_v3_sensor` | OK | +0 | +8 | +| `PicoW_repeater` | OK | +0 | +16 | +| `ProMicro_companion_radio_usb` | OK | +0 | +0 | +| `ProMicro_repeater` | OK | +0 | +0 | +| `ProMicro_room_server` | OK | +0 | +0 | +| `ProMicro_sensor` | OK | +0 | +0 | +| `RAK_3112_repeater_bridge_espnow` | OK | +0 | +8 | +| `RAK_4631_companion_radio_ble` | OK | +0 | +0 | +| `RAK_4631_repeater` | OK | +0 | +0 | +| `RAK_4631_room_server` | OK | +0 | +0 | +| `RAK_4631_sensor` | OK | +0 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | OK | +0 | +16 | +| `wio-e5-mini_repeater` | OK | +0 | +8 | +| `wio-e5-mini_sensor` | OK | +0 | +8 | + +RAM is unchanged everywhere. The ESP32, RP2040 and STM32 server builds grow by 8 +to 16 bytes (`findMatch()` itself, 0x70 to 0x79 bytes on the ESP32-S3). Two groups +come out at +0, for different reasons: + +- The companion builds never call `findMatch()`; the symbol is not in their images + at all, before or after (`nm` finds none in `Heltec_v3_companion_radio_wifi` and + `RAK_4631_companion_radio_ble`). +- The nRF52 server builds (`RAK_4631_*`, `ProMicro_*`) contain the change, but the + function is 0xda bytes before and after. The disassembly of `findMatch()` in + `RAK_4631_repeater` differs from the fifth instruction on (the mask is kept in + `r8` instead of `r9`), so the extra compare fits into the same size. + +### What was not measured + +- Nothing was run on a board or on the air. The repeater's forwarding gate + (`simple_repeater/MyMesh.cpp:440`) is read from the source; there is no host + harness for the repeater role. The choice `findMatch()` makes is covered by + the host test, and what a role does with it is covered for the room server by + the series' `native_server` suite. +- The reply scope change for the room server and the sensor is read from + `chooseReplyScope()`, not measured. +- Whether any client sends code 0 floods on the air is not known. + +## Related + +- **The hop limit has the same hole.** `isFloodHopLimitExceeded()` counts only + `ROUTE_TYPE_FLOOD` as un-scoped (`src/helpers/RoutingPolicy.h:19`), so a code 0 + flood also passes `flood.max.unscoped`, the alternative to `denyf *` that + `docs/cli_commands.md:700` recommends. Same cause, different function, left + alone here. +- **Open pull request 2960** (runtime region gating by duty cycle) replaces the + flag test in this loop and in the un-scoped branch with `effectiveFlags()`. A + merge will not conflict with this change, so the wildcard check added here has to + use `effectiveFlags()` too, or code 0 will walk past that gate the same way. +- **Open pull request 1791** (a default "any" Region) changes the end of + `findMatch()` and does not overlap. From 78e7c02fc736ccccd46bc73d997d79dbd0a10a3b Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 14:04:36 +0200 Subject: [PATCH 079/136] docs(refactoring): the boot sequence note, the build matrix and the ledger Adds 0007, the note for step 4f, in the style of 0001 to 0006: what moved and why, the three hooks, the alternatives that were rejected, the finding that Mesh::begin() runs before the prefs are loaded, and the evidence. Evidence in the note: - the boot characterization written first on the old code, and what it pins - the diff proof of each old begin() against the shared one - 15 mutation tests, all red; the first version of one was an equivalent mutant and is recorded as such - the host suites: 211 of 211 on the old and on the new code, plus a syntax check of the new test files with three GCC versions - the build matrix: 22 environments on three commits (dev, the previous tip and this step), 66 of 66 builds green, RAM unchanged by this step in all of them, and the flash deltas attributed to symbols - that the previous tip's rebuild reproduces the numbers recorded in 0004, RAM exactly and flash for 14 of 18; the four sensors differ because the recorded state carried the flood limit half of the alignment, and building that branch's tip reproduces them exactly - 9 more builds per commit of kiss modem and terminal chat environments, which also compile ServerMesh.cpp - the debug build string comparison that shows the room server and the sensor gained no log line What was not verified is listed in the note, with the board test described for whoever runs it: no board was touched. The ledger gets a status for F2 and F6 and issue 13 (the dispatcher budget ordering). README gets the step 4 row. 0004's two bullets about begin() and allowPacketForward() are brought up to date: the second was true only on the flood limit branch, not on the branch this step is stacked on. --- docs/refactoring/0004-shared-server-base.md | 17 +- docs/refactoring/0007-boot-sequence.md | 399 ++++++++++++++++++ docs/refactoring/README.md | 3 +- docs/refactoring/architecture-review-v1.18.md | 13 +- 4 files changed, 421 insertions(+), 11 deletions(-) create mode 100644 docs/refactoring/0007-boot-sequence.md diff --git a/docs/refactoring/0004-shared-server-base.md b/docs/refactoring/0004-shared-server-base.md index 51658331b50..2c9aa7e8415 100644 --- a/docs/refactoring/0004-shared-server-base.md +++ b/docs/refactoring/0004-shared-server-base.md @@ -167,15 +167,14 @@ instead of three times. The host suite grew from 82 to 115 test cases. What is still shared between all three roles, and why it stayed: -- `begin()` is step 4f. Its precondition is met since the sensor alignment closed - the RX boosted gain gap: measured after that change, the room server's and the - sensor's `begin()` are now **identical**, 44 lines each, and the repeater differs - only by its `WITH_BRIDGE` block and one debug print. So 4f is a move with one - hook, and the measurement is in place for whoever picks it up. -- `allowPacketForward()` is now **identical** in the room server and the sensor, 8 - lines each, after the alignment. The repeater adds a Region check and loop - detection on top of the same two leading checks. A later step could share the - common part behind a hook; it was not part of this cut. +- `begin()` was step 4f and has landed, see [0007](0007-boot-sequence.md): one body in + `ServerMesh` with three hooks, the order unchanged. +- `allowPacketForward()` is a candidate. The room server's and the sensor's bodies + are identical only on the branch that also carries the flood limit alignment + (`refactor/sensor-flood-limits`, 8 lines each there); on the branch without it the + sensor still checks `flood_max` alone (5 lines). The repeater adds a Region check + and loop detection on top of the same two leading checks. A later step could share + the common part behind a hook; it was not part of this cut. - `createSelfAdvert()` is a role hook by design now: only the advert type differs. - `handleCommand()`, `handleRequest()`, `onAnonDataRecv()`, `onPeerDataRecv()` and `loop()` differ by role and are out of scope, as stated above. diff --git a/docs/refactoring/0007-boot-sequence.md b/docs/refactoring/0007-boot-sequence.md new file mode 100644 index 00000000000..773601c3366 --- /dev/null +++ b/docs/refactoring/0007-boot-sequence.md @@ -0,0 +1,399 @@ +# 0007: The boot sequence moves into the shared base + +Status: implemented. This is step 4f, the last of the series 4a to 4f described in +[0004](0004-shared-server-base.md). + +Of everything the three server roles carried in triplicate, `begin()` was the +largest body left. It is a move, not a change: the order of every call is kept, and +the tests that pin it passed before the move and after it without being touched. + +## Context + +`begin()` is the boot sequence of a node: it starts the dispatcher, loads what the +node stored, establishes the default scope, gives the radio and the board the +settings it holds, and starts the advert timers. Before this step each role had its +own copy, 53 lines in the repeater and 44 in the room server and in the sensor. + +After the sensor alignment ([0005](0005-sensor-alignment.md)) closed the RX boosted +gain gap, the room server's and the sensor's bodies are the same text, and the +repeater's differs in two blocks. Measured on the commit this step started from, +comparing each old body with the shared one by `diff -w -B` (blank lines and +whitespace ignored, see "Diff proof" below): + +| role | lines | differs from the shared body by | +|---|---|---| +| room server | 44 | class name, `loadPrefs()`, one comment, two hook calls | +| sensor | 44 | the same | +| repeater | 53 | class name, `loadPrefs()`, and two blocks replaced by hook calls | + +The two repeater blocks are the `WITH_BRIDGE` start of the bridge and one debug +print of the gain mode the radio ended up in. + +## What changes + +`ServerMesh` gets a public `begin(FILESYSTEM*)` with the sequence, and the roles lose +theirs. Everything a role must say for itself becomes a protected virtual on the +base: + +| hook | default | the role that overrides it | +|---|---|---| +| `loadPrefs()` | none, pure virtual | all three: `_cli.loadPrefs(_fs)`, the line each had. A role owns its `CommonCLI`, so the base cannot call it | +| `beginBridge()` | does nothing | the repeater, under `WITH_BRIDGE`: starts the bridge if `bridge_enabled`, the block it had | +| `onRadioConfigured()` | does nothing | the repeater: the debug print of the applied gain mode | + +`applyGpsPrefs()` goes with the body. It was the same inline line in all three role +headers (`sensors.setSettingValue("gps", ...)`, checked by grep: three matches across +the three old headers and one in the base), and `begin()` was its only caller, so it +moves to `ServerMesh` as a protected method behind `ENV_INCLUDE_GPS`. + +The order, which the tests pin, is: + +1. `mesh::Mesh::begin()`, which starts the dispatcher and the radio driver +2. the file system pointer is stored +3. the prefs are loaded (`loadPrefs()`) +4. the ACL is loaded +5. the Regions are loaded +6. the default scope is established from the stored default Region, or from + `DEFAULT_FLOOD_SCOPE_NAME` if the build names one +7. the bridge is started (`beginBridge()`) +8. the radio gets its parameters, TX power and boosted gain mode +9. `onRadioConfigured()` +10. the board gets its dynamic prefs +11. both advert timers are set +12. the board gets its ADC multiplier +13. the GPS toggle is pushed to the sensor manager (`ENV_INCLUDE_GPS`) + +What does not change: + +- **The order.** Every call is where it was. Sections of it are load bearing; see + "The order is part of the behaviour" below. +- **Visibility.** `begin()` stays public because each role's `main.cpp` calls it. The + hooks are protected on the base and overridden in the roles' non-public sections, + so nothing outside the three roles sees a new name. This is the lesson of step 4b, + where an access level nearly broke the firmware build in a place the host harness + does not compile. +- **The roles' behaviour.** The room server and the sensor gain two empty virtual + calls; nothing else. +- **One comment.** `// TODO: key_store.begin();` stood in the repeater's copy only. + It is true of all three roles, since no role starts the key store, so it now stands + in the shared body. + +## Alternatives + +- **Each role keeps its `begin()` and calls shared steps.** Rejected. The order of the + steps is the thing worth sharing, and this keeps it written out three times; a + future reordering would again have to be made in three places. +- **One virtual hook with a stage argument** (`onBootStage(AFTER_LOADING)` and so on) + instead of two. Rejected for readability: two named hooks say where they run. +- **Move the `CommonCLI` into the base**, which removes `loadPrefs()` and the three + copies of `savePrefs()` together. The three constructions are already identical + (`_cli(board, rtc, sensors, region_map, acl, &_prefs, this)` in all three), so this + is possible, but the base would need the board in its constructor, which changes + the constructor of `ServerMesh` and of the three roles. That is more than a move. + Listed as a next candidate. +- **Log the gain mode in all three roles** by putting the print into the shared body. + It would change what the room server and the sensor print in a debug build, and a + step that promises no change does not take that decision. The hook keeps the print + where it was; making it shared is one line if it is wanted. + +## The order is part of the behaviour + +Writing the tests turned up an ordering that a reader of the three copies would not +notice and that a careless move could have changed: `mesh::Mesh::begin()` runs +**before** the prefs are loaded. + +`Dispatcher::begin()` sizes the transmit budget from the airtime factor +(`src/Dispatcher.cpp:25-28`). At that point the node holds the factor its constructor +set (1.0), not the one on flash, so the budget starts at the size that factor gives +(half of the one hour window). `updateTxBudget()` reads the stored factor, and every +budget decision calls it first (`Dispatcher.cpp:96` and `:282`): if the stored factor +is stricter than 1.0 the budget is capped to the stored maximum before it is used, and +if it is looser the budget starts below its cap and refills at the stored rate. So +the effect is a start-up level, not a lasting setting. + +This is read from the code. It is not measured on a board, and it is probably +harmless, which is why the test pins it as observed behaviour without endorsing it, +and why it is in the ledger as an observation and not as a fix (issue 13). The point +for this step is the order: the first two lines of `begin()` cannot be swapped in a +move without changing what the dispatcher starts with. + +## Verification + +### Characterization first + +The tests were written and run on the three old `begin()` bodies, in their own +commit, before anything moved. One set of expectations, +`test/mocks/BootSequenceChecks.h`, is included by the suite of each role, so the +three are held to the same behaviour. They watch the boot from outside. + +To make the order observable the mocks keep one ordered event log +(`test/mocks/EventLog.h`): the file system lookups, the calls on the radio, the board +and the sensor manager, and a stand-in RS232 bridge all append a tag when called. The +sensor manager mock now records the settings pushed to it. + +The repeater had no host environment, because it shares the name `MyMesh` with the +room server. `native_repeater` adds it. `native_repeater_full` runs the same suite +with every optional block of `begin()` compiled in: a bridge, the GPS toggle, a +configured default scope and the debug log. Both are in the CI test run. + +| | what is pinned | environments | +|---|---|---| +| order | the dispatcher first; prefs, then ACL, then Regions, all before the radio; params, power, gain, then the board's prefs and ADC multiplier; each exactly once | all | +| the quirk | the dispatcher sizes its budget from the constructor's airtime factor | all | +| values | stored radio settings, gain, ADC multiplier and advert intervals reach the radio, the board and the timers; the stored ACL (with its secrets recalculated) and Regions load | all | +| scope | a stored default Region becomes the default scope; none without one | all | +| scope, configured | the named Region is created with flood allowed, reused with its flags, loses to a stored default, and is not written to flash | `native_repeater_full` | +| GPS | the stored setting is pushed last, on and off; nothing is pushed without GPS support | `native_repeater_full`, the others | +| repeater only | the bridge starts after loading and before the radio, and not when disabled; the debug build logs the applied mode, after applying it | `native_repeater_full` | +| housekeeping | booting an empty file system writes nothing; the role can save its prefs right after `begin()` | all | + +20 boot cases run in each of the three default environments and 28 in +`native_repeater_full`. + +### Diff proof + +Each old body extracted from the commit before the move and compared with the shared +one (`diff -w -B`): + +``` +OLD repeater vs NEW shared +1c1 void MyMesh::begin(FILESYSTEM *fs) { -> void ServerMesh::begin(FILESYSTEM* fs) { +5c5 _cli.loadPrefs(_fs); -> loadPrefs(); +30-34 #if defined(WITH_BRIDGE) ... bridge.begin(); ... #endif -> beginBridge(); +40-41 MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: ...); -> onRadioConfigured(); + +OLD room server vs NEW shared (the sensor's diff is the same) +1c1 void MyMesh::begin(FILESYSTEM *fs) { -> void ServerMesh::begin(FILESYSTEM* fs) { +5-6 _cli.loadPrefs(_fs); -> loadPrefs(); +7a7 (nothing) -> // TODO: key_store.begin(); +29a30 (nothing) -> beginBridge(); +32a36 (nothing) -> onRadioConfigured(); +``` + +Every line is accounted for. The repeater's replaced blocks are the bodies of its +overrides in `examples/simple_repeater/MyMesh.h`, character for character (the +`MESH_DEBUG_PRINTLN` call and the `if (_prefs.bridge_enabled) { bridge.begin(); }`). + +### Mutation tests + +Fifteen deliberate faults in the committed code, one at a time, run against the four +environments they can affect. Every one turns the suite red. The faults were applied +to a copy and restored from it after each run, never with `git checkout`, because +that would also have removed any uncommitted work. + +| # | fault | caught by | +|---|---|---| +| M1 | ACL and Regions loaded in the other order | `PrefsAreLoadedBeforeTheAclAndTheAclBeforeTheRegions`, all four | +| M2 | `Mesh::begin()` moved after loading the prefs | `TheDispatcherStartsBeforeAnythingIsLoaded`, `TheDispatcherSizesItsBudget...`, `PrefsAreLoadedBefore...` | +| M3 | TX power set before the radio parameters | `TheRadioIsSetUpInTheOrderParamsPowerGainThenTheBoard` | +| M4 | ADC multiplier no longer applied | `EachStepOfTheSetUpHappensExactlyOnce`, `TheStoredAdcMultiplierReachesTheBoard`, the order test | +| M5 | advert timers computed before the prefs are loaded | `AdvertTimersCountFromTheStoredIntervals`, `ZeroIntervalsLeaveBothTimersOff` | +| M6 | bridge started after the radio is configured | `TheBridgeStartsAfterTheLoadingAndBeforeTheRadioIsConfigured` | +| M7 | `onRadioConfigured()` before the gain is applied | `TheRepeaterLogsTheGainModeAfterApplyingIt` | +| M8 | a stored default Region no longer yields the default scope | `AStoredDefaultRegionBecomesTheDefaultScope`, all four | +| M9 | the auto-created default Region left denying flood | `TheConfiguredDefaultScopeIsCreatedWhenNoneIsStored` | +| M10 | the GPS setting pushed with inverted polarity | `AStoredGpsOffIsPushedAsOff`, `TheStoredGpsSettingIsPushedToTheSensorManagerLast` | +| M11 | the bridge started although it is disabled | `ADisabledBridgeIsNotStarted` | +| M12 | the sensor loads its prefs from nowhere | seven boot cases and `SensorRoleTest.AStoredBoostedGainSurvivesAReboot` | +| M13 | the room server loads its prefs from nowhere | seven boot cases | +| M14 | the repeater loads its prefs from nowhere | ten boot cases, both repeater environments | +| M15 | the file system pointer is null while the prefs load | segmentation fault in all four environments | + +M5 was first written as an extra, earlier call to the timers with the original +calls left in place. That mutant survived, correctly: the later calls overwrite the +earlier ones, so it changes nothing observable. It was rewritten as a real move of +the calls and then turned the suite red. Of the fifteen, M12 to M14 are the ones +that show the shared checks reaching each role's own hook. + +### Host suites + +`pio test -e native -e native_ofast -e native_server -e native_sensor -e native_core +-e native_kiss_modem -e native_repeater -e native_repeater_full`: 211 of 211 test +cases pass, the same count before and after the move. The CI runs all eight; its GCC +build is the only run of the tests under GCC, since this machine has clang only. + +As a partial substitute, the boot test files of the three suites and of +`native_repeater_full` were syntax checked (`-fsyntax-only`, host mocks and the +googletest headers) with the `arm-none-eabi-g++` of three PlatformIO toolchains: +7.2.1, 12.3.1 and 14.2.1. Zero errors in all twelve combinations, and a positive +control (a role type that does not exist) gives 18 errors on the same command. That +is a type check against newlib, not a run on Linux, so it does not replace the CI. + +## The build matrix + +Run on 20 October in three worktrees at exact commits, the same toolchains, one +`pio run` per environment: `dev` (`428a95c6`), the commit this step started from +(`0a93bc40`, steps 4a to 4e and the first half of the sensor alignment, called 4e in +the table) and the commit that carries the code of this step (`133fe68b`). The commits +after it change documentation only. **66 of 66 builds succeeded, none failed.** + +The 18 environments of the series matrix, plus four that close gaps in it: the 18 +contain no RP2040 room server, nothing on the pioarduino ESP-IDF 5 toolchain (which +also compiles these files), and no RS232 bridge on nRF52. That makes 22 per commit. + +| environment | RAM | vs 4e | vs dev | Flash | vs 4e | vs dev | +|---|---|---|---|---|---|---| +| `Heltec_v3_repeater` | 62512 | +0 | +0 | 1173909 | +56 | -540 | +| `Heltec_v3_room_server` | 66200 | +0 | +0 | 1169205 | +52 | -500 | +| `Heltec_v3_companion_radio_ble` | 194844 | +0 | +8 | 1666053 | +48 | -384 | +| `Heltec_v3_companion_radio_wifi` | 172384 | +0 | +8 | 1102225 | +48 | -452 | +| `Heltec_v3_sensor` | 60600 | +0 | +40 | 1180417 | +84 | -176 | +| `RAK_4631_repeater` | 37240 | +0 | +0 | 441048 | +192 | +96 | +| `RAK_4631_room_server` | 40928 | +0 | +0 | 434232 | +192 | +144 | +| `RAK_4631_companion_radio_ble` | 149768 | +0 | +8 | 462196 | +0 | -352 | +| `RAK_4631_sensor` | 35328 | +0 | +40 | 437816 | +208 | +272 | +| `PicoW_repeater` | 85368 | +0 | +0 | 492132 | -24 | +160 | +| `wio-e5-mini_repeater` | 17172 | +0 | +0 | 203584 | -24 | +32 | +| `wio-e5-mini_sensor` | 15276 | +0 | +40 | 203040 | -24 | +136 | +| `RAK_3112_repeater_bridge_espnow` | 64072 | +0 | -8 | 1175209 | +84 | -508 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | 26704 | +0 | +0 | 240544 | -24 | +176 | +| `ProMicro_repeater` | 33892 | +0 | +0 | 345504 | +192 | +80 | +| `ProMicro_room_server` | 37588 | +0 | +0 | 338584 | +160 | +128 | +| `ProMicro_companion_radio_usb` | 99468 | +0 | +8 | 356964 | +0 | -336 | +| `ProMicro_sensor` | 31988 | +0 | +40 | 342140 | +192 | +240 | +| `waveshare_rp2040_lora_room_server` | 28532 | +0 | +0 | 232084 | -32 | +112 | +| `M5Stack_Unit_C6L_repeater` | 29752 | +0 | +0 | 552061 | -64 | -36 | +| `M5Stack_Unit_C6L_room_server` | 33448 | +0 | +0 | 546661 | -70 | -62 | +| `Heltec_t096_repeater_bridge_rs232` | 37148 | +0 | +0 | 415824 | +208 | +64 | + +RAM did not move in any of the 22 environments from 4e to 4f. Against `dev` the +deltas are those of the earlier steps and nothing of this one: `+32` for the sensor +from step 4e (its peer index grew from 24 to `MAX_CLIENTS` entries), `+8` on the +companion and the sensor from step 2 (recorded in [0002](0002-core-reads-radio-prefs.md)), and `-8` on `RAK_3112_repeater_bridge_espnow`, +which 0004 already lists as not explained. The flash column against `dev` is the sum of +all steps and is shown for orientation; the column to read for this step is "vs 4e". + +### Reproducing the numbers recorded in 0004 + +0004 prints, per environment, the series base's RAM and flash and the delta to the +tip. Rebuilt, the 4e column here equals base plus delta for RAM in all 18 +environments and for flash in 14 of 18. The four that differ are the sensors, whose +recorded state also carried the flood limit half of the alignment +(`refactor/sensor-flood-limits`, not on this branch). Building those four sensor +environments on that branch's tip reproduces the recorded numbers exactly: + +| environment | RAM | Flash | +|---|---|---| +| `Heltec_v3_sensor` | 60600 | 1180401 | +| `RAK_4631_sensor` | 35328 | 437672 | +| `wio-e5-mini_sensor` | 15276 | 203112 | +| `ProMicro_sensor` | 31988 | 342012 | + +(Recorded in 0004: Heltec 60568 + 32 and 1179737 + 664, RAK 35296 + 32 and +437416 + 256, wio-e5-mini 15244 + 32 and 202424 + 688, ProMicro 31956 + 32 and +341772 + 240, which sum to the values above.) + +### What the flash deltas of this step are + +RAM is unchanged, so the whole difference between 4e and 4f is flash. Symbol sizes +(`nm -S`) and section sizes (`size -A`) from the ELF files, per toolchain: + +| group | flash vs 4e | what the symbols show | +|---|---|---| +| ESP32-S3 servers (Heltec v3 x3, RAK 3112 bridge) | +52 to +84 | Code shrinks: `.flash.text` -8, -28 and -24 on repeater, room server and sensor. The three old `begin()` bodies (263 to 275 B each) become one of 176 B, plus `loadPrefs()` 16 B, `applyGpsPrefs()` 28 B and the empty hooks at 5 B. `.flash.rodata` grows by 64, 80 and 108: 24 B of that are the two vtables (12 B each, three new virtual slots), the rest (40 to 72 B) is **not attributed**; the rodata of the whole image shifts, and no new string or symbol accounts for it | +| nRF52 servers (RAK 4631 x3, ProMicro x3, Heltec t096 bridge) | +160 to +208 | The shared `begin()` is 456 to 488 B where each old one was 316 to 352 B. The disassembly of `RAK_4631_repeater` shows why: the two advert timer updates, which the role copies called through the vtable, are inlined in the shared body (two calls to `Dispatcher::futureMillis` appear, none before). That is the compiler's decision in a new translation unit under nRF52's `-Ofast`, probably speculative devirtualization; **not verified** with `-fno-devirtualize-speculatively`. On top: `loadPrefs()` 16 B, vtables 24 B, empty hooks 4 to 6 B. For `RAK_4631_repeater` that is 186 of the 192 B, the other 6 are not attributed; `ProMicro_room_server` shows +160 for a symbol sum of +184, a 24 B residual that is **not attributed** | +| RP2040, STM32 servers | -24 to -32 | `.text` -48 and `.rodata` +24 (the two vtables), measured on `PicoW_repeater` and `wio-e5-mini_repeater`. The shared body is 212 B (RP2040) and 168 B (STM32) against 292 and 244 for the old ones | +| ESP32-C6 servers (pioarduino) | -64 and -70 | `.flash.text` -80, `.flash.rodata` +16 on the repeater; the unwind tables (`.eh_frame` +64) are not part of the flash figure | +| Companion, nRF52 (RAK 4631, ProMicro) | +0 | No section differs at all | +| Companion, ESP32-S3 (Heltec v3 x2) | +48 | `.flash.text` is byte for byte the same size; only `.flash.rodata` moves, which is layout, as in 0004: `ServerMesh` is not in a companion image | + +The pattern in the table is the expected one for a move that routes the +role-specific parts through three new virtual calls: a shared body that comes out +bigger or smaller depending on how the compiler handles it, plus 24 B of vtable per +image. The `+160` to `+208` on nRF52 is the one a reviewer should +look at; it is a one time cost of 0.04 percent of a 440 kB image. + +### Other examples that compile `ServerMesh.cpp` + +`helpers/*.cpp` is in the base source filter of every firmware environment, so +`ServerMesh.cpp` is compiled for the kiss modem and the terminal chat too, and this +step added `board.attachDynamicPrefs()` and `sensors.setSettingValue()` to it. +Whether those compile for every variant's board was checked in two ways. + +First by argument: every one of the 91 variant directories with a `-I variants/` +flag in some environment has at least one environment that compiles a server role or +the companion, and both already call `board.attachDynamicPrefs()` on that variant's +board, so the board types are proven. (Computed from `pio project config +--json-output`.) + +Second by building one kiss modem or terminal chat environment per platform on `dev`, +4e and 4f. All 27 builds succeeded. + +| environment | Flash dev | Flash 4e | Flash 4f | 4f vs 4e | RAM 4f vs 4e | +|---|---|---|---|---|---| +| `RAK_4631_kiss_modem` | 371560 | 371560 | 371560 | +0 | +0 | +| `Heltec_v3_kiss_modem` | 579993 | 580561 | 580609 | +48 | +0 | +| `PicoW_kiss_modem` | 439420 | 439420 | 439420 | +0 | +0 | +| `wio-e5-mini_kiss_modem` | 151032 | 151032 | 151032 | +0 | +0 | +| `M5Stack_Unit_C6L_kiss_modem` | 479807 | 479807 | 479807 | +0 | +0 | +| `PicoW_terminal_chat` | 468108 | 468908 | 468908 | +0 | +0 | +| `ProMicro_terminal_chat` | 295312 | 298112 | 298112 | +0 | +0 | +| `Heltec_v3_terminal_chat` | 600685 | 601517 | 601565 | +48 | +0 | +| `RAK_4631_terminal_chat` | 393416 | 393960 | 393960 | +0 | +0 | + +Step 4f changes nothing in seven of the nine and moves the two ESP32-S3 ones by +48, the same layout shift as in the companion. Against `dev` the terminal chat +environments are larger by 544 to 2800 B and the ESP32-S3 kiss modem by 568 B, already +at 4e. That comes from the steps before this one; these environments were not part of +the series matrix. **Not examined and not explained** here; it is outside this step +and listed under "What was not verified". + +### The debug line + +`onRadioConfigured()` keeps the repeater's one debug print. Built with `-D MESH_DEBUG=1` +for `RAK_4631_repeater`, `RAK_4631_room_server` and `RAK_4631_sensor` at 4e and at 4f, +(nRF52 only) the sorted set of strings containing `DEBUG: ` or `Boosted` is identical in each pair +(117, 112 and 103 strings, empty diff), `RX Boosted Gain Mode` occurs once in the +repeater image and not in the room server or the sensor, before and after. The +room server and the sensor did not gain a log line. + +## What was not verified + +- **No board test.** The host environments model the boot sequence, not the radio, the + board or the flash. See the next section. +- **GCC on Linux.** Only the CI runs the tests under GCC; locally there is a syntax + check with three arm GCC versions, see above. +- **The debug line on the host for the room server and the sensor.** Their host + environments do not define `MESH_DEBUG`; the firmware string comparison above is the + evidence that they print nothing new. +- **The effect of the budget ordering** (ledger issue 13) is read from the code. +- **The unattributed bytes** named in the flash table: 40 to 72 B of rodata on + ESP32-S3 and a 24 B residual on one nRF52 environment. +- **The growth of the terminal chat and kiss modem images** before this step. +- **Variants other than those built.** Argued from the variant directories, built for + the 22 + 9 environments above, not for all 626. + +## Board test (not done, for whoever runs it) + +`techo-b43b` is reserved for the sensor alignment test and was not touched. A board +test of this step is a before and after on one board with the same stored settings: + +- **Images.** Two per role, same toolchain, marked with + `PLATFORMIO_BUILD_FLAGS='-D FIRMWARE_BUILD_DATE=\"\"'` so `ver` shows which + one runs: the 4e commit (`0a93bc40`) and the 4f commit (`133fe68b`). The T-Echo has + `LilyGo_T-Echo_repeater` and `LilyGo_T-Echo_room_server`; there is no T-Echo sensor + environment in the tree, so a sensor test needs the image the sensor alignment test + uses, built at both commits. +- **What to compare.** Set non-default values first (a small TX power change, the two + advert intervals, the ADC multiplier; no frequency change), reboot, and read them + back (`get tx`, `get radio`, `get advert.interval`, `get flood.advert.interval`, + `get adc.multiplier`, `get radio.rxgain` on an SX126x board). The reads must be the + same on both images. Then check that the first scheduled advert comes after the + stored interval, and that the file list on flash is the same before and after + (`ls`), since booting must write nothing. +- **Upgrade and downgrade.** 4e to 4f and back, with the values set on the first. This + step changes no persisted format, so the files must carry over untouched. +- **What only a board shows.** That the real `board.attachDynamicPrefs()`, the radio + driver's `setRxBoostedGainMode()` and `setAdcMultiplier()` run in the same order on + the real driver, which the host mocks record but cannot prove for hardware. + +## Next candidates + +- **`allowPacketForward()`.** The leading `disable_fwd` check is the same in all three + roles. The room server's and the sensor's whole bodies are identical only once the + flood limit alignment (`refactor/sensor-flood-limits`) is on the branch; today the + sensor checks `flood_max` alone. Not touched here. +- **The `CommonCLI _cli` member.** Constructed with the same arguments in all three + roles. Moving it into the base removes `loadPrefs()` and the three copies of + `savePrefs()`, at the price of the board in `ServerMesh`'s constructor. +- **Sharing the gain log line**, if it is wanted in the room server and the sensor. + diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index acdc1cebe22..be3cb611817 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -50,9 +50,10 @@ The full analysis, with file and line references for every finding, is in | 1 | Share the radio and GPS settings between the server roles' and the companion's NodePrefs | [0001](0001-shared-radio-gps-prefs.md) | ready for review | | 2 | Let the core read its tuning values from the radio settings instead of a getter per role | [0002](0002-core-reads-radio-prefs.md) | ready for review | | 3 | Drive the radio settings (JSON, CLI, key/value store) from one table | [0003](0003-radio-settings-table.md) | ready for review | +| 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4f; the boot sequence is the last) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md) | ready for review | Planned next steps (each needs its own design note before code), in order of -expected benefit: a shared base for repeater, room server and sensor; one generic variant target +expected benefit: one generic variant target built from a board description; one RadioLib adapter per chip family; a companion frame codec with a command table. See the review for the reasoning. diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md index fa36f01a9c1..e1159e9ed86 100644 --- a/docs/refactoring/architecture-review-v1.18.md +++ b/docs/refactoring/architecture-review-v1.18.md @@ -91,6 +91,14 @@ the upper bound is exclusive: the sensor draws from the six multiples 0 to 5 tim `+ 1` of `96e786fa` exists because `t == 0` made the bound 0 and `num % 0` crashed, which the sensor's form could not do. +Status: the shared plumbing is in `ServerMesh` since the steps 4a to 4f, the last of +which moved the boot sequence ([0007](0007-boot-sequence.md)). Two candidates are left +that were not part of the cut: the `CommonCLI _cli` member, constructed with +identical arguments in all three roles, and `allowPacketForward()`, whose `disable_fwd` +check is the same in all three and whose whole body is identical in the room server +and the sensor only once the parked flood limit alignment +(`refactor/sensor-flood-limits`) lands; today the sensor still checks `flood_max` alone. + ### F3. Board variants are near copies 89 `variants/*/target.cpp` files; `radio_new_identity()` is identical (ignoring @@ -121,7 +129,9 @@ roles themselves since the harness of step 4: `test/mocks` now carries an in-mem file system, an RTC and the globals a variant's `target.h` declares, so all three role implementations compile natively and `native_server` drives the real room server. The gap that is left is the deeper request handling, which needs packets and -a populated ACL. +a populated ACL. Step 4f added `native_repeater`, so all three server roles now run on +the host, and `native_repeater_full`, which compiles in the bridge, GPS and +configured-scope blocks of `begin()` as well. ## Issues noticed while reading (not part of this series) @@ -142,3 +152,4 @@ issue or fix. | 10 | The two flood route types disagree about a denied Region. `onRecvPacket()` checks `REGION_DENY_FLOOD` itself for an un-scoped flood, while a scoped flood goes through `RegionMap::findMatch()`, whose transport-code-0 shortcut returns the wildcard before the mask is applied. So denying flood on the wildcard stops an un-scoped flood but not a scoped one carrying no code. (Found during step 4a, pinned in test_server_base) | `src/helpers/ServerMesh.cpp`, `src/helpers/RegionMap.cpp:findMatch()` | | 11 | `setRxBoostedGain()` is a virtual nobody calls: a default in `helpers/CommonCLI.h`, an override in the repeater and in the room server, and no caller anywhere. The only `radio.rxgain` handler reaches the radio driver directly, and the settings table lists the field as `CLI_NONE`, which the generic loop skips. Two dead overrides. (Found during the sensor alignment) | `src/helpers/CommonCLI.h`, `examples/simple_repeater/MyMesh.cpp`, `examples/simple_room_server/MyMesh.cpp` | | 12 | A hop limit of zero is written to `prefs.json` as an explicit `0` and applied verbatim on load, so `set flood.max.unscoped 0` persists and the constructor default cannot protect against it. Since the limits are compared with `>=`, zero means "never forward", which is what upstream's own comment describes repeaters as running. Combined with issue 7 (no range checking on `/prefs.json`) this is how a node can boot into a state that silently forwards nothing. Measured in `test_sensor_role` | `src/helpers/ConfigSerializer.cpp`, `src/helpers/RoutingPolicy.h` | +| 13 | `Dispatcher::begin()` runs before the prefs are loaded, so the transmit budget starts at the size the constructor's airtime factor gives (1.0, half of the one hour window), not the stored one. Every budget decision calls `updateTxBudget()` first, which caps it at the stored factor's maximum or refills it at the stored rate, so this is a start-up level and probably harmless. Read from the code, not measured on a board. (Found while pinning the boot sequence in step 4f; pinned in `BootSequenceChecks.h`) | `src/Dispatcher.cpp:19-31`, `src/helpers/ServerMesh.cpp` | From 3e68578558d82f68b8037f02d7bbe2173f6a4003 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 14:47:27 +0200 Subject: [PATCH 080/136] docs(refactoring): record the hardware check of the sensor alignment Note 0005 said the hardware check was not done. It is now, on a T-Echo running the sensor role, with marked images of ae0b9f22 and of its first parent cc1e6c3b: upgrade from the original 1.17.1, upgrade base to new, downgrade new to base and back to the original, all settings identical across each step. With the RX gain stored as off, the radio register reads boosted again after a reboot on base and stays off on new, and a stored multi.acks only takes effect on new. Details and the six steps are in the note. The diagnostic patch that made this visible goes next to the other diag-*.patch files, with its README section. It is test scaffolding and not part of any step. Two findings from the check go into the ledger: the CLI accepts any multi.acks value while the loader clamps it to 0..1 (issue 13), and a soft reset can leave the I2C bus stuck so that every role hangs in the RTC probe at boot (issue 14). --- docs/refactoring/0005-sensor-alignment.md | 32 +++++++++++-- docs/refactoring/architecture-review-v1.18.md | 2 + tools/refactoring/README.md | 29 ++++++++++++ .../refactoring/diag-sensor-rxgain-acks.patch | 47 +++++++++++++++++++ 4 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 tools/refactoring/diag-sensor-rxgain-acks.patch diff --git a/docs/refactoring/0005-sensor-alignment.md b/docs/refactoring/0005-sensor-alignment.md index ca3e9ce9ef2..6d45567a25c 100644 --- a/docs/refactoring/0005-sensor-alignment.md +++ b/docs/refactoring/0005-sensor-alignment.md @@ -98,8 +98,30 @@ flood behaviour is untouched on this branch. - `pio test` over all six environments: 123 of 123 test cases pass. (The branch that also carries the flood limits has 127; the four extra cases are its own.) -- Hardware: **not done.** `techo-b43b` is the sensor board and is not to be claimed - until the F1 to F4 fixes are merged. What it should cover for this change: that a - stored `radio.rxgain` really survives a reboot on an SX1262 board, which the host - environment cannot show because it defines neither `USE_SX1262` nor - `SX126X_RX_BOOSTED_GAIN`. +- Hardware: done on `techo-b43b` (LilyGo T-Echo, SX1262, sensor role), with marked + images of the merge into `integration` (`ae0b9f22`, "new") and of its first parent + (`cc1e6c3b`, "base"), both built with + `tools/refactoring/diag-sensor-rxgain-acks.patch`. `get radio.rxgain` only reads the + stored pref, so the patch adds `diag sensor`, which prints the pref, the radio's RX + gain register (`CustomSX1262::getRxBoostedGainMode()`) and + `getExtraAckTransmitCount()`. The T-Echo turns boosted gain on during radio init + (`SX126X_RX_BOOSTED_GAIN=1`), so only a stored `off` shows the difference. Values + below are pref / register / extra ACKs. + 1. Original 1.17.1 to base: all 30 settings identical, `1 / 1 / 0`. + 2. Base, `set radio.rxgain off` and `set multi.acks 1`: `0 / 0 / 0` at once, and + `0 / 1 / 0` after a reboot. The stored gain is lost at boot and `multi.acks` has + no effect: the two defects this change removes. + 3. Base to new: all settings identical, `0 / 0 / 1`. + 4. New, `set radio.rxgain on` and reboot: `1 / 1`. Then `set radio.rxgain off`, + `set multi.acks 0` and reboot: `0 / 0 / 0`. + 5. New to base (downgrade): all settings identical, `0 / 1 / 0`, the old boot + behaviour again. + 6. Back to the original 1.17.1 with the original values: 31 of 31 lines identical to + the reading taken before the check. + + Two findings on the way, neither caused by this change (ledger issues 13 and 14): + `set multi.acks 2` is accepted, but the range check after loading `/prefs.json` + clamps the field to 0..1, so it reads back as 1 after a reboot. And a soft reset + sometimes leaves this board's I2C bus with SDA held low, after which every role + blocks in the RTC probe before any radio or mesh code runs; the diagnostic patch + frees the bus before `board.begin()` so the check could run at all. diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md index fa36f01a9c1..203783c4b6f 100644 --- a/docs/refactoring/architecture-review-v1.18.md +++ b/docs/refactoring/architecture-review-v1.18.md @@ -142,3 +142,5 @@ issue or fix. | 10 | The two flood route types disagree about a denied Region. `onRecvPacket()` checks `REGION_DENY_FLOOD` itself for an un-scoped flood, while a scoped flood goes through `RegionMap::findMatch()`, whose transport-code-0 shortcut returns the wildcard before the mask is applied. So denying flood on the wildcard stops an un-scoped flood but not a scoped one carrying no code. (Found during step 4a, pinned in test_server_base) | `src/helpers/ServerMesh.cpp`, `src/helpers/RegionMap.cpp:findMatch()` | | 11 | `setRxBoostedGain()` is a virtual nobody calls: a default in `helpers/CommonCLI.h`, an override in the repeater and in the room server, and no caller anywhere. The only `radio.rxgain` handler reaches the radio driver directly, and the settings table lists the field as `CLI_NONE`, which the generic loop skips. Two dead overrides. (Found during the sensor alignment) | `src/helpers/CommonCLI.h`, `examples/simple_repeater/MyMesh.cpp`, `examples/simple_room_server/MyMesh.cpp` | | 12 | A hop limit of zero is written to `prefs.json` as an explicit `0` and applied verbatim on load, so `set flood.max.unscoped 0` persists and the constructor default cannot protect against it. Since the limits are compared with `>=`, zero means "never forward", which is what upstream's own comment describes repeaters as running. Combined with issue 7 (no range checking on `/prefs.json`) this is how a node can boot into a state that silently forwards nothing. Measured in `test_sensor_role` | `src/helpers/ConfigSerializer.cpp`, `src/helpers/RoutingPolicy.h` | +| 13 | `set multi.acks` accepts any byte (the settings table gives it no maximum), but the range check after loading `/prefs.json` clamps the field to 0..1. A stored `2` therefore reads back as `1` after a reboot, and the CLI never says so. The CLI and the loader should agree on one range. (Found in the hardware check of the sensor alignment) | `src/helpers/CommonRadioPrefs.cpp` (`multi_ack` row), `src/helpers/CommonCLI.cpp` (`sanitisePrefs()`) | +| 14 | After a soft reset (serial DFU, `reboot`) the I2C bus is sometimes left with SDA held low by a device that was interrupted mid transfer, and the next boot blocks for good in the RTC probe, before any radio or mesh code runs. Measured on a T-Echo: without freeing the bus 4 of 4 boots stopped there; with nine clock pulses before `Wire.begin()` SDA was low in 2 of 3 boots, one pulse released it and all 3 boots completed. Since the blocked probe is itself mid transfer, the next soft reset tends to leave the bus stuck again, so only a power cycle breaks the loop. (Found in the hardware check of the sensor alignment) | `src/helpers/AutoDiscoverRTCClock.cpp` (`i2c_probe()`), the variants' `Wire.begin()` | diff --git a/tools/refactoring/README.md b/tools/refactoring/README.md index b069d8d282e..7b1e89ce17d 100644 --- a/tools/refactoring/README.md +++ b/tools/refactoring/README.md @@ -45,6 +45,35 @@ git apply tools/refactoring/diag-tuning.patch The patch is for testing only and is not part of any step. +## Diagnostic build for the sensor alignment + +`diag-sensor-rxgain-acks.patch` adds `diag sensor` to the sensor role. It prints the +stored RX boosted gain, the gain the radio really runs with (read back from the +SX126x register) and the extra ACK count the core uses, because `get radio.rxgain` +only shows the stored value. It also makes two boot changes that only serve the +check: `setup()` waits up to 15 s for the host to open the serial port, so the boot +output is not lost, and it frees an I2C bus that a soft reset left with SDA held low +(see ledger issue 14), since the board otherwise hangs in the RTC probe. The T-Echo +has no sensor environment in the tree. The check used one like this, appended to +`variants/lilygo_techo/platformio.ini`; the board's own build also sets its admin +password and `DEFAULT_FLOOD_SCOPE_NAME`, which only take effect on a board without +stored prefs: + +```ini +[env:LilyGo_T-Echo_sensor] +extends = LilyGo_T-Echo +build_src_filter = ${LilyGo_T-Echo.build_src_filter} + +<../examples/simple_sensor> +build_flags = + ${LilyGo_T-Echo.build_flags} + -D ADVERT_NAME='"T-Echo Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' +``` + +The patch is for testing only and is not part of any step. + ## Restoring a board Since v1.17 every role reads `/prefs.json`. Flashing the old firmware back keeps diff --git a/tools/refactoring/diag-sensor-rxgain-acks.patch b/tools/refactoring/diag-sensor-rxgain-acks.patch new file mode 100644 index 00000000000..473557b7c39 --- /dev/null +++ b/tools/refactoring/diag-sensor-rxgain-acks.patch @@ -0,0 +1,47 @@ +diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp +index 75fa8b74..62444ed8 100644 +--- a/examples/simple_sensor/SensorMesh.cpp ++++ b/examples/simple_sensor/SensorMesh.cpp +@@ -376,6 +376,11 @@ uint8_t SensorMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* + } + + void SensorMesh::handleCommand(ClientInfo* from, uint32_t sender_timestamp, char* command, char* reply) { ++ if (strcmp(command, "diag sensor") == 0) { // diagnostic build only, not part of any commit ++ sprintf(reply, "> rxgain_pref=%d rxgain_chip=%d acks=%d", (int)_prefs.rx_boosted_gain, ++ (int)radio_driver.getRxBoostedGainMode(), (int)getExtraAckTransmitCount()); ++ return; ++ } + if (region_load_active) { + if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation + region_map = temp_map; // copy over the temp instance as new current map +diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp +index 66991daa..06184d3d 100644 +--- a/examples/simple_sensor/main.cpp ++++ b/examples/simple_sensor/main.cpp +@@ -55,7 +55,25 @@ static char command[160]; + void setup() { + Serial.begin(115200); + delay(1000); +- ++ while (!Serial && millis() < 15000) delay(10); // diagnostic build only: boot output needs the host ++ ++ { // diagnostic build only: free an I2C bus a soft reset left with SDA held low ++ pinMode(PIN_WIRE_SDA, INPUT_PULLUP); pinMode(PIN_WIRE_SCL, INPUT_PULLUP); delay(2); ++ int sda = digitalRead(PIN_WIRE_SDA), pulses = 0; ++ if (sda == LOW) { ++ pinMode(PIN_WIRE_SCL, OUTPUT); ++ while (pulses < 9 && digitalRead(PIN_WIRE_SDA) == LOW) { ++ digitalWrite(PIN_WIRE_SCL, LOW); delayMicroseconds(10); ++ digitalWrite(PIN_WIRE_SCL, HIGH); delayMicroseconds(10); ++ pulses++; ++ } ++ pinMode(PIN_WIRE_SDA, OUTPUT); digitalWrite(PIN_WIRE_SDA, LOW); delayMicroseconds(10); ++ digitalWrite(PIN_WIRE_SCL, HIGH); delayMicroseconds(10); ++ digitalWrite(PIN_WIRE_SDA, HIGH); delayMicroseconds(10); ++ pinMode(PIN_WIRE_SDA, INPUT_PULLUP); pinMode(PIN_WIRE_SCL, INPUT_PULLUP); delay(2); ++ } ++ Serial.printf("DIAG i2c sda_at_boot=%d pulses=%d sda_after=%d\n", sda, pulses, digitalRead(PIN_WIRE_SDA)); ++ } + board.begin(); + + #ifdef HAS_EXTERNAL_WATCHDOG From 9e39d32e85d0de8746d21742b5f4985f3d7e3894 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 15:07:20 +0200 Subject: [PATCH 081/136] docs(refactoring): record the board test of the boot sequence Note 0007 left the board test open. The sensor role part is done now, on the T-Echo used for the sensor alignment, with marked images of the merge 4cf3161b and its first parent 3e685785: upgrade from the original 1.17.1, a stored RX gain of off and multi.acks 1, upgrade to the moved boot sequence, a reboot on it, the downgrade and the way back to the original. Every reading was identical across each step, and the RX gain register showed the stored value after every boot of both images. The repeater, the room server and the non-default values the note's plan asks for are still open and are listed as such. --- docs/refactoring/0007-boot-sequence.md | 34 +++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/refactoring/0007-boot-sequence.md b/docs/refactoring/0007-boot-sequence.md index 78f54c859f0..7374053c50f 100644 --- a/docs/refactoring/0007-boot-sequence.md +++ b/docs/refactoring/0007-boot-sequence.md @@ -362,10 +362,36 @@ room server and the sensor did not gain a log line. - **Variants other than those built.** Argued from the variant directories, built for the 22 + 9 environments above, not for all 626. -## Board test (not done, for whoever runs it) - -`techo-b43b` is reserved for the sensor alignment test and was not touched. A board -test of this step is a before and after on one board with the same stored settings: +## Board test + +### Done, sensor role only + +Run on `techo-b43b` (LilyGo T-Echo, SX1262, sensor role) after the merge into +`integration`, with marked images of the merge commit (`4cf3161b`, "4f") and of its +first parent (`3e685785`, "before"), both built with +`tools/refactoring/diag-sensor-rxgain-acks.patch` for `diag sensor` (stored RX gain, +the radio's RX gain register, extra ACK count) and for freeing a stuck I2C bus at boot +(ledger issue 14). Each step read all 30 server settings plus `region default` and +`diag sensor`: + +1. Original 1.17.1 to "before": all settings identical. +2. "Before", `set radio.rxgain off`, `set multi.acks 1`, reboot: the register reads + off and one extra ACK is used, as the sensor alignment intends. +3. "Before" to "4f": every reading identical, the register still off. +4. Reboot on "4f": identical to step 3, so the moved boot path reproduces itself. +5. "4f" to "before" (downgrade): identical. +6. Back to the original 1.17.1 with the original values: 31 of 31 lines identical to + the reading taken before. + +So the moved `begin()` loads the same prefs and pushes the stored RX gain to the radio +on real hardware. The I2C bus was found stuck at two of these boots and freed by the +diagnostic patch; that is ledger issue 14 and independent of this step. + +### Not covered yet + +The repeater and the room server were not run on a board, and the sensor run used +default values for everything but the RX gain and `multi.acks`. The rest of the plan +below is still open: - **Images.** Two per role, same toolchain, marked with `PLATFORMIO_BUILD_FLAGS='-D FIRMWARE_BUILD_DATE=\"\"'` so `ver` shows which From 2e456f741a959f9111e6c104ae91564644ce7314 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 15:15:07 +0200 Subject: [PATCH 082/136] test(repeater): check that a denied wildcard stops a code 0 scoped flood The fix for ledger issue 10 was tested on RegionMap and on the room server's Region resolution. Only the repeater turns a missing Region into a dropped packet (allowPacketForward()), and until the boot sequence step there was no host harness for the repeater role, so that end was read from the source only. Three cases run the real onRecvPacket() and then ask the real allowPacketForward() of the repeater: a code 0 scoped flood is forwarded while the wildcard allows flood (the control, so nothing else in the gate stops these packets), and both an un-scoped flood and a code 0 scoped flood are dropped once the wildcard denies flood. Measured on integration 9e39d32e without the fix: the control and the un-scoped case pass, the code 0 case fails (test_region_forwarding.cpp, in native_repeater and native_repeater_full). With the fix merged all three pass, and all thirteen native environments give 267 of 267. --- .../test_region_forwarding.cpp | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 test/test_repeater_role/test_region_forwarding.cpp diff --git a/test/test_repeater_role/test_region_forwarding.cpp b/test/test_repeater_role/test_region_forwarding.cpp new file mode 100644 index 00000000000..38b88f11866 --- /dev/null +++ b/test/test_repeater_role/test_region_forwarding.cpp @@ -0,0 +1,100 @@ +// Whether the repeater forwards a flood depends on the Region the packet resolves to: +// allowPacketForward() drops a flood that arrived on no Region. These tests let the +// real resolution in onRecvPacket() run and then ask the real gate, so they cover the +// repeater's end of ledger issue 10 (a scoped flood with transport code 0 and the +// wildcard's flood denial), which the room server suite in test_server_base cannot, +// because only the repeater turns a missing Region into a dropped packet. + +#include + +#include +#include + +#include +#include +#include +#include + +#include "MyMesh.h" + +namespace { + +constexpr uint32_t START_MILLIS = 1000; + +class ForwardProbe : public MyMesh { +public: + using MyMesh::MyMesh; + + RegionMap& regions() { return region_map; } + RegionEntry* seenRegion() const { return recv_pkt_region; } + mesh::DispatcherAction recv(mesh::Packet* pkt) { return onRecvPacket(pkt); } + bool forwards(const mesh::Packet* pkt) { return allowPacketForward(pkt); } +}; + +class RepeaterRegionForwardingTest : public ::testing::Test { +protected: + fs::FS fs; + ArduinoMillis ms; + StdRNG rng; + SimpleMeshTables tables; + std::unique_ptr node; + + void SetUp() override { + g_mock_millis = START_MILLIS; + board.reset(); + radio_driver.reset(); + rtc_clock.now = 1760000000; + fs.reset(); + rng.begin(1); + + node.reset(new ForwardProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + node->begin(&fs); + } + + // A flood with an empty path and transport code 0: nothing but the route type for + // the Region lookup to read, and nothing for the hop limits or the loop detection + // to object to. + static mesh::Packet floodOfRouteType(uint8_t route_type) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = route_type; + return pkt; + } + + // What the repeater does with the packet: resolve its Region, then decide. + bool forwardsAfterReceiving(mesh::Packet& pkt) { + node->recv(&pkt); + return node->forwards(&pkt); + } +}; + +TEST_F(RepeaterRegionForwardingTest, ForwardsAScopedFloodWithCodeZeroWhileTheWildcardAllowsFlood) { + // The control case: with flood allowed on the wildcard nothing else in the gate + // stops this packet, so the two cases below fail for the Region alone. + node->regions().getWildcard().flags &= ~REGION_DENY_FLOOD; + + mesh::Packet pkt = floodOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + EXPECT_TRUE(forwardsAfterReceiving(pkt)); + EXPECT_NE(node->seenRegion(), nullptr); +} + +TEST_F(RepeaterRegionForwardingTest, DropsAnUnscopedFloodWhenTheWildcardDeniesFlood) { + node->regions().getWildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = floodOfRouteType(ROUTE_TYPE_FLOOD); + EXPECT_FALSE(forwardsAfterReceiving(pkt)); + EXPECT_EQ(node->seenRegion(), nullptr); +} + +TEST_F(RepeaterRegionForwardingTest, DropsAScopedFloodWithCodeZeroWhenTheWildcardDeniesFlood) { + // `region denyf *` is documented to drop packets without a Region transport code. + // A scoped flood whose code is 0 carries none, so it must be dropped like the + // un-scoped flood above. + node->regions().getWildcard().flags |= REGION_DENY_FLOOD; + + mesh::Packet pkt = floodOfRouteType(ROUTE_TYPE_TRANSPORT_FLOOD); + EXPECT_FALSE(forwardsAfterReceiving(pkt)); + EXPECT_EQ(node->seenRegion(), nullptr); +} + +} // namespace From 3b58ebd7a6a131a9d3c2cd28d677b3a08e7e3c35 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 15:21:46 +0200 Subject: [PATCH 083/136] fix(sensor): apply the flood hop limits, and lift stored zero limits once What changes - SensorMesh::allowPacketForward() checked flood_max only and now calls mesh::isFloodHopLimitExceeded() like the repeater and the room server, so flood.max.unscoped and flood.max.advert take effect on the sensor too. After it, allowPacketForward() is the same text in the room server and the sensor. - The sensor's constructor sets those two limits to the values the other roles start with, 64 and 8. It set neither, so both sat at 0. - NodePrefs gains prefs_version, written as the first key of /prefs.json ("ver"). A file without it reads as version 0. NODE_PREFS_VERSION is 1. - ServerMesh::begin() calls a new hook, upgradePrefs(from), right after the prefs are loaded and only when the stored version is older than NODE_PREFS_VERSION. It then records the current version, and saves at once if the hook changed a value. Otherwise booting stays read only, as before. A newer stored version (after a downgrade) is left as it is. - The sensor overrides the hook: from version 0 it lifts a stored flood.max.unscoped of 0 to 64 and a stored flood.max.advert of 0 to 8. A value other than 0 is kept. The repeater and the room server change nothing. Why - The sensor drifted from the other two roles (architecture review, finding F2); this is the last part of aligning it. - Applying the limits alone would break forwarding sensors on update. Because the sensor never set the two limits, every sensor that ever saved its settings stored f_max_uns:0 and f_max_adv:0. The loader applies a stored value verbatim, and the limits are compared with >=, so a zero refuses every un-scoped flood and every advert. A sensor with forwarding switched on would have stopped forwarding both, silently. Measured on the host, not assumed: a zero is written out as "0", it is loaded back as 0, and the node then refuses a fresh flood. - The range check on /prefs.json does not help: 0 is a legal value for these limits ("do not forward"), so there is nothing to clamp it into. - The version is what makes the lift run once. Without it, a zero an operator sets on purpose after the update would be lifted again at every boot. - Only sensors with forwarding switched on are affected at all: the sensor's constructor sets disable_fwd, as the room server's does. Alternatives - Ship it and tell operators to run "set flood.max.unscoped 64" after the update: rejected, a node whose operator misses the note stops forwarding without a sign. - Leave the sensor ignoring the two limits: rejected, it keeps the sensor apart from the other roles for good. - A one-off marker key for just these two fields instead of a version: rejected, the next change of meaning would need another one; a version takes any later step. - Upgrade inside CommonCLI::loadPrefs(): rejected, the CLI does not know the role, and only the role knows which stored values meant something else before. Compatibility - Older firmware ignores the unknown "ver" key (ConfigSerializer takes only the keys its structure() names). If it saves, the key is dropped, and a later upgrade runs the step again; for the sensor that is harmless, because the older firmware never applied these limits. Verification - Tests first. The forwarding cases and the three measurements of the hazard ran against the old code; the upgrade cases live in test/mocks/BootSequenceChecks.h so all three roles run them. With the version field in place but no upgrade, 7 tests failed (14 cases across the environments) where expected: the sensor lift, the version after boot and after save, the pinned prefs.json. - pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -e native_sensor -e native_repeater -e native_repeater_full: 251 of 251 cases pass. - Nine mutations against this change, each caught, each by a different set of tests; the table is in docs/refactoring/0006-sensor-flood-limits.md. Two survived the first round, each masked by the other guard (begin() asks only for older versions, the sensor's step only acts from version 0); one test for each guard on its own closed both. --- examples/simple_sensor/SensorMesh.cpp | 25 ++- examples/simple_sensor/SensorMesh.h | 4 + src/helpers/CommonNodePrefs.h | 7 + src/helpers/ServerMesh.cpp | 7 + src/helpers/ServerMesh.h | 10 ++ test/mocks/BootSequenceChecks.h | 152 ++++++++++++++++++ .../test_common_node_prefs.cpp | 20 ++- test/test_sensor_role/test_boot_sequence.cpp | 4 + test/test_sensor_role/test_sensor_role.cpp | 98 ++++++++++- 9 files changed, 319 insertions(+), 8 deletions(-) diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 66359bdb82d..c802e768215 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -314,10 +314,31 @@ void SensorMesh::alertIf(bool condition, Trigger& t, AlertPriority pri, const ch bool SensorMesh::allowPacketForward(const mesh::Packet* packet) { if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false; + if (packet->isRouteFlood() + && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { + return false; + } return true; } +// The hop limits the repeater and the room server start with. The constructor and +// the prefs upgrade both need them. +static const uint8_t DEFAULT_FLOOD_MAX_UNSCOPED = 64; +static const uint8_t DEFAULT_FLOOD_MAX_ADVERT = 8; + +bool SensorMesh::upgradePrefs(uint8_t from) { + bool changed = false; + if (from < 1) { + // Before version 1 the sensor ignored these two limits and set no default for + // them, so every saved file holds a zero that nobody chose and that never took + // effect. Applied as it is, a zero refuses every un-scoped flood and every advert. + // A value other than zero was set on purpose and is kept. + if (_prefs.flood_max_unscoped == 0) { _prefs.flood_max_unscoped = DEFAULT_FLOOD_MAX_UNSCOPED; changed = true; } + if (_prefs.flood_max_advert == 0) { _prefs.flood_max_advert = DEFAULT_FLOOD_MAX_ADVERT; changed = true; } + } + return changed; +} + uint32_t SensorMesh::getRetransmitDelay(const mesh::Packet* packet) { uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); return getRNG()->nextInt(0, 6)*t; @@ -736,6 +757,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.flood_advert_interval = 0; // disabled _prefs.disable_fwd = true; _prefs.flood_max = 64; + _prefs.flood_max_unscoped = DEFAULT_FLOOD_MAX_UNSCOPED; + _prefs.flood_max_advert = DEFAULT_FLOOD_MAX_ADVERT; _prefs.interference_threshold = 0; // disabled _prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on') diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index da4a9742e92..de002128bba 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -102,6 +102,10 @@ class SensorMesh : public ServerMesh { virtual int querySeriesData(uint32_t start_secs_ago, uint32_t end_secs_ago, MinMaxAvg dest[], int max_num) = 0; // for app to implement virtual bool handleCustomCommand(uint32_t sender_timestamp, char* command, char* reply) { return false; } + // ServerMesh hook for begin(). Protected rather than private, unlike loadPrefs(), + // so that a subclass can run the step on its own. + bool upgradePrefs(uint8_t from) override; + // Mesh overrides bool allowPacketForward(const mesh::Packet* packet) override; // The sensor keeps its own retransmit delays: it draws from six discrete steps diff --git a/src/helpers/CommonNodePrefs.h b/src/helpers/CommonNodePrefs.h index 48cd0720a28..f56ec6c199b 100644 --- a/src/helpers/CommonNodePrefs.h +++ b/src/helpers/CommonNodePrefs.h @@ -14,6 +14,11 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 +// The version of the layout and meaning of /prefs.json. A file without the key is +// version 0. ServerMesh::begin() brings an older file up to this version once, on the +// first boot of a firmware that knows a newer one; see ServerMesh::upgradePrefs(). +#define NODE_PREFS_VERSION 1 + /** * \brief Persisted settings of the server roles (repeater, room server, sensor), * stored as /prefs.json and edited through CommonCLI. @@ -51,6 +56,7 @@ class NodePrefs : public ConfigSerializer, public RadioPrefsFields, public GPSPr char owner_info[120]; uint8_t loop_detect = 0; uint8_t extra_sf[4]; + uint8_t prefs_version = 0; // 0 until loaded or upgraded, see NODE_PREFS_VERSION private: CommonRadioPrefs radio; @@ -115,6 +121,7 @@ class NodePrefs : public ConfigSerializer, public RadioPrefsFields, public GPSPr protected: void structure() override { + def("ver", prefs_version); def("name", node_name, sizeof(node_name)); def("pass", password, sizeof(password)); def("guest", guest_password, sizeof(guest_password)); diff --git a/src/helpers/ServerMesh.cpp b/src/helpers/ServerMesh.cpp index 1b03ebe4e36..9524020baf2 100644 --- a/src/helpers/ServerMesh.cpp +++ b/src/helpers/ServerMesh.cpp @@ -220,6 +220,13 @@ void ServerMesh::begin(FILESYSTEM* fs) { _fs = fs; // load persisted prefs loadPrefs(); + // A file from an older firmware is brought up to date once. Booting writes nothing + // unless a value changed, and a newer version (after a downgrade) is left alone. + if (_prefs.prefs_version < NODE_PREFS_VERSION) { + bool changed = upgradePrefs(_prefs.prefs_version); + _prefs.prefs_version = NODE_PREFS_VERSION; + if (changed) savePrefs(); + } acl.load(_fs, self_id); // TODO: key_store.begin(); region_map.load(_fs); diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h index 61df6600721..45af063b8eb 100644 --- a/src/helpers/ServerMesh.h +++ b/src/helpers/ServerMesh.h @@ -83,6 +83,16 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { */ virtual void loadPrefs() = 0; + /** + * \brief Bring prefs that an older firmware stored up to NODE_PREFS_VERSION. + * Called by begin() right after loadPrefs(), and only when the stored + * version is older; begin() then records the current version. + * \param from the stored version, 0 for a file written before versions existed + * \returns true if a value was changed, so begin() saves the result at once and + * the upgrade does not run again on the next boot + */ + virtual bool upgradePrefs(uint8_t from) { return false; } + /** * \brief Start the role's bridge, if it has one and it is enabled. Called after * everything is loaded and before the radio is configured. diff --git a/test/mocks/BootSequenceChecks.h b/test/mocks/BootSequenceChecks.h index 738b47f12b4..7e3eb54b977 100644 --- a/test/mocks/BootSequenceChecks.h +++ b/test/mocks/BootSequenceChecks.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -60,6 +61,13 @@ class BootProbe : public BootRole { } mutable bool asked = false; mutable float first_airtime_factor = -1.0f; + + // Which stored versions begin() asked the role to upgrade from. + bool upgradePrefs(uint8_t from) override { + upgrades_from.push_back(from); + return BootRole::upgradePrefs(from); + } + std::vector upgrades_from; }; class BootSequenceTest : public ::testing::Test { @@ -521,4 +529,148 @@ TEST_F(BootSequenceTest, ADisabledBridgeIsNotStarted) { #endif // WITH_BRIDGE +// ---- settings stored by an older firmware ----------------------------------- +// +// /prefs.json carries a version since the sensor alignment, so that a setting whose +// meaning changed can be brought up to date once, on the first boot of the newer +// firmware, and never again. See docs/refactoring/0006-sensor-flood-limits.md. A +// suite defines BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS if its role is the one +// whose stored zero limits were never applied before version 1. + +class StoredPrefsTest : public BootSequenceTest { +protected: + void putPrefs(const char* text) { + const std::string t(text); + fs.files["/prefs.json"] = std::vector(t.begin(), t.end()); + } + std::string prefsText() { + const auto& bytes = fs.files["/prefs.json"]; + return std::string(bytes.begin(), bytes.end()); + } +}; + +// A file from before version 1 that carries both limits at zero, with forwarding on. +static const char* const UNVERSIONED_ZERO_LIMITS = + "{repeat:{disable:0,f_max:64,f_max_uns:0,f_max_adv:0}}"; + +TEST_F(StoredPrefsTest, AFreshNodeIsAtTheCurrentVersion) { + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().prefs_version, NODE_PREFS_VERSION); + EXPECT_TRUE(fs.files.empty()) << "and booting still writes nothing"; +} + +TEST_F(StoredPrefsTest, AStoredFileGetsTheCurrentVersionWithTheNextSave) { + putPrefs("{repeat:{disable:0,f_max:64,f_max_uns:64,f_max_adv:8}}"); + + auto node = bootFresh(); + EXPECT_EQ(node->prefs().prefs_version, NODE_PREFS_VERSION); + + node->savePrefs(); + EXPECT_NE(prefsText().find("ver:" + std::to_string(NODE_PREFS_VERSION)), std::string::npos) + << prefsText(); +} + +TEST_F(StoredPrefsTest, AnOlderFileThatNeedsNothingIsNotRewrittenAtBoot) { + const char* text = "{repeat:{disable:0,f_max:64,f_max_uns:64,f_max_adv:8}}"; + putPrefs(text); + + bootFresh(); + + EXPECT_EQ(prefsText(), text) << "booting stays read only when nothing is upgraded"; +} + +TEST_F(StoredPrefsTest, TheRoleIsAskedToUpgradeOnlyAnOlderFile) { + putPrefs("{repeat:{f_max:64}}"); + EXPECT_EQ(bootFresh()->upgrades_from, std::vector({0})) << "once, from version 0"; + + putPrefs("{ver:1,repeat:{f_max:64}}"); + EXPECT_TRUE(bootFresh()->upgrades_from.empty()) << "not at the current version"; + + putPrefs("{ver:200,repeat:{f_max:64}}"); + EXPECT_TRUE(bootFresh()->upgrades_from.empty()) << "nor at a newer one"; +} + +TEST_F(StoredPrefsTest, ZeroLimitsStoredAtTheCurrentVersionAreKept) { + // A zero chosen on a firmware that applies the limits is deliberate, on every role. + putPrefs("{ver:1,repeat:{disable:0,f_max:64,f_max_uns:0,f_max_adv:0}}"); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 0); + EXPECT_EQ(node->prefs().flood_max_advert, 0); +} + +TEST_F(StoredPrefsTest, ANewerVersionIsLeftAsItIs) { + // A downgrade onto a file a later firmware wrote: nothing is upgraded, and the + // version is not lowered, so the later firmware does not run its steps twice. + putPrefs("{ver:200,repeat:{disable:0,f_max:64,f_max_uns:0,f_max_adv:0}}"); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().prefs_version, 200); + EXPECT_EQ(node->prefs().flood_max_unscoped, 0); +} + +#ifdef BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS + +TEST_F(StoredPrefsTest, UnversionedZeroHopLimitsAreLiftedToTheRoleDefaults) { + putPrefs(UNVERSIONED_ZERO_LIMITS); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 64); + EXPECT_EQ(node->prefs().flood_max_advert, 8); + EXPECT_EQ(node->prefs().disable_fwd, 0) << "nothing else is touched"; + EXPECT_EQ(node->prefs().flood_max, 64); +} + +TEST_F(StoredPrefsTest, TheLiftIsSavedWithTheVersionSoItRunsOnce) { + putPrefs(UNVERSIONED_ZERO_LIMITS); + + bootFresh(); + + const std::string text = prefsText(); + EXPECT_NE(text.find("ver:1"), std::string::npos) << text; + EXPECT_NE(text.find("f_max_uns:64,"), std::string::npos) << text; + EXPECT_NE(text.find("f_max_adv:8,"), std::string::npos) << text; +} + +TEST_F(StoredPrefsTest, OnlyAZeroIsLifted) { + putPrefs("{repeat:{disable:0,f_max:64,f_max_uns:5,f_max_adv:0}}"); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 5) << "an operator's value is kept"; + EXPECT_EQ(node->prefs().flood_max_advert, 8); +} + +TEST_F(StoredPrefsTest, AZeroSetAfterTheUpgradeSurvivesTheNextBoot) { + putPrefs(UNVERSIONED_ZERO_LIMITS); + { + auto node = bootFresh(); + node->prefs().flood_max_unscoped = 0; // what "set flood.max.unscoped 0" stores + node->savePrefs(); + } + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 0) << "the upgrade does not run a second time"; +} + +#else + +TEST_F(StoredPrefsTest, UnversionedZeroHopLimitsAreKept) { + // This role has always applied the limits, so a stored zero was a choice. + putPrefs(UNVERSIONED_ZERO_LIMITS); + + auto node = bootFresh(); + + EXPECT_EQ(node->prefs().flood_max_unscoped, 0); + EXPECT_EQ(node->prefs().flood_max_advert, 0); + EXPECT_EQ(prefsText(), UNVERSIONED_ZERO_LIMITS) << "and the file is not rewritten"; +} + +#endif // BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS + } // namespace boot_checks diff --git a/test/test_common_node_prefs/test_common_node_prefs.cpp b/test/test_common_node_prefs/test_common_node_prefs.cpp index 0bcb7bb4e01..a13ea92f96f 100644 --- a/test/test_common_node_prefs/test_common_node_prefs.cpp +++ b/test/test_common_node_prefs/test_common_node_prefs.cpp @@ -56,12 +56,30 @@ const char* kPrefsJson = } // namespace TEST(CommonNodePrefs, WritesTheKnownPrefsJson) { + // The v1.18.0 file with the prefs version in front, the only key added since. NodePrefs prefs; fillAll(prefs); + prefs.prefs_version = NODE_PREFS_VERSION; CaptureStream out; ASSERT_TRUE(prefs.saveSerial(out)); - EXPECT_EQ(kPrefsJson, out.text()); + EXPECT_EQ("{ver:" + std::to_string(NODE_PREFS_VERSION) + "," + std::string(kPrefsJson + 1), + out.text()); +} + +TEST(CommonNodePrefs, AFileWithoutAVersionReadsAsVersionZero) { + NodePrefs loaded; + ReplayStream in(kPrefsJson); + ASSERT_TRUE(loaded.loadSerial(in)); + EXPECT_EQ(loaded.prefs_version, 0) << "v1.18.0 wrote no version"; +} + +TEST(CommonNodePrefs, TheVersionIsReadBack) { + NodePrefs loaded; + ReplayStream in("{ver:7,repeat:{f_max:3}}"); + ASSERT_TRUE(loaded.loadSerial(in)); + EXPECT_EQ(loaded.prefs_version, 7); + EXPECT_EQ(loaded.flood_max, 3) << "and the keys after it as before"; } TEST(CommonNodePrefs, ReadsEveryPersistedFieldBack) { diff --git a/test/test_sensor_role/test_boot_sequence.cpp b/test/test_sensor_role/test_boot_sequence.cpp index 23ed6c31b51..25e2d2802a1 100644 --- a/test/test_sensor_role/test_boot_sequence.cpp +++ b/test/test_sensor_role/test_boot_sequence.cpp @@ -21,4 +21,8 @@ class BootSensor : public SensorMesh { using BootRole = BootSensor; +// Before prefs version 1 the sensor ignored the two hop limits and stored a zero +// for both, so a zero in an unversioned file was never chosen. +#define BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS + #include diff --git a/test/test_sensor_role/test_sensor_role.cpp b/test/test_sensor_role/test_sensor_role.cpp index f3fc918b931..e3277412c13 100644 --- a/test/test_sensor_role/test_sensor_role.cpp +++ b/test/test_sensor_role/test_sensor_role.cpp @@ -41,6 +41,7 @@ class TestSensor : public SensorMesh { uint32_t floodRetransmitDelay(const mesh::Packet* pkt) { return getRetransmitDelay(pkt); } uint32_t airtimeFor(int len) { return _radio->getEstAirtimeFor(len); } NodePrefs* prefs() { return getNodePrefs(); } + bool upgradeFrom(uint8_t version) { return upgradePrefs(version); } protected: void onSensorDataRead() override { reads++; } @@ -101,16 +102,101 @@ TEST_F(SensorRoleTest, MultiAcksTakesEffect) { EXPECT_EQ(mesh->extraAcks(), 0) << "and off still means off"; } -TEST_F(SensorRoleTest, OnlyFloodMaxIsAppliedToday) { +TEST_F(SensorRoleTest, TheUnscopedHopLimitIsApplied) { + // Was: the sensor looked at flood_max only, so it forwarded this while the other + // two roles stopped it. mesh->prefs()->disable_fwd = 0; mesh->prefs()->flood_max = 5; - mesh->prefs()->flood_max_unscoped = 1; // the other roles would stop at one hop - mesh->prefs()->flood_max_advert = 1; + mesh->prefs()->flood_max_unscoped = 1; + mesh->prefs()->flood_max_advert = 8; - mesh::Packet pkt = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + mesh::Packet over = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + EXPECT_FALSE(mesh->forwards(&over)) << "two hops is past the un-scoped limit of one"; - EXPECT_TRUE(mesh->forwards(&pkt)) - << "today the sensor forwards this, because it only looks at flood_max"; + mesh::Packet under = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + EXPECT_TRUE(mesh->forwards(&under)) << "and a fresh one still goes on"; +} + +TEST_F(SensorRoleTest, TheAdvertHopLimitIsApplied) { + mesh->prefs()->disable_fwd = 0; + mesh->prefs()->flood_max = 64; + mesh->prefs()->flood_max_unscoped = 64; + mesh->prefs()->flood_max_advert = 2; + + mesh::Packet advert = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + advert.header |= (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); + EXPECT_FALSE(mesh->forwards(&advert)) << "adverts have their own, lower ceiling"; + + mesh::Packet plain = floodPacketWithHops(ROUTE_TYPE_FLOOD, 2); + EXPECT_TRUE(mesh->forwards(&plain)) << "a non advert at the same hop count goes on"; +} + +TEST_F(SensorRoleTest, AZeroHopLimitIsWrittenOutAsAZero) { + // The first half of the upgrade hazard: if a zero were written as an empty value, + // the loader would leave the new default alone and there would be no hazard. + mesh->prefs()->flood_max_unscoped = 0; + mesh->prefs()->flood_max_advert = 0; + mesh->savePrefs(); + + ASSERT_TRUE(fs.exists("/prefs.json")); + const auto& bytes = fs.files["/prefs.json"]; + const std::string json(bytes.begin(), bytes.end()); + + EXPECT_NE(json.find("f_max_uns:0,"), std::string::npos) + << "a zero is written out as a zero, not left empty: " << json; +} + +TEST_F(SensorRoleTest, ASensorUpgradedOntoZeroHopLimitsKeepsForwarding) { + // The hazard this change had to answer. A sensor built before it set no default for + // the two limits, so it stored zero for both, and with forwarding switched on by an + // operator that is the state an updated node boots into. Zero means "never + // forward", because the limits are compared with >=. The file carries no prefs + // version, so the first boot lifts both zeros to the role defaults (see + // BootSequenceChecks.h for the upgrade itself). + const char* older = "{repeat:{disable:0,f_max:64,f_max_uns:0,f_max_adv:0}}"; + const std::string text(older); + fs.files["/prefs.json"] = std::vector(text.begin(), text.end()); + + TestSensor updated(board, radio_driver, ms, rng, rtc_clock, tables); + updated.begin(&fs); + + EXPECT_EQ(updated.prefs()->disable_fwd, 0) << "the operator had forwarding on"; + + mesh::Packet fresh_flood = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + EXPECT_TRUE(updated.forwards(&fresh_flood)) + << "an upgraded node goes on forwarding un-scoped floods"; + + mesh::Packet advert = floodPacketWithHops(ROUTE_TYPE_FLOOD, 0); + advert.header |= (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); + EXPECT_TRUE(updated.forwards(&advert)) << "and adverts"; +} + +TEST_F(SensorRoleTest, TheHopLimitStepIsOnlyForFilesBeforeVersionOne) { + // begin() only asks for an upgrade from an older version, but the step must hold + // on its own: from version 1 on, a zero was chosen on a firmware that applies it. + mesh->prefs()->flood_max_unscoped = 0; + mesh->prefs()->flood_max_advert = 0; + + EXPECT_FALSE(mesh->upgradeFrom(1)); + EXPECT_EQ(mesh->prefs()->flood_max_unscoped, 0); + EXPECT_EQ(mesh->prefs()->flood_max_advert, 0); + + EXPECT_TRUE(mesh->upgradeFrom(0)) << "and from version 0 it does lift them"; + EXPECT_EQ(mesh->prefs()->flood_max_unscoped, 64); +} + +TEST_F(SensorRoleTest, AMissingKeyLeavesTheNewDefaultStanding) { + // The other half of the upgrade picture: a key absent from the file overwrites + // nothing, so a node whose file predates the key gets the new default. + const char* without = "{repeat:{disable:0,f_max:64}}"; + const std::string text(without); + fs.files["/prefs.json"] = std::vector(text.begin(), text.end()); + + TestSensor updated(board, radio_driver, ms, rng, rtc_clock, tables); + updated.begin(&fs); + + EXPECT_EQ(updated.prefs()->flood_max_unscoped, 64); + EXPECT_EQ(updated.prefs()->flood_max_advert, 8); } TEST_F(SensorRoleTest, FloodMaxItselfIsApplied) { From 4fdb7f875049e20e0d71dc0d65e4cc3035715b57 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 17:46:33 +0200 Subject: [PATCH 084/136] test(radiolib): characterize the receive detection of the chip classes Seven chip classes in src/helpers/radiolib (SX1262, SX1268, LLCC68, STM32WLx, LR1110, LR1121, LR2021) each carry their own copy of isReceiving(), the state machine that tells the dispatcher a packet is on the air: a preamble counts for preambleMillis, a valid header for maxPayloadMillis, a header error ends it, and the IRQ bits are cleared when a deadline passes. The copies differ only in the names of the IRQ bits and in the set of bits they clear. Pin that behaviour on the host before the copies are merged: one typed test suite runs the same 16 expectations against all seven real chip classes (112 cases), built against a stand-in for the RadioLib chip classes whose IRQ bit values are copied from RadioLib 7.7.1. Covered: both deadlines including the deadline millisecond itself, which bits each chip clears, the header error, a header flag cleared elsewhere, the setters, the millis() wrap, the quirk that a timer value of 0 means "not started", and the IRQ flags startReceive() asks for. New environment native_radio_chips; native ignores the suite. --- platformio.ini | 24 ++ test/test_radio_chips/stubs/RadioLib.h | 141 +++++++++++ test/test_radio_chips/test_radio_chips.cpp | 262 +++++++++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 test/test_radio_chips/stubs/RadioLib.h create mode 100644 test/test_radio_chips/test_radio_chips.cpp diff --git a/platformio.ini b/platformio.ini index 1cce9190f0c..f64fafa7fe1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -174,6 +174,7 @@ test_ignore = test_identity_store test_node_prefs test_radio_wrappers + test_radio_chips test_server_base test_sensor_role test_repeater_role @@ -465,3 +466,26 @@ build_src_filter = +<../examples/kiss_modem/KissModem.cpp> lib_deps = google/googletest @ 1.17.0 + +[env:native_radio_chips] +; The real chip classes in src/helpers/radiolib against a host stand-in for the +; RadioLib chip classes (test/test_radio_chips/stubs, listed first so it takes the +; place of the PhysicalLayer mock), so their receive detection can be checked on +; the host. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/test_radio_chips/stubs + -I src + -I test/mocks + -D LORA_FREQ=869.525 + -D LORA_BW=250 + -D LORA_SF=11 + -D LORA_TX_POWER=22 + -D LR2021_IRQ_DIO=8 +test_build_src = yes +test_filter = test_radio_chips +build_src_filter = + -<*> +lib_deps = + google/googletest @ 1.17.0 diff --git a/test/test_radio_chips/stubs/RadioLib.h b/test/test_radio_chips/stubs/RadioLib.h new file mode 100644 index 00000000000..c8f54074683 --- /dev/null +++ b/test/test_radio_chips/stubs/RadioLib.h @@ -0,0 +1,141 @@ +#pragma once + +// Host stand-in for the RadioLib chip classes that src/helpers/radiolib/Custom*.h +// derive from, so the real chip classes can be compiled and their receive +// detection exercised on the host. It is listed before test/mocks for this suite +// only, and replaces the PhysicalLayer-only mock there. +// +// The IRQ bit values are copied from RadioLib 7.7.1, the version the firmware +// builds against, so a test that names a bit means the bit the chip reports. +// Every call the chip classes make that the tests do not care about is accepted +// by a catch-all template and does nothing. + +#include "Arduino.h" + +#include +#include +#include + +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_SPI_CMD_INVALID (-706) +#define RADIOLIB_ERR_SPI_CMD_FAILED (-707) +#define RADIOLIB_NC (0xFFFFFFFF) +#define RADIOLIB_PREAMBLE_DETECTED (-14) +#define RADIOLIB_ASSERT(STATEVAR) { if((STATEVAR) != RADIOLIB_ERR_NONE) { return(STATEVAR); } } + +// RadioLib's generic IRQ bit numbers (TypeDef.h, enum RadioLibIrqType_t). +enum { + RADIOLIB_IRQ_TX_DONE = 0x00, + RADIOLIB_IRQ_RX_DONE, + RADIOLIB_IRQ_PREAMBLE_DETECTED, + RADIOLIB_IRQ_SYNC_WORD_VALID, + RADIOLIB_IRQ_HEADER_VALID, + RADIOLIB_IRQ_HEADER_ERR, + RADIOLIB_IRQ_CRC_ERR, + RADIOLIB_IRQ_CAD_DONE, + RADIOLIB_IRQ_CAD_DETECTED, + RADIOLIB_IRQ_TIMEOUT, +}; +#define RADIOLIB_IRQ_RX_DEFAULT_FLAGS ((1UL << RADIOLIB_IRQ_RX_DONE) | (1UL << RADIOLIB_IRQ_TIMEOUT) | (1UL << RADIOLIB_IRQ_CRC_ERR) | (1UL << RADIOLIB_IRQ_HEADER_VALID) | (1UL << RADIOLIB_IRQ_HEADER_ERR)) +#define RADIOLIB_IRQ_RX_DEFAULT_MASK ((1UL << RADIOLIB_IRQ_RX_DONE)) + +// SX126x family (SX126x_commands.h) +#define RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED 0b0000000000000100 +#define RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID 0b0000000000001000 +#define RADIOLIB_SX126X_IRQ_HEADER_VALID 0b0000000000010000 +#define RADIOLIB_SX126X_IRQ_HEADER_ERR 0b0000000000100000 +#define RADIOLIB_SX126X_RX_TIMEOUT_INF 0xFFFFFF +#define RADIOLIB_SX126X_SYNC_WORD_PRIVATE 0x12 +#define RADIOLIB_SX126X_REG_RX_GAIN 0x08AC +#define RADIOLIB_SX126X_RX_GAIN_BOOSTED 0x96 + +// LR11x0 family (LR11x0_commands.h) +#define RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED (0x01UL << 4) +#define RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID (0x01UL << 5) +#define RADIOLIB_LR11X0_IRQ_HEADER_ERR (0x01UL << 6) +#define RADIOLIB_LR11X0_RX_TIMEOUT_INF (0xFFFFFFUL) +#define RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE (0x12) + +// LR2021 (LR2021_commands.h) +#define RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED (0x01UL << 5) +#define RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID (0x01UL << 6) +#define RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR (0x01UL << 9) +#define RADIOLIB_LR2021_RX_TIMEOUT_INF (0xFFFFFFUL) +#define RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE (0x12) + +class SPIClass { +public: + template void begin(A...) { } + template void setPins(A...) { } +}; + +class Module { +public: + Module() { } +}; +using STM32WLx_Module = Module; +using SX1262_Module = Module; + +// One base for every chip: the IRQ register is a plain field a test sets, and +// every clear the chip class asks for is recorded and applied to it. +class StubChip { +public: + explicit StubChip(Module*) { } + virtual ~StubChip() = default; + + uint32_t irq = 0; + std::vector clears; + + struct RxCall { uint32_t timeout, flags, mask; size_t len; }; + std::vector rx_calls; + + float freqMHz = 869.525f; + uint8_t spreadingFactor = 9; + + // SX126x and LR2021 names + uint32_t getIrqFlags() { return irq; } + int16_t clearIrqFlags(uint32_t mask) { clears.push_back(mask); irq &= ~mask; return RADIOLIB_ERR_NONE; } + // LR11x0 names + uint32_t getIrqStatus() { return irq; } + int16_t clearIrqState(uint32_t mask) { clears.push_back(mask); irq &= ~mask; return RADIOLIB_ERR_NONE; } + + virtual int16_t startReceive() { return RADIOLIB_ERR_NONE; } + int16_t startReceive(uint32_t timeout, uint32_t flags, uint32_t mask, size_t len) { + rx_calls.push_back({timeout, flags, mask, len}); + return RADIOLIB_ERR_NONE; + } + + virtual size_t getPacketLength(bool) { return 0; } + virtual int16_t setOutputPower(int8_t) { return RADIOLIB_ERR_NONE; } + int16_t setOutputPower(int8_t, bool) { return RADIOLIB_ERR_NONE; } + virtual int16_t setFrequency(float) { return RADIOLIB_ERR_NONE; } + + template int16_t begin(A...) { return RADIOLIB_ERR_NONE; } + template int16_t standby(A...) { return RADIOLIB_ERR_NONE; } + template int16_t setCRC(A...) { return RADIOLIB_ERR_NONE; } + template int16_t setCurrentLimit(A...) { return RADIOLIB_ERR_NONE; } + template int16_t setDio2AsRfSwitch(A...) { return RADIOLIB_ERR_NONE; } + template int16_t setRxBoostedGainMode(A...) { return RADIOLIB_ERR_NONE; } + template void setRfSwitchPins(A...) { } + template int16_t readRegister(A...) { return RADIOLIB_ERR_NONE; } + template int16_t writeRegister(A...) { return RADIOLIB_ERR_NONE; } + template int16_t setRegulatorDCDC(A...) { return RADIOLIB_ERR_NONE; } + template int16_t checkOutputPower(A...) { return RADIOLIB_ERR_NONE; } + template int16_t explicitHeader(A...) { return RADIOLIB_ERR_NONE; } + uint8_t getStatus() { return 0; } + uint16_t getDeviceErrors() { return 0; } +}; + +class SX126x : public StubChip { public: using StubChip::StubChip; }; +class SX1262 : public SX126x { public: using SX126x::SX126x; }; +class SX1268 : public SX126x { public: using SX126x::SX126x; }; +class LLCC68 : public SX1262 { public: using SX1262::SX1262; }; +class STM32WLx : public SX1262 { public: using SX1262::SX1262; }; +class LR11x0 : public StubChip { public: using StubChip::StubChip; }; +class LR1110 : public LR11x0 { public: using LR11x0::LR11x0; }; +class LR1121 : public LR11x0 { public: using LR11x0::LR11x0; }; +class LR2021 : public StubChip { +public: + using StubChip::StubChip; + uint8_t irqDioNum = 0; +}; diff --git a/test/test_radio_chips/test_radio_chips.cpp b/test/test_radio_chips/test_radio_chips.cpp new file mode 100644 index 00000000000..bc0dab8abdf --- /dev/null +++ b/test/test_radio_chips/test_radio_chips.cpp @@ -0,0 +1,262 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +// Characterization of how the chip classes tell the wrapper that a packet is on +// the air (isReceiving(), which RadioLibWrapper::isReceivingPacket() forwards to). +// The dispatcher holds a transmission back while this reports true, so the +// timing below decides when a node talks over a packet it is hearing. +// +// The same expectations run against every chip class. Each chip names its IRQ +// bits differently and clears a slightly different set, which the traits below +// spell out; everything else is meant to be the same state machine. + +namespace { + +struct SX126xBits { + static constexpr uint32_t preamble = RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; + static constexpr uint32_t header = RADIOLIB_SX126X_IRQ_HEADER_VALID; + static constexpr uint32_t hdrErr = RADIOLIB_SX126X_IRQ_HEADER_ERR; + // the SX126x copies also clear the sync word bit + static constexpr uint32_t clearAll = preamble | header | hdrErr | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID; + static constexpr uint32_t rxTimeout = RADIOLIB_SX126X_RX_TIMEOUT_INF; +}; +struct LR11x0Bits { + static constexpr uint32_t preamble = RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; + static constexpr uint32_t header = RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID; + static constexpr uint32_t hdrErr = RADIOLIB_LR11X0_IRQ_HEADER_ERR; + static constexpr uint32_t clearAll = preamble | header | hdrErr; + static constexpr uint32_t rxTimeout = RADIOLIB_LR11X0_RX_TIMEOUT_INF; +}; +struct LR2021Bits { + static constexpr uint32_t preamble = RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED; + static constexpr uint32_t header = RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID; + static constexpr uint32_t hdrErr = RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR; + static constexpr uint32_t clearAll = preamble | header | hdrErr; + static constexpr uint32_t rxTimeout = RADIOLIB_LR2021_RX_TIMEOUT_INF; +}; + +template struct Chip { using Radio = C; using Bits = B; }; + +using Chips = ::testing::Types< + Chip, + Chip, + Chip, + Chip, + Chip, + Chip, + Chip>; + +// The defaults the chip classes start with, before setParams() replaces them. +constexpr uint32_t DEFAULT_PREAMBLE_MS = 66; +constexpr uint32_t DEFAULT_PAYLOAD_MS = 3934; + +// Start the clock away from zero: a timer value of 0 means "not started" in the +// state machine, which a separate test pins. +constexpr uint32_t T0 = 1000; + +template +struct RadioChipTest : public ::testing::Test { + using Radio = typename T::Radio; + using Bits = typename T::Bits; + Module mod; + Radio radio{&mod}; + + void SetUp() override { g_mock_millis = T0; } + + bool poll(uint32_t irq) { radio.irq = irq; return radio.isReceiving(); } + void at(uint32_t ms) { g_mock_millis = T0 + ms; } +}; +TYPED_TEST_SUITE(RadioChipTest, Chips); + +TYPED_TEST(RadioChipTest, QuietChannelIsNotReceiving) { + EXPECT_FALSE(this->poll(0)); + EXPECT_TRUE(this->radio.clears.empty()); +} + +TYPED_TEST(RadioChipTest, OtherIrqBitsAreIgnored) { + using B = typename TestFixture::Bits; + uint32_t other = ~(B::preamble | B::header | B::hdrErr); + EXPECT_FALSE(this->poll(other)); + EXPECT_TRUE(this->radio.clears.empty()); +} + +TYPED_TEST(RadioChipTest, PreambleCountsUntilItsDeadlineThenIsCleared) { + using B = typename TestFixture::Bits; + EXPECT_TRUE(this->poll(B::preamble)); + this->at(DEFAULT_PREAMBLE_MS); // deadline itself still counts + EXPECT_TRUE(this->poll(B::preamble)); + EXPECT_TRUE(this->radio.clears.empty()); + this->at(DEFAULT_PREAMBLE_MS + 1); // one past: give up on it + EXPECT_FALSE(this->poll(B::preamble)); + ASSERT_EQ(this->radio.clears.size(), 1u); + EXPECT_EQ(this->radio.clears[0], B::preamble); // only the preamble bit +} + +TYPED_TEST(RadioChipTest, PreambleTimerRestartsAfterAClear) { + using B = typename TestFixture::Bits; + this->poll(B::preamble); + this->at(DEFAULT_PREAMBLE_MS + 1); + EXPECT_FALSE(this->poll(B::preamble)); + // the chip reports a new preamble: a fresh deadline from now + this->at(DEFAULT_PREAMBLE_MS + 2); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(2 * DEFAULT_PREAMBLE_MS + 2); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(2 * DEFAULT_PREAMBLE_MS + 3); + EXPECT_FALSE(this->poll(B::preamble)); + EXPECT_EQ(this->radio.clears.size(), 2u); +} + +TYPED_TEST(RadioChipTest, QuietPollResetsThePreambleTimer) { + using B = typename TestFixture::Bits; + this->poll(B::preamble); + this->at(50); + EXPECT_FALSE(this->poll(0)); + this->at(100); // 100 ms after the first preamble + EXPECT_TRUE(this->poll(B::preamble)); // but a new timer started at 100 + this->at(100 + DEFAULT_PREAMBLE_MS); + EXPECT_TRUE(this->poll(B::preamble)); + EXPECT_TRUE(this->radio.clears.empty()); +} + +TYPED_TEST(RadioChipTest, HeaderCountsUntilThePayloadDeadlineThenAllIsCleared) { + using B = typename TestFixture::Bits; + EXPECT_TRUE(this->poll(B::preamble | B::header)); + this->at(DEFAULT_PAYLOAD_MS); + EXPECT_TRUE(this->poll(B::preamble | B::header)); + EXPECT_TRUE(this->radio.clears.empty()); + this->at(DEFAULT_PAYLOAD_MS + 1); + EXPECT_FALSE(this->poll(B::preamble | B::header)); + ASSERT_EQ(this->radio.clears.size(), 1u); + EXPECT_EQ(this->radio.clears[0], B::clearAll); +} + +TYPED_TEST(RadioChipTest, PayloadDeadlineStartsAtTheHeaderNotThePreamble) { + using B = typename TestFixture::Bits; + this->poll(B::preamble); + this->at(50); + EXPECT_TRUE(this->poll(B::preamble | B::header)); + this->at(50 + DEFAULT_PAYLOAD_MS); + EXPECT_TRUE(this->poll(B::header)); + this->at(51 + DEFAULT_PAYLOAD_MS); + EXPECT_FALSE(this->poll(B::header)); +} + +TYPED_TEST(RadioChipTest, HeaderAloneOutlivesThePreambleDeadline) { + using B = typename TestFixture::Bits; + EXPECT_TRUE(this->poll(B::header)); + this->at(DEFAULT_PREAMBLE_MS + 500); + EXPECT_TRUE(this->poll(B::header | B::preamble)); + EXPECT_TRUE(this->radio.clears.empty()); +} + +TYPED_TEST(RadioChipTest, HeaderErrorWinsAndClearsEverything) { + using B = typename TestFixture::Bits; + this->poll(B::preamble | B::header); + this->at(10); + EXPECT_FALSE(this->poll(B::preamble | B::header | B::hdrErr)); + ASSERT_EQ(this->radio.clears.size(), 1u); + EXPECT_EQ(this->radio.clears[0], B::clearAll); +} + +TYPED_TEST(RadioChipTest, HeaderErrorWithoutHeaderAlsoClears) { + using B = typename TestFixture::Bits; + EXPECT_FALSE(this->poll(B::hdrErr)); + ASSERT_EQ(this->radio.clears.size(), 1u); + EXPECT_EQ(this->radio.clears[0], B::clearAll); +} + +TYPED_TEST(RadioChipTest, HeaderErrorResetsTheTimers) { + using B = typename TestFixture::Bits; + this->poll(B::preamble); + this->at(60); + EXPECT_FALSE(this->poll(B::hdrErr)); + this->at(61); // a new preamble right after + EXPECT_TRUE(this->poll(B::preamble)); + this->at(61 + DEFAULT_PREAMBLE_MS); + EXPECT_TRUE(this->poll(B::preamble)); +} + +TYPED_TEST(RadioChipTest, HeaderFlagClearedElsewhereEndsTheReception) { + using B = typename TestFixture::Bits; + this->poll(B::header); + this->at(10); + // the header bit is gone (the packet was read), a preamble bit is still up + EXPECT_FALSE(this->poll(B::preamble)); + EXPECT_TRUE(this->radio.clears.empty()); + // that poll reset the state, so the next one starts a preamble timer + this->at(20); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(20 + DEFAULT_PREAMBLE_MS); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(21 + DEFAULT_PREAMBLE_MS); + EXPECT_FALSE(this->poll(B::preamble)); +} + +TYPED_TEST(RadioChipTest, DeadlinesFollowTheSetters) { + using B = typename TestFixture::Bits; + this->radio.setPreambleMillis(20); + this->radio.setMaxPayloadMillis(300); + this->poll(B::preamble); + this->at(20); + EXPECT_TRUE(this->poll(B::preamble)); + this->at(21); + EXPECT_FALSE(this->poll(B::preamble)); + + this->at(100); + this->poll(B::header); + this->at(400); + EXPECT_TRUE(this->poll(B::header)); + this->at(401); + EXPECT_FALSE(this->poll(B::header)); +} + +// A timer value of 0 doubles as "not started". A preamble first seen at +// millis() == 0 therefore starts its timer on the next poll instead. Pinned +// because a rewrite with a separate flag would change it. +TYPED_TEST(RadioChipTest, PreambleSeenAtMillisZeroStartsOnTheNextPoll) { + using B = typename TestFixture::Bits; + g_mock_millis = 0; + EXPECT_TRUE(this->poll(B::preamble)); + g_mock_millis = 50; + EXPECT_TRUE(this->poll(B::preamble)); + g_mock_millis = 50 + DEFAULT_PREAMBLE_MS; // 116 ms after the first sighting + EXPECT_TRUE(this->poll(B::preamble)); + g_mock_millis = 51 + DEFAULT_PREAMBLE_MS; + EXPECT_FALSE(this->poll(B::preamble)); +} + +TYPED_TEST(RadioChipTest, DeadlinesSurviveTheMillisWrap) { + using B = typename TestFixture::Bits; + g_mock_millis = 0xFFFFFFF0u; + this->poll(B::header); + g_mock_millis = 0xFFFFFFF0u + DEFAULT_PAYLOAD_MS; // wraps past zero + EXPECT_TRUE(this->poll(B::header)); + g_mock_millis = 0xFFFFFFF0u + DEFAULT_PAYLOAD_MS + 1; + EXPECT_FALSE(this->poll(B::header)); +} + +TYPED_TEST(RadioChipTest, StartReceiveAsksForThePreambleIrq) { + using B = typename TestFixture::Bits; + this->radio.startReceive(); + ASSERT_EQ(this->radio.rx_calls.size(), 1u); + const auto& c = this->radio.rx_calls[0]; + EXPECT_EQ(c.timeout, B::rxTimeout); + EXPECT_EQ(c.flags, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED)); + EXPECT_EQ(c.mask, RADIOLIB_IRQ_RX_DEFAULT_MASK); + EXPECT_EQ(c.len, 0u); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From ddca1a5f061f14be989ee9b7539b89279ba1b7bc Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 17:51:13 +0200 Subject: [PATCH 085/136] test(variants): pin how a variant creates a new identity 87 of the 89 variants' target.cpp define radio_new_identity() with the same two lines: a RadioNoiseListener over the variant's radio, handed to the LocalIdentity constructor. The identity is the node's address in the mesh, so before that body moves into one shared definition, pin what it draws: SEED_SIZE bytes, each the radio's random byte XOR the low byte of Arduino's random(0, 256). - native_variant_target (new): the real RadioLibWrappers against the RadioLib stand-in. The test carries the variants' body verbatim and a stand-in for the Ed25519 constructor that keeps the seed, so the test sees exactly what was drawn. - The PhysicalLayer stand-in gets a per instance random_byte (default 0x5A, the value it returned so far), so a test can tell two radios apart. - tools/refactoring/variant_duplication.py measures the copies in the variants (function bodies strict and normalized, FEM gain handlers with the board class name replaced), for the design note of this step. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 21 ++++ test/mocks/RadioLib.h | 3 +- .../test_variant_target.cpp | 67 +++++++++++ tools/refactoring/variant_duplication.py | 109 ++++++++++++++++++ 5 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 test/test_variant_target/test_variant_target.cpp create mode 100644 tools/refactoring/variant_duplication.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6fca397a34e..a634308e1a2 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_chat -e native_identity_store -e native_node_prefs -e native_radio_wrappers -e native_server -e native_sensor -e native_repeater -e native_repeater_full -e native_region -vv + run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_chat -e native_identity_store -e native_node_prefs -e native_radio_wrappers -e native_server -e native_sensor -e native_repeater -e native_repeater_full -e native_region -e native_variant_target -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index 1cce9190f0c..5727f10a749 100644 --- a/platformio.ini +++ b/platformio.ini @@ -178,6 +178,7 @@ test_ignore = test_sensor_role test_repeater_role test_region_map + test_variant_target build_src_filter = -<*> +<../src/Utils.cpp> @@ -428,6 +429,26 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 +[env:native_variant_target] +; What a board variant's target.cpp gives the roles, on the host: the real +; RadioLibWrapper and RadioNoiseListener against the RadioLib stand-in, so the way +; a variant creates a new identity can be pinned before it moves out of the +; variants. LORA_SF is what every variant's build flags set; the value does not +; matter here. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks + -D LORA_SF=8 +test_build_src = yes +test_filter = test_variant_target +build_src_filter = + -<*> + +<../src/helpers/radiolib/RadioLibWrappers.cpp> +lib_deps = + google/googletest @ 1.17.0 + [env:native_region] ; RegionMap on the host. It reads packets and keys and writes nothing but its own ; table, so it builds against the small stubs in test/test_region_map/stubs, which are diff --git a/test/mocks/RadioLib.h b/test/mocks/RadioLib.h index c5ac0802c36..68e9bd5c5dd 100644 --- a/test/mocks/RadioLib.h +++ b/test/mocks/RadioLib.h @@ -27,6 +27,7 @@ class PhysicalLayer { int16_t scan_result = RADIOLIB_CHANNEL_FREE; size_t preamble_length = 0; int8_t output_power = 0; + uint8_t random_byte = 0x5A; // what randomByte() returns; set per radio to tell two apart int16_t sleep() { return RADIOLIB_ERR_NONE; } int16_t standby() { return RADIOLIB_ERR_NONE; } @@ -42,6 +43,6 @@ class PhysicalLayer { int16_t setOutputPower(int8_t dbm) { output_power = dbm; return RADIOLIB_ERR_NONE; } void setPacketReceivedAction(void (*)(void)) { } int16_t setPreambleLength(size_t len) { preamble_length = len; return RADIOLIB_ERR_NONE; } - uint8_t randomByte() { return 0x5A; } + uint8_t randomByte() { return random_byte; } int32_t random(int32_t upper) { return upper / 2; } }; diff --git a/test/test_variant_target/test_variant_target.cpp b/test/test_variant_target/test_variant_target.cpp new file mode 100644 index 00000000000..004865b9088 --- /dev/null +++ b/test/test_variant_target/test_variant_target.cpp @@ -0,0 +1,67 @@ +#include + +#include + +#include + +// What a board variant's target.cpp provides to the roles, pinned on the host. +// 87 of the 89 variants create a new identity with the same two lines, and the +// identity is the node's address in the mesh, so the entropy it is made from +// matters: it has to come from the LoRa radio's noise, mixed with Arduino's +// random(), exactly as RadioNoiseListener does it. + +// Stand-in for src/Identity.cpp, which needs the Ed25519 library. The real +// constructor draws SEED_SIZE bytes from the RNG and derives both keys from +// them. Keeping the seed itself as the public key lets a test see exactly what +// was drawn, which is the only input the real key derivation has. +mesh::Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } +mesh::LocalIdentity::LocalIdentity(mesh::RNG* rng) { rng->random(pub_key, SEED_SIZE); } + +namespace { + +// The body of radio_new_identity() as 87 variants' target.cpp carry it, verbatim +// apart from the parameter: there `radio` is the variant's global radio. +mesh::LocalIdentity variant_copy(PhysicalLayer& radio) { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + +// The seed RadioNoiseListener draws: each byte is the radio's random byte XOR +// the low byte of Arduino's random(0, 256), in order. +void expected_seed(uint8_t* seed, uint8_t radio_byte, long random_seed) { + randomSeed(random_seed); + for (int i = 0; i < SEED_SIZE; i++) { + seed[i] = radio_byte ^ (random(0, 256) & 0xFF); + } +} + +TEST(VariantTarget, VariantCopySeedsFromTheRadioAndArduinoRandom) { + PhysicalLayer radio; + radio.random_byte = 0xC3; + randomSeed(7); + mesh::LocalIdentity id = variant_copy(radio); + + uint8_t seed[SEED_SIZE]; + expected_seed(seed, 0xC3, 7); + EXPECT_EQ(0, memcmp(seed, id.pub_key, SEED_SIZE)); +} + +TEST(VariantTarget, VariantCopyDependsOnTheRadio) { + // Same random() sequence, two radios: the identities must differ, so the + // radio's entropy really goes into the seed. + PhysicalLayer a, b; + a.random_byte = 0x11; + b.random_byte = 0x22; + randomSeed(3); + mesh::LocalIdentity from_a = variant_copy(a); + randomSeed(3); + mesh::LocalIdentity from_b = variant_copy(b); + EXPECT_NE(0, memcmp(from_a.pub_key, from_b.pub_key, SEED_SIZE)); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tools/refactoring/variant_duplication.py b/tools/refactoring/variant_duplication.py new file mode 100644 index 00000000000..a5f1deb11f6 --- /dev/null +++ b/tools/refactoring/variant_duplication.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Measure how much of the board variants' code is copied. + +Used for step 5 of the refactoring series (docs/refactoring/0008-variant-target.md) +to show the duplication before each cut and what is left after it. Python 3 +standard library only; run from the repository root: + + python3 tools/refactoring/variant_duplication.py + +For every function that a variant's target.cpp defines, it reports in how many +files the function exists and how many different bodies there are, once with +only runs of spaces collapsed (strict) and once with comments and all white space +removed (normalized). The FEM gain handlers of the board classes are compared +with the board class name replaced, since that name is the only thing they are +expected to differ in. +""" + +import collections +import glob +import re + +FUNC_HEAD = re.compile(r"^(?:[\w:<>*&]+\s+)+([\w:]+)\s*\([^;{)]*\)\s*(?:const\s*)?\{", re.M) + +# Board classes whose FEM gain handlers are expected to be copies of each other, +# plus the other boards that have handlers of the same name, for comparison. +FEM_BOARDS = [ + ("variants/heltec_v4/HeltecV4Board.cpp", "HeltecV4Board"), + ("variants/heltec_v4_r8/HeltecV4R8Board.cpp", "HeltecV4R8Board"), + ("variants/heltec_t096/T096Board.cpp", "T096Board"), + ("variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp", "HeltecTrackerV2Board"), + ("variants/lilygo_tbeam_1w/TBeam1WBoard.cpp", "TBeam1WBoard"), + ("variants/station_g3_esp32/StationG3Board.cpp", "StationG3Board"), + ("variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp", "WioTrackerL1Board"), +] + + +def body(src, start): + """The text of the function whose head ends at `start` (just after its `{`).""" + depth, i = 1, start + while depth: + depth += (src[i] == "{") - (src[i] == "}") + i += 1 + return src[:i] + + +def function(src, name): + m = re.search(r"^[^\n]*\b" + re.escape(name) + r"\s*\([^)]*\)\s*\{", src, re.M) + return None if m is None else body(src[m.start():], m.end() - m.start()) + + +def strict(text): + return re.sub(r" +", " ", text) + + +def normalized(text): + text = re.sub(r"//[^\n]*", "", text) + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + return re.sub(r"\s+", " ", text).strip() + + +def variant_functions(): + files = sorted(glob.glob("variants/*/target.cpp")) + seen = collections.defaultdict(list) # name -> [(variant, body)] + for path in files: + src = open(path).read() + for m in FUNC_HEAD.finditer(src): + seen[m.group(1)].append((path.split("/")[1], body(src[m.start():], m.end() - m.start()))) + return files, seen + + +def main(): + variant_dirs = sorted(glob.glob("variants/*/")) + files, seen = variant_functions() + print(f"variant directories: {len(variant_dirs)}, with target.cpp: {len(files)}") + lines = sum(len(open(f).read().splitlines()) for f in files) + print(f"lines in all target.cpp: {lines}") + print() + print(f"{'function':32s} {'files':>5s} {'strict':>6s} {'normal':>6s} largest normalized group") + for name, defs in sorted(seen.items(), key=lambda kv: -len(kv[1])): + if len(defs) < 2: + continue + strict_groups = collections.Counter(strict(b) for _, b in defs) + norm_groups = collections.Counter(normalized(b) for _, b in defs) + print(f"{name:32s} {len(defs):5d} {len(strict_groups):6d} {len(norm_groups):6d} " + f"{norm_groups.most_common(1)[0][1]}") + + print() + print("radio_new_identity() bodies:") + groups = collections.defaultdict(list) + for variant, b in seen.get("radio_new_identity", []): + groups[normalized(b)].append(variant) + for text, variants in sorted(groups.items(), key=lambda kv: -len(kv[1])): + listed = "" if len(variants) > 5 else " (" + ", ".join(variants) + ")" + print(f" {len(variants):3d} x {text}{listed}") + + print() + print("FEM gain handlers, board class name replaced by BOARD:") + for fn in ("attachDynamicPrefs", "handleCommand"): + groups = collections.defaultdict(list) + for path, cls in FEM_BOARDS: + b = function(open(path).read(), fn) + groups["" if b is None else normalized(b.replace(cls, "BOARD"))].append(cls) + print(f" {fn}: {len(groups)} distinct bodies") + for text, classes in groups.items(): + print(f" {len(text):5d} chars: {', '.join(classes)}") + + +if __name__ == "__main__": + main() From ebb14737b033025434243536127213cb0b1c4ad4 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 17:51:10 +0200 Subject: [PATCH 086/136] refactor(radiolib): share the receive detection of the chip classes isReceiving() existed in seven copies (CustomSX1262, CustomSX1268, CustomLLCC68, CustomSTM32WLx, CustomLR1110, CustomLR1121, CustomLR2021), about 41 lines each, together with the four members it keeps and the two setters the wrappers call. Upstream created the copies by hand in #3063 ("Unify IRQ timeout logic across radio types"), and each later fix to the logic had to be repeated per chip. The copies differ only in how they reach the chip: the IRQ getter and clearer names, the bits for preamble, header and header error, and the set of bits cleared when a reception ends (the SX126x copies also clear the sync word bit). Move the state machine into RxActivityTracker, a header-only class that sees three booleans and the time and returns whether a packet is on the air and which bits to clear. Each chip keeps its register access in a short isReceiving(). No behaviour change. The characterization suite from the previous commit (native_radio_chips, 112 cases over all seven chips) passes unchanged. The one difference is the order of a debug line against the SPI clear in MESH_DEBUG builds: both "Clearing ... IRQ" lines are now printed before the clear. The public methods isReceiving(), setPreambleMillis() and setMaxPayloadMillis() keep their signatures; the removed members were private. --- src/helpers/radiolib/CustomLLCC68.h | 62 +++++---------------- src/helpers/radiolib/CustomLR1110.h | 61 +++++---------------- src/helpers/radiolib/CustomLR1121.h | 61 +++++---------------- src/helpers/radiolib/CustomLR2021.h | 61 +++++---------------- src/helpers/radiolib/CustomSTM32WLx.h | 62 +++++---------------- src/helpers/radiolib/CustomSX1262.h | 62 +++++---------------- src/helpers/radiolib/CustomSX1268.h | 62 +++++---------------- src/helpers/radiolib/RxActivityTracker.h | 70 ++++++++++++++++++++++++ 8 files changed, 161 insertions(+), 340 deletions(-) create mode 100644 src/helpers/radiolib/RxActivityTracker.h diff --git a/src/helpers/radiolib/CustomLLCC68.h b/src/helpers/radiolib/CustomLLCC68.h index 1dcd916fa1e..a2cc0a65692 100644 --- a/src/helpers/radiolib/CustomLLCC68.h +++ b/src/helpers/radiolib/CustomLLCC68.h @@ -1,12 +1,10 @@ #pragma once #include +#include "RxActivityTracker.h" class CustomLLCC68 : public LLCC68 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; public: CustomLLCC68(Module *mod) : LLCC68(mod) { } @@ -87,55 +85,21 @@ class CustomLLCC68 : public LLCC68 { bool isReceiving() { uint32_t irq = getIrqFlags(); - bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 - bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 - bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, + irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } bool getRxBoostedGainMode() { uint8_t rxGain = 0; diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index f0fde435400..2dc96206d57 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -2,12 +2,10 @@ #include #include "MeshCore.h" +#include "RxActivityTracker.h" class CustomLR1110 : public LR1110 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; bool _rx_boosted = false; public: @@ -52,54 +50,21 @@ class CustomLR1110 : public LR1110 { bool isReceiving() { uint32_t irq = getIrqStatus(); - bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 - bool header = irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID; // bit 5 - bool hdrErr = irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR; // bit 6 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID, + irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } uint8_t getSpreadingFactor() const { return spreadingFactor; } }; diff --git a/src/helpers/radiolib/CustomLR1121.h b/src/helpers/radiolib/CustomLR1121.h index 7182d1d5c3e..6a91b8ad7af 100644 --- a/src/helpers/radiolib/CustomLR1121.h +++ b/src/helpers/radiolib/CustomLR1121.h @@ -2,12 +2,10 @@ #include #include "MeshCore.h" +#include "RxActivityTracker.h" class CustomLR1121 : public LR1121 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; bool _rx_boosted = false; int8_t _txPower = 0; bool _txPowerSet = false; @@ -81,54 +79,21 @@ class CustomLR1121 : public LR1121 { bool isReceiving() { uint32_t irq = getIrqStatus(); - bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 - bool header = irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID; // bit 5 - bool hdrErr = irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR; // bit 6 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID, + irq & RADIOLIB_LR11X0_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID | RADIOLIB_LR11X0_IRQ_HEADER_ERR); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqState(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } uint8_t getSpreadingFactor() const { return spreadingFactor; } }; \ No newline at end of file diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 0255b93cf29..1a7dfb1059c 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -2,12 +2,10 @@ #include #include "MeshCore.h" +#include "RxActivityTracker.h" class CustomLR2021 : public LR2021 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; bool _rx_boosted = false; public: @@ -77,54 +75,21 @@ class CustomLR2021 : public LR2021 { bool isReceiving() { uint32_t irq = getIrqStatus(); - bool preamble = irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED; // bit 5 - bool header = irq & RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID; // bit 6 - bool hdrErr = irq & RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR; // bit 9 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID, + irq & RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID | RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED | RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID | RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } uint8_t getSpreadingFactor() const { return spreadingFactor; } diff --git a/src/helpers/radiolib/CustomSTM32WLx.h b/src/helpers/radiolib/CustomSTM32WLx.h index e312a5ee470..77c9d350e21 100644 --- a/src/helpers/radiolib/CustomSTM32WLx.h +++ b/src/helpers/radiolib/CustomSTM32WLx.h @@ -1,12 +1,10 @@ #pragma once #include +#include "RxActivityTracker.h" class CustomSTM32WLx : public STM32WLx { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; public: CustomSTM32WLx(STM32WLx_Module *mod) : STM32WLx(mod) { } @@ -18,54 +16,20 @@ class CustomSTM32WLx : public STM32WLx { bool isReceiving() { uint32_t irq = getIrqFlags(); - bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 - bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 - bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, + irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } }; \ No newline at end of file diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b4ee6c97aa2..b78c3836086 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -2,12 +2,10 @@ #include #include "MeshCore.h" +#include "RxActivityTracker.h" class CustomSX1262 : public SX1262 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; public: CustomSX1262(Module *mod) : SX1262(mod) { } @@ -107,55 +105,21 @@ class CustomSX1262 : public SX1262 { bool isReceiving() { uint32_t irq = getIrqFlags(); - bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 - bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 - bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, + irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } bool getRxBoostedGainMode() { uint8_t rxGain = 0; diff --git a/src/helpers/radiolib/CustomSX1268.h b/src/helpers/radiolib/CustomSX1268.h index f915332dcef..4fd95517c46 100644 --- a/src/helpers/radiolib/CustomSX1268.h +++ b/src/helpers/radiolib/CustomSX1268.h @@ -1,12 +1,10 @@ #pragma once #include +#include "RxActivityTracker.h" class CustomSX1268 : public SX1268 { - uint32_t _preambleMillis = 66; - uint32_t _maxPayloadMillis = 3934; - uint32_t _activityAt = 0; - bool _headerSeen = false; + RxActivityTracker _rxActivity; public: CustomSX1268(Module *mod) : SX1268(mod) { } @@ -87,55 +85,21 @@ class CustomSX1268 : public SX1268 { bool isReceiving() { uint32_t irq = getIrqFlags(); - bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 - bool header = irq & RADIOLIB_SX126X_IRQ_HEADER_VALID; // bit 4 - bool hdrErr = irq & RADIOLIB_SX126X_IRQ_HEADER_ERR; // bit 5 - uint32_t now = millis(); - if (hdrErr) { + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, + irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; - _headerSeen = false; - return false; } - if (!header && _headerSeen) { - // something cleared the header flag, reset our state. - _activityAt = 0; _headerSeen = false; - return false; - } - - if (header) { - if (!_headerSeen) { _headerSeen = true; _activityAt = now; }; - if (now - _activityAt > _maxPayloadMillis) { - MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - _activityAt = 0; _headerSeen = false; - return false; - } - return true; - } - if (preamble) { - if (_activityAt == 0) _activityAt = now; - if (now - _activityAt > _preambleMillis) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - _activityAt = 0; - MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); - - return false; - } - return true; - } - _activityAt = 0; _headerSeen = false; - return false; + return r == RxActivityTracker::RECEIVING; } - void setPreambleMillis(uint32_t preambleMillis) { - _preambleMillis = preambleMillis; - MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); - } - void setMaxPayloadMillis(uint32_t payloadMillis) { - _maxPayloadMillis = payloadMillis; - MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); - } + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } bool getRxBoostedGainMode() { diff --git a/src/helpers/radiolib/RxActivityTracker.h b/src/helpers/radiolib/RxActivityTracker.h new file mode 100644 index 00000000000..fa208e767f6 --- /dev/null +++ b/src/helpers/radiolib/RxActivityTracker.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include "MeshCore.h" + +// Decides from a chip's preamble and header IRQ bits whether a packet is on the +// air, which the dispatcher uses to hold back a transmission. A preamble counts +// for preambleMillis, a valid header for maxPayloadMillis (both set by the +// wrapper from the modem settings); past a deadline the reception is given up so +// a stuck flag cannot block the transmitter for good. +// +// Shared by every chip class with a preamble IRQ. It only sees three booleans and +// the time, so the chips keep their own register access and IRQ bit names, and +// the logic can be tested on the host without a radio. +class RxActivityTracker { + uint32_t _preambleMillis = 66; + uint32_t _maxPayloadMillis = 3934; + uint32_t _activityAt = 0; // 0 means "not started" + bool _headerSeen = false; + +public: + enum Result : uint8_t { + IDLE, // nothing on the air + RECEIVING, // a packet is on the air + CLEAR_PREAMBLE, // not receiving; the caller clears the preamble bit + CLEAR_ALL, // not receiving; the caller clears the preamble and header bits + }; + + Result update(bool preamble, bool header, bool headerErr, uint32_t now) { + if (headerErr) { + _activityAt = 0; + _headerSeen = false; + return CLEAR_ALL; + } + if (!header && _headerSeen) { + // something cleared the header flag, reset our state. + _activityAt = 0; _headerSeen = false; + return IDLE; + } + if (header) { + if (!_headerSeen) { _headerSeen = true; _activityAt = now; } + if (now - _activityAt > _maxPayloadMillis) { + MESH_DEBUG_PRINTLN("Clearing header IRQ after %ums", _maxPayloadMillis); + _activityAt = 0; _headerSeen = false; + return CLEAR_ALL; + } + return RECEIVING; + } + if (preamble) { + if (_activityAt == 0) _activityAt = now; + if (now - _activityAt > _preambleMillis) { + _activityAt = 0; + MESH_DEBUG_PRINTLN("Clearing preamble IRQ after %ums", _preambleMillis); + return CLEAR_PREAMBLE; + } + return RECEIVING; + } + _activityAt = 0; _headerSeen = false; + return IDLE; + } + + void setPreambleMillis(uint32_t preambleMillis) { + _preambleMillis = preambleMillis; + MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); + } + void setMaxPayloadMillis(uint32_t payloadMillis) { + _maxPayloadMillis = payloadMillis; + MESH_DEBUG_PRINTLN("Set _maxPayloadMillis=%u", _maxPayloadMillis); + } +}; From 248b1d12e230a54086a536f44f3bda493e46fd87 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 17:58:54 +0200 Subject: [PATCH 087/136] refactor(variants): define radio_new_identity() once for RadioLib variants 87 of the 89 variants' target.cpp carried the same radio_new_identity(): mesh::LocalIdentity radio_new_identity() { RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng); } They differed only in white space and in whether the trailing comment was there (76 copies with the comment, 10 without, 1 with the brace on its own line). The two ESP-NOW variants have no LoRa radio and use the ESP32 hardware RNG instead. The function now lives once in src/helpers/radiolib/RadioLibTarget.cpp, which every firmware environment builds (helpers/radiolib/*.cpp is part of the shared source filter). It is compiled only where WRAPPER_CLASS is set, which is exactly the RadioLib variants, and it reaches the radio through radio_driver, the global every variant's target.h declares, instead of the variant's own `radio`, which no target.h declares. In all 87 variants radio_driver is constructed from that same `radio` (`WRAPPER_CLASS radio_driver(radio, board);`), so the listener reads the same PhysicalLayer as before. RadioNoiseListener gains a constructor from a RadioLibWrapper for that, as a friend, so the wrapper's radio stays non-public. The definition is weak. A variant that keeps its own definition, the ESP-NOW ones and every variant in a downstream fork or in an open pull request that adds a board, keeps compiling, and its definition wins. A strong definition would turn each of those into a duplicate symbol error. The declaration stays in each target.h, which is where the roles get it from. Behaviour: unchanged. Host tests (native_variant_target) show the shared definition draws the same seed as the variants' copy and listens on the radio that radio_driver wraps; native_variant_target_override shows a variant's own definition takes precedence (the suite does not link if the shared one is not weak). --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 25 ++++++++-- src/helpers/radiolib/RadioLibTarget.cpp | 19 +++++++ src/helpers/radiolib/RadioLibWrappers.h | 4 ++ test/mocks/radiolib_variant/target.h | 30 +++++++++++ .../test_variant_target.cpp | 43 ++++++++++++++++ .../test_variant_target_override.cpp | 50 +++++++++++++++++++ variants/ebyte_eora_s3/target.cpp | 5 -- variants/gat562_30s_mesh_kit/target.cpp | 5 -- variants/gat562_mesh_evb_pro/target.cpp | 5 -- variants/gat562_mesh_tracker_pro/target.cpp | 5 -- variants/gat562_mesh_watch13/target.cpp | 5 -- variants/generic-e22/target.cpp | 6 --- variants/heltec_ct62/target.cpp | 5 -- variants/heltec_e213/target.cpp | 5 -- variants/heltec_e290/target.cpp | 5 -- variants/heltec_mesh_solar/target.cpp | 5 -- variants/heltec_rc32/target.cpp | 5 -- variants/heltec_t096/target.cpp | 5 -- variants/heltec_t1/target.cpp | 5 -- variants/heltec_t114/target.cpp | 5 -- variants/heltec_t190/target.cpp | 5 -- variants/heltec_tower_v2/target.cpp | 5 -- variants/heltec_tracker/target.cpp | 5 -- variants/heltec_tracker_v2/target.cpp | 6 --- variants/heltec_v2/target.cpp | 5 -- variants/heltec_v3/target.cpp | 6 --- variants/heltec_v4/target.cpp | 6 --- variants/heltec_v4_r8/target.cpp | 5 -- variants/heltec_wireless_paper/target.cpp | 6 --- variants/ikoka_handheld_nrf/target.cpp | 6 --- variants/ikoka_nano_nrf/target.cpp | 6 --- variants/ikoka_stick_nrf/target.cpp | 6 --- variants/keepteen_lt1/target.cpp | 7 --- variants/lilygo_t3s3/target.cpp | 6 --- variants/lilygo_t3s3_sx1276/target.cpp | 5 -- variants/lilygo_t_impulse_plus/target.cpp | 6 --- variants/lilygo_tbeam_1w/target.cpp | 6 --- variants/lilygo_tbeam_SX1262/target.cpp | 6 --- variants/lilygo_tbeam_SX1276/target.cpp | 5 -- .../lilygo_tbeam_supreme_SX1262/target.cpp | 6 --- variants/lilygo_tdeck/target.cpp | 5 -- variants/lilygo_techo/target.cpp | 7 --- variants/lilygo_techo_card/target.cpp | 5 -- variants/lilygo_techo_lite/target.cpp | 7 --- variants/lilygo_teth_elite/target.cpp | 5 -- variants/lilygo_tlora_c6/target.cpp | 6 --- variants/lilygo_tlora_v2_1/target.cpp | 5 -- variants/mesh_pocket/target.cpp | 6 --- variants/meshadventurer/target.cpp | 5 -- variants/meshnology_w12/target.cpp | 6 --- variants/meshtiny/target.cpp | 5 -- variants/meshtracker_x1/target.cpp | 5 -- variants/minewsemi_me25ls01/target.cpp | 5 -- variants/muzi_base/target.cpp | 5 -- variants/muziworks_r1_neo/target.cpp | 5 -- variants/nano_g2_ultra/target.cpp | 5 -- variants/nibble_screen_connect/target.cpp | 6 --- variants/nibble_zero_connect/target.cpp | 7 --- variants/promicro/target.cpp | 7 --- variants/rak11310/target.cpp | 5 -- variants/rak3112/target.cpp | 6 --- variants/rak3401/target.cpp | 6 --- variants/rak3x72/target.cpp | 5 -- variants/rak4631/target.cpp | 5 -- variants/rak_wismesh_tag/target.cpp | 6 --- variants/rpi_picow/target.cpp | 6 --- variants/sensecap_solar/target.cpp | 6 --- variants/station_g2/target.cpp | 6 --- variants/station_g3_esp32/target.cpp | 5 -- variants/t1000-e/target.cpp | 5 -- variants/tenstar_c3/target.cpp | 6 --- variants/thinknode_m1/target.cpp | 5 -- variants/thinknode_m2/target.cpp | 7 --- variants/thinknode_m3/target.cpp | 5 -- variants/thinknode_m4/target.cpp | 5 -- variants/thinknode_m5/target.cpp | 7 --- variants/thinknode_m6/target.cpp | 6 --- variants/thinknode_m7/target.cpp | 7 --- variants/thinknode_m8/target.cpp | 5 -- variants/thinknode_m9/target.cpp | 5 -- variants/tiny_relay/target.cpp | 6 --- variants/waveshare_esp32s3_lr1121/target.cpp | 5 -- variants/waveshare_rp2040_lora/target.cpp | 6 --- variants/wio-e5-dev/target.cpp | 5 -- variants/wio-e5-mini/target.cpp | 5 -- variants/wio-tracker-l1-1w/target.cpp | 6 --- variants/wio-tracker-l1/target.cpp | 6 --- variants/wio_wm1110/target.cpp | 6 --- variants/xiao_c3/target.cpp | 6 --- variants/xiao_nrf52/target.cpp | 5 -- variants/xiao_rp2040/target.cpp | 6 --- variants/xiao_s3/target.cpp | 5 -- variants/xiao_s3_wio/target.cpp | 6 --- 94 files changed, 168 insertions(+), 489 deletions(-) create mode 100644 src/helpers/radiolib/RadioLibTarget.cpp create mode 100644 test/mocks/radiolib_variant/target.h create mode 100644 test/test_variant_target_override/test_variant_target_override.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a634308e1a2..3f7d554dc9d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_chat -e native_identity_store -e native_node_prefs -e native_radio_wrappers -e native_server -e native_sensor -e native_repeater -e native_repeater_full -e native_region -e native_variant_target -vv + run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_chat -e native_identity_store -e native_node_prefs -e native_radio_wrappers -e native_server -e native_sensor -e native_repeater -e native_repeater_full -e native_region -e native_variant_target -e native_variant_target_override -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index 5727f10a749..378f3f3874d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -179,6 +179,7 @@ test_ignore = test_repeater_role test_region_map test_variant_target + test_variant_target_override build_src_filter = -<*> +<../src/Utils.cpp> @@ -431,21 +432,37 @@ lib_deps = [env:native_variant_target] ; What a board variant's target.cpp gives the roles, on the host: the real -; RadioLibWrapper and RadioNoiseListener against the RadioLib stand-in, so the way -; a variant creates a new identity can be pinned before it moves out of the -; variants. LORA_SF is what every variant's build flags set; the value does not -; matter here. +; RadioLibWrapper and RadioNoiseListener against the RadioLib stand-in, and the +; shared definitions in RadioLibTarget.cpp against the target.h of a RadioLib +; variant (test/mocks/radiolib_variant, ahead of test/mocks). LORA_SF and +; WRAPPER_CLASS are what every RadioLib variant's build flags set; the LORA_SF +; value does not matter here. platform = native test_framework = googletest build_flags = -std=c++17 + -I test/mocks/radiolib_variant -I src -I test/mocks -D LORA_SF=8 + -D WRAPPER_CLASS=TestRadioWrapper test_build_src = yes test_filter = test_variant_target build_src_filter = -<*> +<../src/helpers/radiolib/RadioLibWrappers.cpp> + +<../src/helpers/radiolib/RadioLibTarget.cpp> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_variant_target_override] +; The same, with a test that defines its own radio_new_identity(), as a variant +; that keeps its copy does. One binary per case, since the definition is global. +platform = native +test_framework = googletest +build_flags = ${env:native_variant_target.build_flags} +test_build_src = yes +test_filter = test_variant_target_override +build_src_filter = ${env:native_variant_target.build_src_filter} lib_deps = google/googletest @ 1.17.0 diff --git a/src/helpers/radiolib/RadioLibTarget.cpp b/src/helpers/radiolib/RadioLibTarget.cpp new file mode 100644 index 00000000000..0aa679d5937 --- /dev/null +++ b/src/helpers/radiolib/RadioLibTarget.cpp @@ -0,0 +1,19 @@ +// The parts of a variant's target.cpp that every board with a RadioLib radio +// writes the same way, defined once here. They are weak: a variant (or a +// downstream fork's variant) that still defines its own keeps compiling, and its +// definition takes precedence. WRAPPER_CLASS is what tells a RadioLib variant +// apart; the ESP-NOW variants have no LoRa radio and define their own. +#include "target.h" + +#ifdef WRAPPER_CLASS + +#include + +// A new random identity, from the noise of the radio radio_driver wraps (which +// is the variant's global radio). Slow, so only for creating the node's identity. +__attribute__((weak)) mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio_driver); + return mesh::LocalIdentity(&rng); +} + +#endif diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 4d8489557f3..1b3448c1ce2 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -12,6 +12,7 @@ struct PacketMillis { }; class RadioLibWrapper : public mesh::Radio { + friend class RadioNoiseListener; // listens on _radio, without a public accessor for it protected: PhysicalLayer* _radio; mesh::MainBoard* _board; @@ -93,6 +94,9 @@ class RadioNoiseListener : public mesh::RNG { PhysicalLayer* _radio; public: RadioNoiseListener(PhysicalLayer& radio): _radio(&radio) { } + // The radio a driver wraps, for code that has only the driver (radio_driver), + // not the variant's global radio object. + explicit RadioNoiseListener(RadioLibWrapper& driver): _radio(driver._radio) { } void random(uint8_t* dest, size_t sz) override { #ifdef USE_CC310_HW_CRYPTO diff --git a/test/mocks/radiolib_variant/target.h b/test/mocks/radiolib_variant/target.h new file mode 100644 index 00000000000..579e241cd33 --- /dev/null +++ b/test/mocks/radiolib_variant/target.h @@ -0,0 +1,30 @@ +#pragma once + +// Stand-in for the target.h of a variant with a RadioLib radio, for the suites +// that build the shared variant code (src/helpers/radiolib/RadioLibTarget.cpp). +// Listed ahead of test/mocks, whose target.h stands in for a variant without one. +// As on a board, the environment sets WRAPPER_CLASS and the variant's target.cpp +// (here: the test) defines the globals declared below. + +#include + +class TestRadioWrapper : public RadioLibWrapper { +public: + TestRadioWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } + void setParams(float, float, uint8_t, uint8_t) override { } + bool isReceivingPacket() override { return false; } + float getCurrentRSSI() override { return -100.0f; } +}; + +class TestBoard : public mesh::MainBoard { +public: + uint16_t getBattMilliVolts() override { return 4200; } + const char* getManufacturerName() const override { return "test"; } + void reboot() override { } + uint8_t getStartupReason() const override { return 0; } +}; + +extern WRAPPER_CLASS radio_driver; + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/test/test_variant_target/test_variant_target.cpp b/test/test_variant_target/test_variant_target.cpp index 004865b9088..4a51cbcdd0a 100644 --- a/test/test_variant_target/test_variant_target.cpp +++ b/test/test_variant_target/test_variant_target.cpp @@ -3,6 +3,7 @@ #include #include +#include "target.h" // What a board variant's target.cpp provides to the roles, pinned on the host. // 87 of the 89 variants create a new identity with the same two lines, and the @@ -17,6 +18,14 @@ mesh::Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } mesh::LocalIdentity::LocalIdentity(mesh::RNG* rng) { rng->random(pub_key, SEED_SIZE); } +// The globals a variant's target.cpp defines. The shared radio_new_identity() +// must reach the radio through radio_driver, so a second radio that nothing +// wraps shows whether it picks the right one. +PhysicalLayer radio; +PhysicalLayer other_radio; +TestBoard board; +WRAPPER_CLASS radio_driver(radio, board); + namespace { // The body of radio_new_identity() as 87 variants' target.cpp carry it, verbatim @@ -59,6 +68,40 @@ TEST(VariantTarget, VariantCopyDependsOnTheRadio) { EXPECT_NE(0, memcmp(from_a.pub_key, from_b.pub_key, SEED_SIZE)); } +TEST(VariantTarget, SharedDefaultMatchesTheVariantCopy) { + // The definition in src/helpers/radiolib/RadioLibTarget.cpp, which the + // variants no longer repeat, draws the same seed from the same radio. + radio.random_byte = 0xA7; + randomSeed(11); + mesh::LocalIdentity shared = radio_new_identity(); + randomSeed(11); + mesh::LocalIdentity copy = variant_copy(radio); + EXPECT_EQ(0, memcmp(copy.pub_key, shared.pub_key, SEED_SIZE)); +} + +TEST(VariantTarget, SharedDefaultListensOnTheWrappedRadio) { + radio.random_byte = 0x01; + other_radio.random_byte = 0xFE; + randomSeed(5); + mesh::LocalIdentity shared = radio_new_identity(); + + uint8_t seed[SEED_SIZE]; + expected_seed(seed, 0x01, 5); + EXPECT_EQ(0, memcmp(seed, shared.pub_key, SEED_SIZE)); + expected_seed(seed, 0xFE, 5); + EXPECT_NE(0, memcmp(seed, shared.pub_key, SEED_SIZE)); +} + +TEST(VariantTarget, ListenerOnTheDriverMatchesListenerOnTheRadio) { + radio.random_byte = 0x3C; + uint8_t via_radio[16], via_driver[16]; + randomSeed(9); + RadioNoiseListener(radio).random(via_radio, sizeof(via_radio)); + randomSeed(9); + RadioNoiseListener(radio_driver).random(via_driver, sizeof(via_driver)); + EXPECT_EQ(0, memcmp(via_radio, via_driver, sizeof(via_radio))); +} + } // namespace int main(int argc, char **argv) { diff --git a/test/test_variant_target_override/test_variant_target_override.cpp b/test/test_variant_target_override/test_variant_target_override.cpp new file mode 100644 index 00000000000..e0904047eea --- /dev/null +++ b/test/test_variant_target_override/test_variant_target_override.cpp @@ -0,0 +1,50 @@ +#include + +#include + +#include +#include "target.h" + +// A variant that keeps its own radio_new_identity(), as the two ESP-NOW variants +// do and as every variant in a downstream fork still does. Its definition must +// take precedence over the shared one in src/helpers/radiolib/RadioLibTarget.cpp, +// which is built into this binary as well; if the shared one were not weak, this +// suite would not link. + +mesh::Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } +mesh::LocalIdentity::LocalIdentity(mesh::RNG* rng) { rng->random(pub_key, SEED_SIZE); } + +PhysicalLayer radio; +TestBoard board; +WRAPPER_CLASS radio_driver(radio, board); + +namespace { + +// Fills the seed with one marker byte, which no radio noise produces here. +class MarkerRNG : public mesh::RNG { +public: + void random(uint8_t* dest, size_t sz) override { memset(dest, 0xEE, sz); } +}; + +} // namespace + +mesh::LocalIdentity radio_new_identity() { + MarkerRNG rng; + return mesh::LocalIdentity(&rng); +} + +namespace { + +TEST(VariantTargetOverride, VariantDefinitionTakesPrecedence) { + mesh::LocalIdentity id = radio_new_identity(); + uint8_t marker[SEED_SIZE]; + memset(marker, 0xEE, sizeof(marker)); + EXPECT_EQ(0, memcmp(marker, id.pub_key, SEED_SIZE)); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/ebyte_eora_s3/target.cpp b/variants/ebyte_eora_s3/target.cpp index 91d851ff3e2..ee965c2dc68 100644 --- a/variants/ebyte_eora_s3/target.cpp +++ b/variants/ebyte_eora_s3/target.cpp @@ -63,8 +63,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/gat562_30s_mesh_kit/target.cpp b/variants/gat562_30s_mesh_kit/target.cpp index 8f84a7a19ef..1a1b01fc1bc 100644 --- a/variants/gat562_30s_mesh_kit/target.cpp +++ b/variants/gat562_30s_mesh_kit/target.cpp @@ -37,8 +37,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/gat562_mesh_evb_pro/target.cpp b/variants/gat562_mesh_evb_pro/target.cpp index 8dc203de979..d1ce7c6f612 100644 --- a/variants/gat562_mesh_evb_pro/target.cpp +++ b/variants/gat562_mesh_evb_pro/target.cpp @@ -36,8 +36,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/gat562_mesh_tracker_pro/target.cpp b/variants/gat562_mesh_tracker_pro/target.cpp index d16b453810b..bcf61e481c9 100644 --- a/variants/gat562_mesh_tracker_pro/target.cpp +++ b/variants/gat562_mesh_tracker_pro/target.cpp @@ -37,8 +37,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/gat562_mesh_watch13/target.cpp b/variants/gat562_mesh_watch13/target.cpp index 95bcf6dc280..58096165487 100644 --- a/variants/gat562_mesh_watch13/target.cpp +++ b/variants/gat562_mesh_watch13/target.cpp @@ -28,8 +28,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/generic-e22/target.cpp b/variants/generic-e22/target.cpp index 26bba1c0754..0e7e69d0a3e 100644 --- a/variants/generic-e22/target.cpp +++ b/variants/generic-e22/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/heltec_ct62/target.cpp b/variants/heltec_ct62/target.cpp index 522c3199225..bfd651d772f 100644 --- a/variants/heltec_ct62/target.cpp +++ b/variants/heltec_ct62/target.cpp @@ -15,8 +15,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_e213/target.cpp b/variants/heltec_e213/target.cpp index 6914769d9c8..12e22b30a8c 100644 --- a/variants/heltec_e213/target.cpp +++ b/variants/heltec_e213/target.cpp @@ -32,8 +32,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_e290/target.cpp b/variants/heltec_e290/target.cpp index f23279548f9..bcfdbcdba26 100644 --- a/variants/heltec_e290/target.cpp +++ b/variants/heltec_e290/target.cpp @@ -32,8 +32,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index e12dd186256..9daae8a7b67 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -25,11 +25,6 @@ bool radio_init() { return radio.std_init(&SPI); } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void SolarSensorManager::start_gps() { if (!gps_active) { gps_active = true; diff --git a/variants/heltec_rc32/target.cpp b/variants/heltec_rc32/target.cpp index cc7e4deb740..be6d73b87bc 100644 --- a/variants/heltec_rc32/target.cpp +++ b/variants/heltec_rc32/target.cpp @@ -45,8 +45,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/heltec_t096/target.cpp b/variants/heltec_t096/target.cpp index 30509977eee..5ebbc8821f6 100644 --- a/variants/heltec_t096/target.cpp +++ b/variants/heltec_t096/target.cpp @@ -42,8 +42,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_t1/target.cpp b/variants/heltec_t1/target.cpp index af771582dab..7ef0eb0e276 100644 --- a/variants/heltec_t1/target.cpp +++ b/variants/heltec_t1/target.cpp @@ -41,8 +41,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/heltec_t114/target.cpp b/variants/heltec_t114/target.cpp index 90a902f804a..c40be44ae8e 100644 --- a/variants/heltec_t114/target.cpp +++ b/variants/heltec_t114/target.cpp @@ -42,8 +42,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_t190/target.cpp b/variants/heltec_t190/target.cpp index 6a2eab7d847..3c32f047eb4 100644 --- a/variants/heltec_t190/target.cpp +++ b/variants/heltec_t190/target.cpp @@ -32,8 +32,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/heltec_tower_v2/target.cpp b/variants/heltec_tower_v2/target.cpp index ec8e7a17dac..d7b9fc51a32 100644 --- a/variants/heltec_tower_v2/target.cpp +++ b/variants/heltec_tower_v2/target.cpp @@ -26,11 +26,6 @@ bool radio_init() { return radio.std_init(&SPI); } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} - bool TowerV2ExternalWatchdog::begin() { last_feed_watchdog = 0; pinMode(EXTERNAL_WATCHDOG_WAKE_PIN, INPUT); diff --git a/variants/heltec_tracker/target.cpp b/variants/heltec_tracker/target.cpp index af8319923c5..7d88a33210e 100644 --- a/variants/heltec_tracker/target.cpp +++ b/variants/heltec_tracker/target.cpp @@ -38,11 +38,6 @@ bool radio_init() { } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void HWTSensorManager::start_gps() { if (!gps_active) { _location->begin(); // Claims periph_power via RefCountedDigitalPin diff --git a/variants/heltec_tracker_v2/target.cpp b/variants/heltec_tracker_v2/target.cpp index c48a29f6a05..6d042913666 100644 --- a/variants/heltec_tracker_v2/target.cpp +++ b/variants/heltec_tracker_v2/target.cpp @@ -38,9 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/heltec_v2/target.cpp b/variants/heltec_v2/target.cpp index 585b0a37749..5698f4095cc 100644 --- a/variants/heltec_v2/target.cpp +++ b/variants/heltec_v2/target.cpp @@ -31,8 +31,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/heltec_v3/target.cpp b/variants/heltec_v3/target.cpp index e6af466a84d..b467bd98c5a 100644 --- a/variants/heltec_v3/target.cpp +++ b/variants/heltec_v3/target.cpp @@ -38,9 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/heltec_v4/target.cpp b/variants/heltec_v4/target.cpp index 8483f276891..9f163e1a8db 100644 --- a/variants/heltec_v4/target.cpp +++ b/variants/heltec_v4/target.cpp @@ -38,9 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/heltec_v4_r8/target.cpp b/variants/heltec_v4_r8/target.cpp index 0b38531e3c0..2f5390c5961 100644 --- a/variants/heltec_v4_r8/target.cpp +++ b/variants/heltec_v4_r8/target.cpp @@ -38,8 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/heltec_wireless_paper/target.cpp b/variants/heltec_wireless_paper/target.cpp index be21695ec4e..91ee526f119 100644 --- a/variants/heltec_wireless_paper/target.cpp +++ b/variants/heltec_wireless_paper/target.cpp @@ -31,9 +31,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/ikoka_handheld_nrf/target.cpp b/variants/ikoka_handheld_nrf/target.cpp index 6c53c5e324a..31c4a70dceb 100644 --- a/variants/ikoka_handheld_nrf/target.cpp +++ b/variants/ikoka_handheld_nrf/target.cpp @@ -24,9 +24,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/ikoka_nano_nrf/target.cpp b/variants/ikoka_nano_nrf/target.cpp index baf02c3d122..9788ead279a 100644 --- a/variants/ikoka_nano_nrf/target.cpp +++ b/variants/ikoka_nano_nrf/target.cpp @@ -22,9 +22,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/ikoka_stick_nrf/target.cpp b/variants/ikoka_stick_nrf/target.cpp index bb140505a86..e8cbde71139 100644 --- a/variants/ikoka_stick_nrf/target.cpp +++ b/variants/ikoka_stick_nrf/target.cpp @@ -22,9 +22,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/keepteen_lt1/target.cpp b/variants/keepteen_lt1/target.cpp index f1879ac569c..23845927b38 100644 --- a/variants/keepteen_lt1/target.cpp +++ b/variants/keepteen_lt1/target.cpp @@ -28,10 +28,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/lilygo_t3s3/target.cpp b/variants/lilygo_t3s3/target.cpp index 34df31c3b17..2679f6f914e 100644 --- a/variants/lilygo_t3s3/target.cpp +++ b/variants/lilygo_t3s3/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_t3s3_sx1276/target.cpp b/variants/lilygo_t3s3_sx1276/target.cpp index 770db4a7746..5e1c352d822 100644 --- a/variants/lilygo_t3s3_sx1276/target.cpp +++ b/variants/lilygo_t3s3_sx1276/target.cpp @@ -32,8 +32,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/lilygo_t_impulse_plus/target.cpp b/variants/lilygo_t_impulse_plus/target.cpp index 87b3dbfed7e..1181a3d31ee 100644 --- a/variants/lilygo_t_impulse_plus/target.cpp +++ b/variants/lilygo_t_impulse_plus/target.cpp @@ -29,9 +29,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_tbeam_1w/target.cpp b/variants/lilygo_tbeam_1w/target.cpp index 6755df09f59..b98d8924c37 100644 --- a/variants/lilygo_tbeam_1w/target.cpp +++ b/variants/lilygo_tbeam_1w/target.cpp @@ -42,9 +42,3 @@ bool radio_init() { } return success; } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} - diff --git a/variants/lilygo_tbeam_SX1262/target.cpp b/variants/lilygo_tbeam_SX1262/target.cpp index 6e5292a77ec..d5ea9ad1f8c 100644 --- a/variants/lilygo_tbeam_SX1262/target.cpp +++ b/variants/lilygo_tbeam_SX1262/target.cpp @@ -33,9 +33,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_tbeam_SX1276/target.cpp b/variants/lilygo_tbeam_SX1276/target.cpp index 30f23dab78f..35b2c0af3fc 100644 --- a/variants/lilygo_tbeam_SX1276/target.cpp +++ b/variants/lilygo_tbeam_SX1276/target.cpp @@ -38,8 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/lilygo_tbeam_supreme_SX1262/target.cpp b/variants/lilygo_tbeam_supreme_SX1262/target.cpp index a0b765bf6d1..a0e11204235 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/target.cpp +++ b/variants/lilygo_tbeam_supreme_SX1262/target.cpp @@ -30,9 +30,3 @@ bool radio_init() { rtc_clock.begin(Wire1); return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_tdeck/target.cpp b/variants/lilygo_tdeck/target.cpp index d20c56a3e9d..f63c06f6099 100644 --- a/variants/lilygo_tdeck/target.cpp +++ b/variants/lilygo_tdeck/target.cpp @@ -33,8 +33,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/lilygo_techo/target.cpp b/variants/lilygo_techo/target.cpp index 278fc76dad5..c71929d9fea 100644 --- a/variants/lilygo_techo/target.cpp +++ b/variants/lilygo_techo/target.cpp @@ -30,10 +30,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/lilygo_techo_card/target.cpp b/variants/lilygo_techo_card/target.cpp index 8bbe036338a..6c69a7e4f4b 100644 --- a/variants/lilygo_techo_card/target.cpp +++ b/variants/lilygo_techo_card/target.cpp @@ -31,8 +31,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/lilygo_techo_lite/target.cpp b/variants/lilygo_techo_lite/target.cpp index 95a62fcd250..3dd43b193dd 100644 --- a/variants/lilygo_techo_lite/target.cpp +++ b/variants/lilygo_techo_lite/target.cpp @@ -29,10 +29,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/lilygo_teth_elite/target.cpp b/variants/lilygo_teth_elite/target.cpp index 4dc377d6202..7afbace768e 100644 --- a/variants/lilygo_teth_elite/target.cpp +++ b/variants/lilygo_teth_elite/target.cpp @@ -36,8 +36,3 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { void radio_set_tx_power(int8_t dbm) { radio.setOutputPower(dbm); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/lilygo_tlora_c6/target.cpp b/variants/lilygo_tlora_c6/target.cpp index d70e9b79177..02dfcd52a35 100644 --- a/variants/lilygo_tlora_c6/target.cpp +++ b/variants/lilygo_tlora_c6/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/lilygo_tlora_v2_1/target.cpp b/variants/lilygo_tlora_v2_1/target.cpp index 8c120161505..00ae597b84c 100644 --- a/variants/lilygo_tlora_v2_1/target.cpp +++ b/variants/lilygo_tlora_v2_1/target.cpp @@ -27,8 +27,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/mesh_pocket/target.cpp b/variants/mesh_pocket/target.cpp index ee8f944de41..79af757a1cb 100644 --- a/variants/mesh_pocket/target.cpp +++ b/variants/mesh_pocket/target.cpp @@ -22,9 +22,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/meshadventurer/target.cpp b/variants/meshadventurer/target.cpp index 9ce1a0d8ddb..8241cac144d 100644 --- a/variants/meshadventurer/target.cpp +++ b/variants/meshadventurer/target.cpp @@ -31,11 +31,6 @@ bool radio_init() { #endif } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void MASensorManager::start_gps() { if(!gps_active) { diff --git a/variants/meshnology_w12/target.cpp b/variants/meshnology_w12/target.cpp index bf04ba36470..aa2e0e13060 100644 --- a/variants/meshnology_w12/target.cpp +++ b/variants/meshnology_w12/target.cpp @@ -65,9 +65,3 @@ bool radio_init() { return true; } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/meshtiny/target.cpp b/variants/meshtiny/target.cpp index 50d678266e8..f7560900d8b 100644 --- a/variants/meshtiny/target.cpp +++ b/variants/meshtiny/target.cpp @@ -25,8 +25,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/meshtracker_x1/target.cpp b/variants/meshtracker_x1/target.cpp index 366e6bdddcc..a04df3f01cf 100644 --- a/variants/meshtracker_x1/target.cpp +++ b/variants/meshtracker_x1/target.cpp @@ -22,11 +22,6 @@ bool radio_init() { return radio.std_init(&SPI); } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void MeshTrackerX1SensorManager::start_gps() { gps_active = true; digitalWrite(GPS_EN, HIGH); diff --git a/variants/minewsemi_me25ls01/target.cpp b/variants/minewsemi_me25ls01/target.cpp index 41fc71a7d9d..ade9e7666ab 100644 --- a/variants/minewsemi_me25ls01/target.cpp +++ b/variants/minewsemi_me25ls01/target.cpp @@ -76,8 +76,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/muzi_base/target.cpp b/variants/muzi_base/target.cpp index 5bd846c9861..5a54999c37a 100644 --- a/variants/muzi_base/target.cpp +++ b/variants/muzi_base/target.cpp @@ -155,8 +155,3 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { void radio_set_tx_power(int8_t dbm) { radio.setOutputPower(dbm); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/muziworks_r1_neo/target.cpp b/variants/muziworks_r1_neo/target.cpp index f667a2827e1..32e4b704a95 100644 --- a/variants/muziworks_r1_neo/target.cpp +++ b/variants/muziworks_r1_neo/target.cpp @@ -30,8 +30,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/nano_g2_ultra/target.cpp b/variants/nano_g2_ultra/target.cpp index 4bd88fb008c..7323ad007dd 100644 --- a/variants/nano_g2_ultra/target.cpp +++ b/variants/nano_g2_ultra/target.cpp @@ -122,8 +122,3 @@ bool NanoG2UltraSensorManager::setSettingValue(const char *name, const char *val } return false; // not supported } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/nibble_screen_connect/target.cpp b/variants/nibble_screen_connect/target.cpp index ceb9fe49cf0..6609338e757 100644 --- a/variants/nibble_screen_connect/target.cpp +++ b/variants/nibble_screen_connect/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} - diff --git a/variants/nibble_zero_connect/target.cpp b/variants/nibble_zero_connect/target.cpp index d53b6a1c3d8..2679f6f914e 100644 --- a/variants/nibble_zero_connect/target.cpp +++ b/variants/nibble_zero_connect/target.cpp @@ -26,10 +26,3 @@ bool radio_init() { return radio.std_init(&spi); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} - - diff --git a/variants/promicro/target.cpp b/variants/promicro/target.cpp index b5a74c90dce..4358b6c6843 100644 --- a/variants/promicro/target.cpp +++ b/variants/promicro/target.cpp @@ -28,10 +28,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/rak11310/target.cpp b/variants/rak11310/target.cpp index 4c288975097..9727a2908ab 100644 --- a/variants/rak11310/target.cpp +++ b/variants/rak11310/target.cpp @@ -21,8 +21,3 @@ bool radio_init() { void radio_set_tx_power(int8_t dbm) { radio.setOutputPower(dbm); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/rak3112/target.cpp b/variants/rak3112/target.cpp index 9df1bf8f8ca..214426d474c 100644 --- a/variants/rak3112/target.cpp +++ b/variants/rak3112/target.cpp @@ -38,9 +38,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/rak3401/target.cpp b/variants/rak3401/target.cpp index 5309e6b22b9..ad5cca8e9ea 100644 --- a/variants/rak3401/target.cpp +++ b/variants/rak3401/target.cpp @@ -36,9 +36,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/rak3x72/target.cpp b/variants/rak3x72/target.cpp index e55543a324f..1e94b62c6ef 100644 --- a/variants/rak3x72/target.cpp +++ b/variants/rak3x72/target.cpp @@ -54,8 +54,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/rak4631/target.cpp b/variants/rak4631/target.cpp index a41ba720751..4711397d6a1 100644 --- a/variants/rak4631/target.cpp +++ b/variants/rak4631/target.cpp @@ -36,8 +36,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/rak_wismesh_tag/target.cpp b/variants/rak_wismesh_tag/target.cpp index cdf81f4f5b9..67b193cd36e 100644 --- a/variants/rak_wismesh_tag/target.cpp +++ b/variants/rak_wismesh_tag/target.cpp @@ -32,9 +32,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/rpi_picow/target.cpp b/variants/rpi_picow/target.cpp index 9318b7f0f76..f586405b821 100644 --- a/variants/rpi_picow/target.cpp +++ b/variants/rpi_picow/target.cpp @@ -17,9 +17,3 @@ bool radio_init() { return radio.std_init(&SPI1); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/sensecap_solar/target.cpp b/variants/sensecap_solar/target.cpp index 466415176f8..5a4683a14c3 100644 --- a/variants/sensecap_solar/target.cpp +++ b/variants/sensecap_solar/target.cpp @@ -24,9 +24,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/station_g2/target.cpp b/variants/station_g2/target.cpp index 8018c20eb88..743fb988da4 100644 --- a/variants/station_g2/target.cpp +++ b/variants/station_g2/target.cpp @@ -39,9 +39,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/station_g3_esp32/target.cpp b/variants/station_g3_esp32/target.cpp index d3de074ba81..2cd01990e1f 100644 --- a/variants/station_g3_esp32/target.cpp +++ b/variants/station_g3_esp32/target.cpp @@ -39,8 +39,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/t1000-e/target.cpp b/variants/t1000-e/target.cpp index c48644f9f35..22a5b87c0fe 100644 --- a/variants/t1000-e/target.cpp +++ b/variants/t1000-e/target.cpp @@ -76,11 +76,6 @@ bool radio_init() { return true; // success } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void T1000SensorManager::start_gps() { gps_active = true; digitalWrite(GPS_EN, HIGH); diff --git a/variants/tenstar_c3/target.cpp b/variants/tenstar_c3/target.cpp index 909625edfc8..b800e2df6c4 100644 --- a/variants/tenstar_c3/target.cpp +++ b/variants/tenstar_c3/target.cpp @@ -26,9 +26,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/thinknode_m1/target.cpp b/variants/thinknode_m1/target.cpp index 0c832447e46..73faf5bb877 100644 --- a/variants/thinknode_m1/target.cpp +++ b/variants/thinknode_m1/target.cpp @@ -25,11 +25,6 @@ bool radio_init() { return radio.std_init(&SPI); } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - void ThinkNodeM1SensorManager::start_gps() { if (!gps_active) { gps_active = true; diff --git a/variants/thinknode_m2/target.cpp b/variants/thinknode_m2/target.cpp index ff0dd6cf4db..a75604d4879 100644 --- a/variants/thinknode_m2/target.cpp +++ b/variants/thinknode_m2/target.cpp @@ -33,10 +33,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/thinknode_m3/target.cpp b/variants/thinknode_m3/target.cpp index 1e6c2ad13cb..ea710f74fac 100644 --- a/variants/thinknode_m3/target.cpp +++ b/variants/thinknode_m3/target.cpp @@ -77,8 +77,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/thinknode_m4/target.cpp b/variants/thinknode_m4/target.cpp index 7da3f400d48..c10fda51a56 100644 --- a/variants/thinknode_m4/target.cpp +++ b/variants/thinknode_m4/target.cpp @@ -77,8 +77,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/thinknode_m5/target.cpp b/variants/thinknode_m5/target.cpp index be534f67d75..6fea04e2b41 100644 --- a/variants/thinknode_m5/target.cpp +++ b/variants/thinknode_m5/target.cpp @@ -40,10 +40,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/thinknode_m6/target.cpp b/variants/thinknode_m6/target.cpp index de167194e40..40f1330eff8 100644 --- a/variants/thinknode_m6/target.cpp +++ b/variants/thinknode_m6/target.cpp @@ -27,9 +27,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/thinknode_m7/target.cpp b/variants/thinknode_m7/target.cpp index f4b898d8e93..9d164855fa5 100644 --- a/variants/thinknode_m7/target.cpp +++ b/variants/thinknode_m7/target.cpp @@ -75,10 +75,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - - diff --git a/variants/thinknode_m8/target.cpp b/variants/thinknode_m8/target.cpp index ac4ac100edc..9b6177888ed 100644 --- a/variants/thinknode_m8/target.cpp +++ b/variants/thinknode_m8/target.cpp @@ -29,8 +29,3 @@ bool radio_init() { rtc_clock.begin(Wire); return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/thinknode_m9/target.cpp b/variants/thinknode_m9/target.cpp index 3cbd7d758a8..8d8ccc86d85 100644 --- a/variants/thinknode_m9/target.cpp +++ b/variants/thinknode_m9/target.cpp @@ -71,8 +71,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/tiny_relay/target.cpp b/variants/tiny_relay/target.cpp index aed8a67c642..c96c04338d1 100644 --- a/variants/tiny_relay/target.cpp +++ b/variants/tiny_relay/target.cpp @@ -56,9 +56,3 @@ bool radio_init() return true; // success } - -mesh::LocalIdentity radio_new_identity() -{ - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/waveshare_esp32s3_lr1121/target.cpp b/variants/waveshare_esp32s3_lr1121/target.cpp index 56775c4cef7..a9c9a00e5a3 100644 --- a/variants/waveshare_esp32s3_lr1121/target.cpp +++ b/variants/waveshare_esp32s3_lr1121/target.cpp @@ -56,8 +56,3 @@ bool radio_init() { return true; } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); -} diff --git a/variants/waveshare_rp2040_lora/target.cpp b/variants/waveshare_rp2040_lora/target.cpp index ef47708fb34..7478189391d 100644 --- a/variants/waveshare_rp2040_lora/target.cpp +++ b/variants/waveshare_rp2040_lora/target.cpp @@ -27,9 +27,3 @@ bool radio_init() { //passing NULL skips init of SPI return radio.std_init(NULL); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/wio-e5-dev/target.cpp b/variants/wio-e5-dev/target.cpp index 680da631a40..3e37d585de8 100644 --- a/variants/wio-e5-dev/target.cpp +++ b/variants/wio-e5-dev/target.cpp @@ -51,8 +51,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/wio-e5-mini/target.cpp b/variants/wio-e5-mini/target.cpp index 92775d5511f..c4b3e178029 100644 --- a/variants/wio-e5-mini/target.cpp +++ b/variants/wio-e5-mini/target.cpp @@ -50,11 +50,6 @@ bool radio_init() { return true; // success } -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - bool WIOE5SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { if (!has_bme) return false; diff --git a/variants/wio-tracker-l1-1w/target.cpp b/variants/wio-tracker-l1-1w/target.cpp index 7a573258e6d..589de98831f 100644 --- a/variants/wio-tracker-l1-1w/target.cpp +++ b/variants/wio-tracker-l1-1w/target.cpp @@ -32,9 +32,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/wio-tracker-l1/target.cpp b/variants/wio-tracker-l1/target.cpp index 7a573258e6d..589de98831f 100644 --- a/variants/wio-tracker-l1/target.cpp +++ b/variants/wio-tracker-l1/target.cpp @@ -32,9 +32,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/wio_wm1110/target.cpp b/variants/wio_wm1110/target.cpp index 1bb7cec8cb0..883fd2e5eab 100644 --- a/variants/wio_wm1110/target.cpp +++ b/variants/wio_wm1110/target.cpp @@ -69,9 +69,3 @@ bool radio_init() { return true; // success } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/xiao_c3/target.cpp b/variants/xiao_c3/target.cpp index 7f30dc8b60c..20bb8c3338e 100644 --- a/variants/xiao_c3/target.cpp +++ b/variants/xiao_c3/target.cpp @@ -34,9 +34,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/xiao_nrf52/target.cpp b/variants/xiao_nrf52/target.cpp index def1dbbeaaa..6af7628e3a4 100644 --- a/variants/xiao_nrf52/target.cpp +++ b/variants/xiao_nrf52/target.cpp @@ -23,8 +23,3 @@ bool radio_init() { return radio.std_init(&SPI); } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} \ No newline at end of file diff --git a/variants/xiao_rp2040/target.cpp b/variants/xiao_rp2040/target.cpp index fe2c62c3dc8..c378b03df8e 100644 --- a/variants/xiao_rp2040/target.cpp +++ b/variants/xiao_rp2040/target.cpp @@ -27,9 +27,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - diff --git a/variants/xiao_s3/target.cpp b/variants/xiao_s3/target.cpp index a7ae7dd8e00..9c093681c16 100644 --- a/variants/xiao_s3/target.cpp +++ b/variants/xiao_s3/target.cpp @@ -34,8 +34,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} diff --git a/variants/xiao_s3_wio/target.cpp b/variants/xiao_s3_wio/target.cpp index 8a975959fa4..5358262e3a9 100644 --- a/variants/xiao_s3_wio/target.cpp +++ b/variants/xiao_s3_wio/target.cpp @@ -34,9 +34,3 @@ bool radio_init() { return radio.std_init(); #endif } - -mesh::LocalIdentity radio_new_identity() { - RadioNoiseListener rng(radio); - return mesh::LocalIdentity(&rng); // create new random identity -} - From 24ce8b5f181c2c1b330d0555bdbf1628fcc7ab80 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:17:54 +0200 Subject: [PATCH 088/136] docs(refactoring): the note for the flood hop limits, and the ledger - docs/refactoring/0006-sensor-flood-limits.md: the upgrade hazard, why the range check on /prefs.json does not resolve it, the prefs version and the sensor's one-time upgrade, the alternatives, compatibility with older firmware, the test and mutation tables, the build matrix against step 4f, and the hardware check on a T-Echo: stock v1.17.1, upgrade, reboot, a deliberate 0 that survives, and the downgrade to a v1.18 build without the version and to stock v1.17.1, back to the starting state on all 31 lines. - 0005: its status and the held back part point to 0006 instead of "waits for Fix 9". - Ledger issue 12 records what changed for the sensor and that a stored 0 stays a valid choice on the repeater and the room server. --- docs/refactoring/0005-sensor-alignment.md | 12 +- docs/refactoring/0006-sensor-flood-limits.md | 197 ++++++++++++++++++ docs/refactoring/architecture-review-v1.18.md | 2 +- 3 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 docs/refactoring/0006-sensor-flood-limits.md diff --git a/docs/refactoring/0005-sensor-alignment.md b/docs/refactoring/0005-sensor-alignment.md index ca3e9ce9ef2..64f124efe55 100644 --- a/docs/refactoring/0005-sensor-alignment.md +++ b/docs/refactoring/0005-sensor-alignment.md @@ -1,8 +1,8 @@ # 0005: Align the sensor, part one: multi.acks and RX boosted gain Status: implemented. The third part of the alignment, the flood hop limits, is -parked on `refactor/sensor-flood-limits` and waits for the `/prefs.json` range -checking; the reason is in "What is deliberately held back" below. +[0006](0006-sensor-flood-limits.md); why it is a change of its own is in "What is +deliberately held back" below. This is a change of behaviour, not a move, so it is a branch of its own with a note of its own. @@ -78,9 +78,11 @@ How far it reaches: forwarding is off by default on the sensor repeater forwards out of the box). The hazard therefore only affects sensors where an operator switched forwarding on, and those are exactly the ones where it matters. -Marcel's decision of 20 October: wait for Fix 9. Once `/prefs.json` is range -checked, the cause is gone and the flood limits can be aligned without a migration -of their own. Until then only the two changes above land. +Marcel's decision of 20 October was first to wait for Fix 9, on the assumption that +range checking `/prefs.json` would remove the cause. It does not: 0 is a legal value +for these limits, so Fix 9 does not clamp them. The second decision, the same day, is +to lift a stored 0 once on upgrade, behind a version in `/prefs.json`. That is +[0006](0006-sensor-flood-limits.md). A separate observation, worth its own issue either way (ledger issue 12): because a 0 is stored and applied verbatim, `set flood.max.unscoped 0` is persistable on every diff --git a/docs/refactoring/0006-sensor-flood-limits.md b/docs/refactoring/0006-sensor-flood-limits.md new file mode 100644 index 00000000000..b44cc0c0db1 --- /dev/null +++ b/docs/refactoring/0006-sensor-flood-limits.md @@ -0,0 +1,197 @@ +# 0006: Align the sensor, part two: the flood hop limits + +Status: implemented. Stacked on step 4f ([0007](0007-boot-sequence.md)), whose shared +`begin()` carries the one-time upgrade this change needs. Part one of the alignment +is [0005](0005-sensor-alignment.md). + +This is a change of behaviour on the sensor, and it adds a version to `/prefs.json` +for every server role, so it is a branch and a note of its own. + +## Context + +`SensorMesh::allowPacketForward()` checked `flood_max` only. The repeater and the +room server also apply `flood.max.unscoped` and `flood.max.advert` through +`mesh::isFloodHopLimitExceeded()`. The sensor missed that change when it reached the +other roles; the history is in `architecture-review-v1.18.md` under finding F2. + +Aligning the check is two lines. What makes it more is the state every existing +sensor is in. + +## The upgrade hazard + +Measured on the host, not assumed. Three tests in `test_sensor_role` hold it: + +1. `AZeroHopLimitIsWrittenOutAsAZero`: a limit of 0 goes into `prefs.json` as an + explicit `0`, not as an empty value. +2. `ASensorUpgradedOntoZeroHopLimitsKeepsForwarding` (before this change it asserted + the opposite): `/prefs.json` is read through `loadSerial()`, which applies a stored + value as it is, and since the limits are compared with `>=`, a 0 refuses every + un-scoped flood and every advert at hop 0. +3. `AMissingKeyLeavesTheNewDefaultStanding`: a key absent from the file overwrites + nothing, so only files that carry the key are affected. + +The sensor set no default for either limit and never applied them, so every sensor +that has ever saved its settings stores `f_max_uns:0, f_max_adv:0`. On the board used +for the hardware check below, stock v1.17.1 reports exactly that. Giving the +constructor the other roles' values (64 and 8) is not enough: the stored zero beats +the default. A sensor with forwarding switched on would have stopped forwarding both +kinds of flood after the update, without any sign. + +How far it reaches: forwarding is off by default on the sensor +(`_prefs.disable_fwd = true` in its constructor, as on the room server). So only +sensors where an operator switched forwarding on are affected, and those are exactly +the ones where it matters. + +### Why the range check does not resolve it + +This change was first parked to wait for Fix 9, the range checking of `/prefs.json`. +Once Fix 9 had landed, its `CommonCLI::sanitisePrefs()` turned out to clamp nineteen +fields but not `flood_max`, `flood_max_unscoped` or `flood_max_advert`. That is +consistent: 0 is a legal value for these limits ("do not forward"), so there is no +range to clamp it into. The reason for parking was an assumption about Fix 9 that +reading it would have refuted. + +## What changes + +- **The sensor applies both limits.** `allowPacketForward()` calls + `mesh::isFloodHopLimitExceeded()` like the other roles, and the constructor sets + `flood_max_unscoped` to 64 and `flood_max_advert` to 8. After it, + `allowPacketForward()` is the same text in the room server and the sensor. +- **`/prefs.json` has a version.** `NodePrefs::prefs_version` is written as the first + key, `ver`. A file without it reads as version 0. `NODE_PREFS_VERSION` is 1. +- **`ServerMesh::begin()` upgrades an older file once.** Right after `loadPrefs()`, + and only when the stored version is older than `NODE_PREFS_VERSION`, it calls the + new hook `upgradePrefs(from)`, records the current version, and saves at once if + the hook changed a value. Otherwise booting stays read only, as before. A newer + stored version, which a downgrade meets, is left as it is. +- **The sensor's upgrade step.** From version 0, a stored `flood_max_unscoped` of 0 + becomes 64 and a stored `flood_max_advert` of 0 becomes 8. A value other than 0 was + set on purpose and is kept. The repeater and the room server do not override the + hook: on those roles a stored 0 has always been applied, so it was a choice. + +The version is what makes the lift run once. Without it, a 0 that an operator sets +on purpose after the update would be lifted again at every boot. + +## Alternatives + +- **Ship it and document it** ("run `set flood.max.unscoped 64` after the update"). + Rejected: a node whose operator misses the note stops forwarding without a sign. +- **Leave the sensor ignoring the two limits.** Rejected: it keeps the sensor apart + from the other roles for good, and `allowPacketForward()` could not be shared. +- **A one-off marker for these two fields** instead of a version. Rejected: the next + setting whose meaning changes would need another marker; a version takes any later + step. +- **Upgrade inside `CommonCLI::loadPrefs()`.** Rejected: the CLI does not know the + role, and only the role knows which stored values meant something else before. +- **Lift a stored 0 at every boot, without a version.** Rejected: it takes away a + setting the other roles offer. + +## Compatibility + +- **Older firmware ignores `ver`.** `ConfigSerializer::loadSerial()` only takes the + keys its `structure()` names. Shown on the board for stock v1.17.1 and for a v1.18 + build without the version (below). +- **A downgrade that saves drops the key**, and the next upgrade runs the step again. + For the sensor that is harmless: the older firmware never applied these limits, so + a 0 it stored was never chosen either. +- **The file of every server role gains one key.** The pinned `prefs.json` of + `test_common_node_prefs` changes by exactly `ver:1,` in front. + +## Verification + +### Tests first + +The forwarding cases and the three measurements of the hazard were written against +the old code. The upgrade cases live in `test/mocks/BootSequenceChecks.h`, so the +repeater, the room server and the sensor all run them; a suite that defines +`BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS` (the sensor's) gets the lift cases, the +others get "a stored 0 is kept and the file is not rewritten". + +With the version field in place but no upgrade, 7 tests failed, 14 cases across the +environments, where expected: the sensor lift, the version after boot and after save, +the pinned `prefs.json`. With the upgrade, all pass. + +| suite | new or changed cases | +|---|---| +| `BootSequenceChecks.h`, all three roles | a fresh node is at the current version and writes nothing; a stored file gets the version with the next save; an older file that needs nothing is not rewritten; the role is asked to upgrade only an older file; zeros stored at the current version are kept; a newer version is left as it is | +| same, sensor only | unversioned zeros are lifted to 64 and 8; the lift is saved with the version; only a 0 is lifted; a 0 set after the upgrade survives the next boot | +| same, repeater and room server | unversioned zeros are kept, file untouched | +| `test_sensor_role` | both limits applied; an upgraded sensor goes on forwarding floods and adverts; the step acts only from version 0 | +| `test_common_node_prefs` | the pinned file with `ver` in front; a v1.18.0 file reads as version 0; the version is read back | + +`pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -e native_sensor -e native_repeater -e native_repeater_full`: +251 of 251 cases pass. Merged onto the `integration` of 20 October (2e456f74, which +also carries Fix 8, Fix 9 and issue 10) in a trial merge without conflicts, all 13 +environments of the CI test run: 307 of 307. + +### Mutation tests + +Each against the committed change, the tree restored by copy after every run and +checked clean at the end. `pio test -e native -e native_sensor -e native_server -e native_repeater`. + +| # | mutation | caught by | +|---|---|---| +| M1 | no save after the hook changed a value | the lift is saved with the version (sensor) | +| M2 | upgrade also at the current version (`<=`) | the role is asked to upgrade only an older file (all roles) | +| M3 | current version not recorded | fresh node at current version, version after save, a 0 set after the upgrade survives, lift saved with version | +| M4 | save after every upgrade, changed or not | boot writes nothing on an empty file system, an older file is not rewritten, the repeater's and room server's file untouched (14 cases) | +| M5 | a newer version is lowered (`!=`) | a newer version is left as it is; the role is asked only for an older file | +| M6 | the sensor lifts any value, not only 0 | only a 0 is lifted, and four boot cases that see an unexpected save | +| M7 | the sensor's step also runs from version 1 | the step acts only from version 0 | +| M8 | the sensor reports no change | the lift is saved; the step acts only from version 0 | +| M9 | `ver` not persisted | the pinned file, the version read back, and six boot cases | + +All nine are caught, each by a different set of tests. In the first round M2 and M7 +survived: each was masked by the other guard (`begin()` only asks for older versions, +the sensor's step only acts from version 0). Neither was an equivalent mutant; a test +for each guard on its own closed both. + +### Build matrix + +The 18 environments of the series, built on this change and on its base (step 4f, +`78e7c02f`) with the same toolchain: 36 of 36 succeed. + +| | RAM | flash | +|---|---|---| +| sensors (Heltec v3, RAK 4631, wio-e5-mini, ProMicro) | +0 | +168 to +208 | +| repeaters and room servers (all others) | +0 | +72 to +128 | +| companions (four environments) | +0 | +0 | + +The companion numbers are equal sizes, not a byte comparison: `ServerMesh` is not part +of a companion image (shown for 4a to 4e in 0004). Why RAM stays the same with one +more byte in `NodePrefs` was not examined (padding is the likely reason, unverified). + +GCC, which this machine does not have: the fork's CI ran the host suites and fourteen +firmware builds on this change, 15 of 15 checks green. + +### Hardware + +`techo-b43b`, a T-Echo running stock simple_sensor v1.17.1, with marked images +(`FIRMWARE_BUILD_DATE`) and the diagnostic patch from `tools/refactoring` for the +I2C bus that a soft reset can leave hanging (ledger issue 14). Values read with +`tools/refactoring/scripts/server_gets.txt`, 31 lines. + +| step | image | result | +|---|---|---| +| 0 | stock v1.17.1 | `flood.max.unscoped` 0, `flood.max.advert` 0, forwarding off; `set flood.max 64` (unchanged value) to make sure the file is written | +| 1 | this change (`fm-3b58ebd7`) | the two limits read 64 and 8; the other 28 settings identical to step 0 | +| 2 | same, after `reboot` | identical to step 1 | +| 3 | same, `set flood.max.unscoped 0`, `reboot` | the 0 stays, so the version reached the flash and the step did not run again; everything else identical | +| 4 | step 4f without this change (`fmbase-78e7c02f`) | all 30 settings identical to step 3: a v1.18 build without the version reads the file | +| 5 | stock v1.17.1 | all 30 settings identical to step 3: the downgrade reads the file with `ver` in it | +| 6 | stock v1.17.1, `set flood.max.advert 0` | all 31 lines identical to step 0, the board is back where it started | + +The first try at step 5 met the hanging I2C bus: after the DFU the stock image stayed +silent. Step 4 was the remedy (an image that frees the bus), then the stock image a +second time. Which is why step 4 exists at all; it is useful as a data point anyway. + +Forwarding itself was not exercised on the air: the board is not to forward in the +real mesh. That an upgraded sensor goes on forwarding is shown by the host tests; the +board shows the values it forwards with. + +## What was not verified + +- The upgrade on any board other than the T-Echo, and on a sensor with forwarding + switched on (the step does not read `disable_fwd`, and the host tests cover both). +- Whether the ESP32 and RP2040 file systems behave like the nRF52's here; the code + path is the same `ConfigSerializer` on all of them. diff --git a/docs/refactoring/architecture-review-v1.18.md b/docs/refactoring/architecture-review-v1.18.md index e1159e9ed86..d1067d15c00 100644 --- a/docs/refactoring/architecture-review-v1.18.md +++ b/docs/refactoring/architecture-review-v1.18.md @@ -151,5 +151,5 @@ issue or fix. | 9 | nRF52 firmware is built with `-Ofast` (`-ffast-math`), so float comparisons may assume no NaN; whether a range check rejects `nan` then depends on how it is written. `set af nan` is accepted on the boards. (Found during step 3, measured on a T-Echo) | `src/helpers/CommonRadioPrefs.cpp` | | 10 | The two flood route types disagree about a denied Region. `onRecvPacket()` checks `REGION_DENY_FLOOD` itself for an un-scoped flood, while a scoped flood goes through `RegionMap::findMatch()`, whose transport-code-0 shortcut returns the wildcard before the mask is applied. So denying flood on the wildcard stops an un-scoped flood but not a scoped one carrying no code. (Found during step 4a, pinned in test_server_base) | `src/helpers/ServerMesh.cpp`, `src/helpers/RegionMap.cpp:findMatch()` | | 11 | `setRxBoostedGain()` is a virtual nobody calls: a default in `helpers/CommonCLI.h`, an override in the repeater and in the room server, and no caller anywhere. The only `radio.rxgain` handler reaches the radio driver directly, and the settings table lists the field as `CLI_NONE`, which the generic loop skips. Two dead overrides. (Found during the sensor alignment) | `src/helpers/CommonCLI.h`, `examples/simple_repeater/MyMesh.cpp`, `examples/simple_room_server/MyMesh.cpp` | -| 12 | A hop limit of zero is written to `prefs.json` as an explicit `0` and applied verbatim on load, so `set flood.max.unscoped 0` persists and the constructor default cannot protect against it. Since the limits are compared with `>=`, zero means "never forward", which is what upstream's own comment describes repeaters as running. Combined with issue 7 (no range checking on `/prefs.json`) this is how a node can boot into a state that silently forwards nothing. Measured in `test_sensor_role` | `src/helpers/ConfigSerializer.cpp`, `src/helpers/RoutingPolicy.h` | +| 12 | A hop limit of zero is written to `prefs.json` as an explicit `0` and applied verbatim on load, so `set flood.max.unscoped 0` persists and the constructor default cannot protect against it. Since the limits are compared with `>=`, zero means "never forward", which is what upstream's own comment describes repeaters as running. Combined with issue 7 (no range checking on `/prefs.json`) this is how a node can boot into a state that silently forwards nothing. Measured in `test_sensor_role`. Status: on the sensor, where these zeros were never chosen, a stored 0 is lifted once on upgrade behind a new `/prefs.json` version (0006); on the repeater and the room server a stored 0 stays a valid choice | `src/helpers/ConfigSerializer.cpp`, `src/helpers/RoutingPolicy.h` | | 13 | `Dispatcher::begin()` runs before the prefs are loaded, so the transmit budget starts at the size the constructor's airtime factor gives (1.0, half of the one hour window), not the stored one. Every budget decision calls `updateTxBudget()` first, which caps it at the stored factor's maximum or refills it at the stored rate, so this is a start-up level and probably harmless. Read from the code, not measured on a board. (Found while pinning the boot sequence in step 4f; pinned in `BootSequenceChecks.h`) | `src/Dispatcher.cpp:19-31`, `src/helpers/ServerMesh.cpp` | From 6c75a9c73152c41e4cf7924229958e77029659c1 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:21:53 +0200 Subject: [PATCH 089/136] test(server): pin which packets each role forwards before the check moves One set of expectations, test/mocks/ForwardPolicyChecks.h, included by the suite of each server role after the boot checks, so the repeater, the room server and the sensor are held to the same allowPacketForward() behaviour: - forwarding switched off refuses every route type - a fresh flood, scoped or not, is forwarded - flood.max stops both kinds of flood at the limit (compared with >=) - flood.max.unscoped stops only un-scoped floods, flood.max.advert only adverts - the hop limits do not apply to direct routes - a fresh node forwards only on the repeater and for the repeater alone: a flood without a Region is refused while a direct route is not, loop detection counts this node in the path, and (debug build) the shared checks run before the Region check. The room server and the sensor are held to the opposite: no Region needed, no loop detection. Written against the current three bodies and green on them: 9 cases in each of native_server, native_sensor and native_repeater, 10 in native_repeater_full. --- test/mocks/ForwardPolicyChecks.h | 181 ++++++++++++++++++ .../test_repeater_role/test_boot_sequence.cpp | 6 + test/test_sensor_role/test_boot_sequence.cpp | 1 + test/test_server_base/test_boot_sequence.cpp | 1 + 4 files changed, 189 insertions(+) create mode 100644 test/mocks/ForwardPolicyChecks.h diff --git a/test/mocks/ForwardPolicyChecks.h b/test/mocks/ForwardPolicyChecks.h new file mode 100644 index 00000000000..dfead19463b --- /dev/null +++ b/test/mocks/ForwardPolicyChecks.h @@ -0,0 +1,181 @@ +#pragma once + +// Which packets a server role passes on, the decision allowPacketForward() makes. +// Included by the suite of each role (repeater, room server, sensor) right after +// BootSequenceChecks.h, so it uses the role the suite named as `BootRole` and the +// fixture defined there, and the same expectations run against all three. +// +// The room server and the sensor check two things, forwarding switched off and the +// flood hop limits. The repeater checks the same two first and then two of its own, +// the Region a flood arrived on and loop detection. A suite defines +// FORWARD_ROLE_CHECKS_REGION_AND_LOOPS if its role does the latter, and +// FORWARD_ROLE_FORWARDS_BY_DEFAULT if a fresh node of the role forwards. + +#include + +namespace boot_checks { + +class ForwardProbe : public BootProbe { +public: + using BootProbe::BootProbe; + + bool forwards(const mesh::Packet* pkt) { return allowPacketForward(pkt); } + void arrivedOn(RegionEntry* r) { recv_pkt_region = r; } +}; + +class ForwardPolicyTest : public BootSequenceTest { +protected: + std::unique_ptr node; + RegionEntry* region = nullptr; + + void SetUp() override { + BootSequenceTest::SetUp(); + node.reset(new ForwardProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + node->begin(&fs); + // A node that forwards, with limits that stop nothing in the tests below and a + // Region the packet arrived on, so each test changes the one thing it is about. + node->prefs().disable_fwd = 0; + node->prefs().flood_max = 64; + node->prefs().flood_max_unscoped = 64; + node->prefs().flood_max_advert = 64; + node->prefs().loop_detect = LOOP_DETECT_OFF; + region = node->regions().putRegion("#fwd", 0); + ASSERT_NE(region, nullptr); + node->arrivedOn(region); + } + + static mesh::Packet packet(uint8_t route_type, uint8_t hops, uint8_t payload_type = 0) { + mesh::Packet pkt; + memset(&pkt, 0, sizeof(pkt)); + pkt.header = route_type | (payload_type << PH_TYPE_SHIFT); + pkt.path_len = hops; // one byte per hash at the default path hash size + return pkt; + } +}; + +TEST_F(ForwardPolicyTest, AFreshNodeForwardsOnlyIfTheRoleDoesByDefault) { + auto fresh = std::unique_ptr(new ForwardProbe(board, radio_driver, ms, rng, rtc_clock, tables)); + fresh->begin(&fs); + fresh->arrivedOn(region); + mesh::Packet pkt = packet(ROUTE_TYPE_FLOOD, 0); +#ifdef FORWARD_ROLE_FORWARDS_BY_DEFAULT + EXPECT_TRUE(fresh->forwards(&pkt)); +#else + EXPECT_FALSE(fresh->forwards(&pkt)) << "forwarding is off until an operator switches it on"; +#endif +} + +TEST_F(ForwardPolicyTest, ForwardingOffRefusesEveryRoute) { + node->prefs().disable_fwd = 1; + for (uint8_t route : {ROUTE_TYPE_TRANSPORT_FLOOD, ROUTE_TYPE_FLOOD, ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT}) { + mesh::Packet pkt = packet(route, 0); + EXPECT_FALSE(node->forwards(&pkt)) << "route type " << int(route); + } +} + +TEST_F(ForwardPolicyTest, AFreshFloodOfEitherKindIsForwarded) { + mesh::Packet unscoped = packet(ROUTE_TYPE_FLOOD, 0); + mesh::Packet scoped = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 0); + EXPECT_TRUE(node->forwards(&unscoped)); + EXPECT_TRUE(node->forwards(&scoped)); +} + +TEST_F(ForwardPolicyTest, FloodMaxStopsBothKindsOfFloodAtTheLimit) { + node->prefs().flood_max = 3; + mesh::Packet under = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 2); + mesh::Packet at_unscoped = packet(ROUTE_TYPE_FLOOD, 3); + mesh::Packet at_scoped = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 3); + EXPECT_TRUE(node->forwards(&under)); + EXPECT_FALSE(node->forwards(&at_unscoped)) << "the limit is compared with >="; + EXPECT_FALSE(node->forwards(&at_scoped)); +} + +TEST_F(ForwardPolicyTest, TheUnscopedLimitStopsOnlyUnscopedFloods) { + node->prefs().flood_max_unscoped = 2; + mesh::Packet unscoped = packet(ROUTE_TYPE_FLOOD, 2); + mesh::Packet scoped = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 2); + EXPECT_FALSE(node->forwards(&unscoped)); + EXPECT_TRUE(node->forwards(&scoped)); +} + +TEST_F(ForwardPolicyTest, TheAdvertLimitStopsOnlyAdverts) { + node->prefs().flood_max_advert = 2; + mesh::Packet advert = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 2, PAYLOAD_TYPE_ADVERT); + mesh::Packet other = packet(ROUTE_TYPE_TRANSPORT_FLOOD, 2); + EXPECT_FALSE(node->forwards(&advert)); + EXPECT_TRUE(node->forwards(&other)); +} + +TEST_F(ForwardPolicyTest, HopLimitsDoNotApplyToDirectRoutes) { + node->prefs().flood_max = 0; + node->prefs().flood_max_unscoped = 0; + node->prefs().flood_max_advert = 0; + mesh::Packet direct = packet(ROUTE_TYPE_DIRECT, 5, PAYLOAD_TYPE_ADVERT); + mesh::Packet transport_direct = packet(ROUTE_TYPE_TRANSPORT_DIRECT, 5); + EXPECT_TRUE(node->forwards(&direct)); + EXPECT_TRUE(node->forwards(&transport_direct)); +} + +#ifdef FORWARD_ROLE_CHECKS_REGION_AND_LOOPS + +TEST_F(ForwardPolicyTest, AFloodWithoutARegionIsRefused) { + node->arrivedOn(nullptr); + mesh::Packet flood = packet(ROUTE_TYPE_FLOOD, 0); + mesh::Packet direct = packet(ROUTE_TYPE_DIRECT, 0); + EXPECT_FALSE(node->forwards(&flood)); + EXPECT_TRUE(node->forwards(&direct)) << "a direct route needs no Region"; +} + +TEST_F(ForwardPolicyTest, LoopDetectionCountsThisNodeInThePath) { + // Minimal loop detection refuses a flood whose path already holds this node's + // one byte hash four times. + mesh::Packet pkt = packet(ROUTE_TYPE_FLOOD, 4); + memset(pkt.path, node->identity().pub_key[0], 4); + + EXPECT_TRUE(node->forwards(&pkt)) << "off by default"; + node->prefs().loop_detect = LOOP_DETECT_MINIMAL; + EXPECT_FALSE(node->forwards(&pkt)); + + pkt.path[0] ^= 0xFF; // three times only + EXPECT_TRUE(node->forwards(&pkt)); +} + +#ifdef MESH_DEBUG + +TEST_F(ForwardPolicyTest, TheSharedChecksComeBeforeTheRegionCheck) { + // Only the debug log shows which check refused a packet. With forwarding off, the + // Region check must not run at all. + node->arrivedOn(nullptr); + mesh::Packet flood = packet(ROUTE_TYPE_FLOOD, 0); + const char* region_line = "allowPacketForward: unknown transport code"; + + Serial.out.clear(); + EXPECT_FALSE(node->forwards(&flood)); + EXPECT_NE(Serial.out.find(region_line), std::string::npos) << "the control: forwarding on"; + + node->prefs().disable_fwd = 1; + Serial.out.clear(); + EXPECT_FALSE(node->forwards(&flood)); + EXPECT_EQ(Serial.out.find(region_line), std::string::npos) << Serial.out; +} + +#endif // MESH_DEBUG + +#else + +TEST_F(ForwardPolicyTest, ARegionIsNotNeededToForwardAFlood) { + node->arrivedOn(nullptr); + mesh::Packet flood = packet(ROUTE_TYPE_FLOOD, 0); + EXPECT_TRUE(node->forwards(&flood)) << "only the repeater refuses a flood without one"; +} + +TEST_F(ForwardPolicyTest, LoopDetectionIsNotApplied) { + node->prefs().loop_detect = LOOP_DETECT_STRICT; + mesh::Packet pkt = packet(ROUTE_TYPE_FLOOD, 4); + memset(pkt.path, node->identity().pub_key[0], 4); + EXPECT_TRUE(node->forwards(&pkt)); +} + +#endif // FORWARD_ROLE_CHECKS_REGION_AND_LOOPS + +} // namespace boot_checks diff --git a/test/test_repeater_role/test_boot_sequence.cpp b/test/test_repeater_role/test_boot_sequence.cpp index 3dc5e3e7b12..5c6fd18d839 100644 --- a/test/test_repeater_role/test_boot_sequence.cpp +++ b/test/test_repeater_role/test_boot_sequence.cpp @@ -10,4 +10,10 @@ using BootRole = MyMesh; // Of the three roles only the repeater logs the gain mode it applied (debug builds). #define BOOT_ROLE_LOGS_RX_GAIN +// The repeater forwards out of the box and checks the Region and loops on top of the +// two checks all roles share. +#define FORWARD_ROLE_FORWARDS_BY_DEFAULT +#define FORWARD_ROLE_CHECKS_REGION_AND_LOOPS + #include +#include diff --git a/test/test_sensor_role/test_boot_sequence.cpp b/test/test_sensor_role/test_boot_sequence.cpp index 25e2d2802a1..9c69fab33b4 100644 --- a/test/test_sensor_role/test_boot_sequence.cpp +++ b/test/test_sensor_role/test_boot_sequence.cpp @@ -26,3 +26,4 @@ using BootRole = BootSensor; #define BOOT_ROLE_LIFTS_UNVERSIONED_ZERO_HOP_LIMITS #include +#include diff --git a/test/test_server_base/test_boot_sequence.cpp b/test/test_server_base/test_boot_sequence.cpp index b02d183d2d7..7b1110d809c 100644 --- a/test/test_server_base/test_boot_sequence.cpp +++ b/test/test_server_base/test_boot_sequence.cpp @@ -7,3 +7,4 @@ using BootRole = MyMesh; #include +#include From e5553e1de8767c04eca17774f9f7bb18af587aff Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:24:21 +0200 Subject: [PATCH 090/136] refactor(server): share the forwarding checks every role makes What changes - ServerMesh overrides mesh::Mesh::allowPacketForward() with the two checks all three server roles make: forwarding switched off, and the flood hop limits through mesh::isFloodHopLimitExceeded(). - The room server and the sensor lose their overrides; their bodies were exactly those two checks. - The repeater keeps its override and calls the shared one first, then checks the Region a flood arrived on and loop detection as before. Why - After the sensor alignment (0006) the room server's and the sensor's bodies were the same text, and the repeater's began with the same five lines. A change to the forwarding rule would otherwise have to be made three times, which is how the sensor fell behind in the first place. A move, not a change - Diff proof against the commit before: the room server's and the sensor's removed bodies are identical to the new ServerMesh body (diff -w); the repeater's old lines 1 to 5 are identical to the shared body's first five lines, and its remaining 19 lines are unchanged after the new call. - The order is kept: the shared checks run before the Region and loop checks. A debug build test pins that the Region check is not reached when forwarding is off. - Visibility: the override is protected in ServerMesh, as it is in mesh::Mesh and in the three roles; no main.cpp calls it. - The companion radio has its own allowPacketForward() and is not a ServerMesh. Verification - The characterization tests of the previous commit pass unchanged. - pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -e native_sensor -e native_repeater -e native_repeater_full: 288 of 288 cases pass. --- examples/simple_repeater/MyMesh.cpp | 6 +----- examples/simple_room_server/MyMesh.cpp | 9 --------- examples/simple_room_server/MyMesh.h | 1 - examples/simple_sensor/SensorMesh.cpp | 9 --------- examples/simple_sensor/SensorMesh.h | 3 +-- src/helpers/ServerMesh.cpp | 9 +++++++++ src/helpers/ServerMesh.h | 7 +++++++ 7 files changed, 18 insertions(+), 26 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index c0b36e26061..2a0df0a6867 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -411,11 +411,7 @@ bool MyMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[]) } bool MyMesh::allowPacketForward(const mesh::Packet *packet) { - if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood() - && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { - return false; - } + if (!ServerMesh::allowPacketForward(packet)) return false; if (packet->isRouteFlood() && recv_pkt_region == NULL) { MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); return false; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 0970ef5b4d5..23d2d4ae762 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -283,15 +283,6 @@ const char *MyMesh::getLogDateTime() { return tmp; } -bool MyMesh::allowPacketForward(const mesh::Packet *packet) { - if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood() - && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { - return false; - } - return true; -} - void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const mesh::Identity &sender, uint8_t *data, size_t len) { if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { // received an initial request by a possible admin diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index b6806b0fd4e..931f8de3cef 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -121,7 +121,6 @@ class MyMesh : public ServerMesh { const char* getLogDateTime() override; - bool allowPacketForward(const mesh::Packet* packet) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index c802e768215..52c615941f9 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -312,15 +312,6 @@ void SensorMesh::alertIf(bool condition, Trigger& t, AlertPriority pri, const ch } } -bool SensorMesh::allowPacketForward(const mesh::Packet* packet) { - if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood() - && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { - return false; - } - return true; -} - // The hop limits the repeater and the room server start with. The constructor and // the prefs upgrade both need them. static const uint8_t DEFAULT_FLOOD_MAX_UNSCOPED = 64; diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index de002128bba..589092e9a7a 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -106,8 +106,7 @@ class SensorMesh : public ServerMesh { // so that a subclass can run the step on its own. bool upgradePrefs(uint8_t from) override; - // Mesh overrides - bool allowPacketForward(const mesh::Packet* packet) override; + // Mesh overrides (allowPacketForward() is the shared one in ServerMesh) // The sensor keeps its own retransmit delays: it draws from six discrete steps // while the other roles draw from a continuous range, and upstream still calls // that change experimental (see docs/refactoring/0005-sensor-alignment.md). diff --git a/src/helpers/ServerMesh.cpp b/src/helpers/ServerMesh.cpp index 9524020baf2..30ea87f0c24 100644 --- a/src/helpers/ServerMesh.cpp +++ b/src/helpers/ServerMesh.cpp @@ -17,6 +17,15 @@ // identical. Verified by extracting each one from all three role implementations and // comparing after normalising only the class name and the pointer star spacing. +bool ServerMesh::allowPacketForward(const mesh::Packet* packet) { + if (_prefs.disable_fwd) return false; + if (packet->isRouteFlood() + && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { + return false; + } + return true; +} + mesh::DispatcherAction ServerMesh::onRecvPacket(mesh::Packet* pkt) { if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); diff --git a/src/helpers/ServerMesh.h b/src/helpers/ServerMesh.h index 45af063b8eb..f42394578a0 100644 --- a/src/helpers/ServerMesh.h +++ b/src/helpers/ServerMesh.h @@ -140,6 +140,13 @@ class ServerMesh : public mesh::Mesh, public CommonCLICallbacks { // Mesh mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; + /** + * \brief The checks every server role makes before passing a packet on: + * forwarding switched off, and the flood hop limits (flood.max, + * flood.max.unscoped, flood.max.advert). A role with checks of its own + * overrides this and calls it first. + */ + bool allowPacketForward(const mesh::Packet* packet) override; int searchPeersByHash(const uint8_t* hash) override; void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; From a0ef4a971cfd085e37cb5262293b4330ed9a5fbd Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:34:14 +0200 Subject: [PATCH 091/136] docs(refactoring): design note 0009, one RadioLib adapter per chip family The measurement behind finding F4, step 9a (the shared receive detection) with its tests, mutation table, build matrix and board test, the upstream pull requests that collide with it, and the cut plan for the chip family templates that follow (9b to 9d). --- docs/refactoring/0009-radio-chip-adapter.md | 273 ++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/refactoring/0009-radio-chip-adapter.md diff --git a/docs/refactoring/0009-radio-chip-adapter.md b/docs/refactoring/0009-radio-chip-adapter.md new file mode 100644 index 00000000000..8c2d899ab7a --- /dev/null +++ b/docs/refactoring/0009-radio-chip-adapter.md @@ -0,0 +1,273 @@ +# 0009: One RadioLib adapter per chip family, part one: the receive detection + +Status: step 9a implemented. Behaviour change: none. Steps 9b to 9d are planned +below and need their own branches. + +## Context + +Finding F4 of the review: the chip classes in `src/helpers/radiolib` are near +copies. Measured on `integration` 2e456f74 with a script that cuts every method +out of `Custom.h` and `CustomWrapper.h`, removes comments and white +space, replaces the chip name with a placeholder and folds the family prefixes of +the IRQ macros (`RADIOLIB_SX126X_`, `RADIOLIB_LR11X0_`, `RADIOLIB_LR2021_`) into +one. Equal hashes mean equal code after that normalization. + +| Method | Groups of identical copies (lines) | +|--------|------------------------------------| +| chip `isReceiving()` | SX1262, SX1268, LLCC68, STM32WLx (42) / LR1110, LR1121 (41) / LR2021 (41) / SX1276 (6, a different method) | +| chip `startReceive()` | the six SX126x and LR11x0 chips / LR2021 | +| chip `setPreambleMillis()`, `setMaxPayloadMillis()` | all seven chips with a preamble IRQ | +| chip `begin()`, `getPacketLength()` | LR1110, LR1121 | +| wrapper `setParams()` | the six SX126x and LR11x0 chips / LR2021 / SX1276 | +| wrapper `isReceivingPacket()`, `getLastRSSI()`, `getLastSNR()` | all eight | +| wrapper `getCurrentRSSI()`, `getSpreadingFactor()` | SX126x family and SX1276 / LR1110, LR1121, LR2021 | +| wrapper `doResetAGC()` | SX126x family / LR11x0 family | +| wrapper `onSendFinished()` | LR1110, LR1121, LR2021 | + +A plain diff of the three `isReceiving()` groups shows that they are one state +machine. They differ only in how they reach the chip: the getter +(`getIrqFlags()` or `getIrqStatus()`), the clearer (`clearIrqFlags()` or +`clearIrqState()`), the three IRQ bits, and the set of bits cleared when a +reception ends (the SX126x copies also clear the sync word bit). The history +shows the cost: upstream fixed the logic in SX1262 alone (#3000, #3062), then +copied it by hand into SX1268, LLCC68, STM32WLx and LR1110 (#3063, "Unify IRQ +timeout logic across all applicable radio types"), fixed LR1110 on its own +afterwards (#3076), and added LR2021 as one more copy (#3146). + +The full chip classes are not identical. Among the SX126x copies, `std_init()` of +SX1268 and LLCC68 lacks the LDO regulator option, the Heltec register patch and two +debug lines that SX1262 has, and STM32WLx has no `std_init()` at all. Merging +those is a behaviour change for some boards and is kept out of this step. + +## What changes (step 9a) + +`src/helpers/radiolib/RxActivityTracker.h` holds the state machine. It sees three +booleans (preamble, header valid, header error) and the time, and returns one of +`IDLE`, `RECEIVING`, `CLEAR_PREAMBLE`, `CLEAR_ALL`. It also owns the two deadlines +and the setters the wrappers call. Each of the seven chip classes keeps its own +register access: + +```cpp +bool isReceiving() { + uint32_t irq = getIrqFlags(); + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, + irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { + clearIrqFlags(/* the chip's own set, unchanged */); + } + return r == RxActivityTracker::RECEIVING; +} +``` + +`setPreambleMillis()` and `setMaxPayloadMillis()` forward to the tracker. In +`src`, 340 lines go and 161 come in. + +## What deliberately does not change + +- Every quirk of the state machine is kept, including that a timer value of 0 + means "not started", so a preamble first seen at `millis() == 0` starts its + timer one poll later. A test pins it. +- Each chip clears exactly the bits it cleared before. Whether the LR chips should + also clear their sync word bit, as the SX126x chips do, is a question for a + separate fix, not for a move. +- SX1276 has no preamble IRQ and reads the modem status instead. It does not use + the tracker. +- `std_init()`, `begin()`, the wrappers and `RadioLibWrapper` are untouched. + +The one observable difference is in `MESH_DEBUG` builds: both "Clearing ... IRQ" +lines are now printed before the SPI call that clears the bits; the preamble line +used to come after it. Release builds contain neither line. + +## Alternatives considered + +- **A CRTP mixin** (`RxActivity` calling `Chip::getIrq()` and + `Chip::clearIrq()` through the derived class). It would also absorb the getter + and clearer, but needs a traits block per chip for the bit masks and the method + names, ties the logic to RadioLib types, and cannot be tested without a fake + chip class. The plain tracker does the same with less machinery and is tested + with three booleans. +- **A template on the RadioLib base class** (`CustomSX126x` and so on) + for the whole chip class. That is the goal of step 9b, but done now it would + mix the move with the `std_init()` differences above, which are behaviour. +- **A common base class with virtual IRQ accessors.** Adds a vtable call per poll + in the radio loop and a second inheritance path next to RadioLib's own; nothing + gained over the tracker. +- **A `.cpp` file for the tracker.** A firmware links one chip class, so the code + exists once either way. Header only lets the compiler inline it as before. + +## Compatibility + +Source compatible. The public methods of the chip classes keep their names and +signatures. The removed members `_preambleMillis`, `_maxPayloadMillis`, +`_activityAt`, `_headerSeen` were private. A downstream class derived from a chip +class that read them would no longer compile; none in the tree does. + +## Verification + +### Characterization tests + +`test/test_radio_chips` builds the seven real chip classes on the host against a +stand-in for the RadioLib chip classes (`test/test_radio_chips/stubs/RadioLib.h`, +IRQ bit values copied from RadioLib 7.7.1). One typed suite runs 16 expectations +against every chip, 112 cases. It was committed first and passes unchanged +before and after the move: + +```sh +pio test -e native_radio_chips +``` + +Covered: both deadlines including the deadline millisecond itself, the timer +restart after a clear, a quiet poll resetting the timers, the payload deadline +starting at the header and not the preamble, a header error with and without a +header, a header flag cleared elsewhere, the setters, the `millis()` wrap, the +zero quirk, the bits each chip clears, and the IRQ flags `startReceive()` asks +for. + +All host suites pass on the step commit: `native`, `native_ofast`, +`native_server`, `native_sensor`, `native_repeater`, `native_repeater_full`, +`native_core`, `native_chat`, `native_identity_store`, `native_node_prefs`, +`native_radio_wrappers`, `native_region`, `native_kiss_modem`, +`native_radio_chips`, 379 cases. + +### Mutation tests + +Each mutation was applied to the step, the suite run, and the change reverted. + +| # | Mutation | Failing cases | +|---|----------|---------------| +| M1 | payload deadline `>` becomes `>=` | 28, all seven chips | +| M2 | preamble deadline `>` becomes `>=` | 49, all seven chips | +| M3 | preamble timeout keeps its timer | 7, all seven chips | +| M4 | header error clears only the preamble bit | 14, all seven chips | +| M5 | a header flag cleared elsewhere goes unnoticed | 7, all seven chips | +| M6 | a quiet poll keeps the timers | 7, all seven chips | +| M7 | payload timer starts at the preamble | 7, all seven chips | +| M8 | timer value 0 no longer means "not started" | 48, all seven chips | +| M9 | SX1268 forgets the sync word bit in its clear | 3, SX1268 only | +| M10 | LR2021 swaps its preamble and header bits | 7, LR2021 only | +| M11 | default preamble deadline 66 becomes 67 ms | 28, all seven chips | + +### Build matrix + +The 18 environments of the standard matrix plus six for the chip classes the +step touches (SX1268, LR1110, LR1121, LR2021), each built twice with the same +toolchain: in a worktree on `integration` 2e456f74 and in one on the step commit ebb14737. All 48 builds succeed. Flash and +RAM as PlatformIO reports them, in bytes: + +| Environment | Platform, chip | Flash before | Flash after | Flash | RAM | +|---|---|---|---|---|---| +| Heltec_v3_repeater | ESP32-S3, SX1262 | 1174193 | 1174225 | +32 | +0 | +| Heltec_v3_room_server | ESP32-S3, SX1262 | 1169481 | 1169513 | +32 | +0 | +| Heltec_v3_companion_radio_ble | ESP32-S3, SX1262 | 1666081 | 1666113 | +32 | +0 | +| Heltec_v3_companion_radio_wifi | ESP32-S3, SX1262 | 1102253 | 1102285 | +32 | +0 | +| Heltec_v3_sensor | ESP32-S3, SX1262 | 1180701 | 1180733 | +32 | +0 | +| RAK_4631_repeater | nRF52, SX1262 | 441384 | 441336 | -48 | +0 | +| RAK_4631_room_server | nRF52, SX1262 | 434552 | 434520 | -32 | +0 | +| RAK_4631_companion_radio_ble | nRF52, SX1262 | 462084 | 462036 | -48 | +0 | +| RAK_4631_sensor | nRF52, SX1262 | 438152 | 438104 | -48 | +0 | +| PicoW_repeater | RP2040, SX1262 | 492420 | 492388 | -32 | +0 | +| wio-e5-mini_repeater | STM32WL, STM32WLx | 203832 | 203824 | -8 | +0 | +| wio-e5-mini_sensor | STM32WL, STM32WLx | 203304 | 203296 | -8 | +0 | +| RAK_3112_repeater_bridge_espnow | ESP32-S3, SX1262 | 1175509 | 1175537 | +28 | +0 | +| waveshare_rp2040_lora_repeater_bridge_rs232 | RP2040, SX1262 | 240832 | 240800 | -32 | +0 | +| ProMicro_repeater | nRF52, SX1262 | 345824 | 345776 | -48 | +0 | +| ProMicro_room_server | nRF52, SX1262 | 338920 | 338888 | -32 | +0 | +| ProMicro_companion_radio_usb | nRF52, SX1262 | 356836 | 356804 | -32 | +0 | +| ProMicro_sensor | nRF52, SX1262 | 342476 | 342428 | -48 | +0 | +| Generic_E22_sx1268_repeater | ESP32, SX1268 | 1142769 | 1142797 | +28 | +0 | +| t1000e_repeater | nRF52, LR1110 | 294360 | 294280 | -80 | +0 | +| t1000e_companion_radio_ble | nRF52, LR1110 | 308668 | 308588 | -80 | +8 | +| Waveshare_S3_LR1121_repeater | ESP32-S3, LR1121 | 1084309 | 1084357 | +48 | +0 | +| meshnology_w12_repeater | ESP32, LR2021 | 1182665 | 1182697 | +32 | +8 | +| MeshTracker_X1_repeater | nRF52, LR2021 | 300752 | 300704 | -48 | +0 | + +Flash moves by -80 to +48 bytes: the compiler inlines and orders the code a +little differently, smaller on the ARM builds, larger on the Xtensa ones. +RAM is unchanged except in two LR builds (+8). The reason is padding: the +tracker is a 16 byte object of its own, and the `bool` members the LR classes +declare after it no longer share its last word. Measured on the host with +`sizeof` against the stubs, the LR1110, LR1121 and LR2021 classes grow by 8 bytes +(16 to 24 above the RadioLib base), the SX126x classes not at all. A firmware +holds one chip object, so the cost is one padding word, accepted rather than +bending the tracker's layout to the members of its users. Why the other four LR +builds report no change was not examined; a likely cause is that the reported +size is rounded to the alignment of the data sections, which is unverified. + +### Board test + +Two boards that each run a USB companion image, marked with +`FIRMWARE_BUILD_DATE`: a Seeed T1000-E (LR1110) and a LilyGo T-Echo (SX1262), +on the same desk, 869.618 MHz, 62.5 kHz, SF8, CR8, as stored on the boards. +Each board in turn sent a zero hop advert (`CMD_SEND_SELF_ADVERT`) while the +other listened for `PUSH_CODE_LOG_RX_DATA` and `PUSH_CODE_NEW_ADVERT` carrying the +sender's public key. Three rounds per direction, first with the step +(`step9a-ebb14737`), then with `integration` (`step9a-before-2e456f74`): + +| Image | Direction | Sent | Received with sender key | SNR / RSSI per round | +|-------|-----------|------|--------------------------|----------------------| +| step | LR1110 to SX1262 | 3/3 | 3/3 | 12.5/-5, 12.5/-5, 13.0/-5 | +| step | SX1262 to LR1110 | 3/3 | 3/3 | 14.0/-4, 14.0/-4, 14.0/-4 | +| integration | LR1110 to SX1262 | 3/3 | 3/3 | 12.5/-5, 12.25/-5, 12.75/-5 | +| integration | SX1262 to LR1110 | 3/3 | 3/3 | 14.25/-4, 14.0/-4, 14.0/-4 | + +Settings, the 16 `get` values of `tools/refactoring/scripts/comp_gets.txt` and +the contact lists (350 and 68 keys) read the same at the start and at the end. +Both boards went back to the official 1.18.0 BLE companion release, and both +paired phones reconnected over BLE. + +This shows that reception and transmission work on both families with the step. +It does not show the receive detection itself at work on air (a transmission held +back while a packet arrives); that part rests on the host tests. + +### What was not verified on hardware + +No hardware evidence for SX1268, LLCC68, STM32WLx, LR1121 and LR2021. They +build (matrix above, except LLCC68, which no variant in the tree uses and which +only the host suite compiles) and pass the host suite. + +## Upstream collisions + +Checked on 20 October 2026 against open pull requests on `meshcore-dev/MeshCore` +that touch `src/helpers/radiolib`: + +- **#3512** (draft, LR2021 continuous mode race) edits the LR2021 copy of + `isReceiving()`: it drops the header error branch and the early return after a + cleared header flag. If it lands, LR2021 diverges from the other chips on + purpose, and the tracker needs either an option for it or LR2021 keeps its own + copy. Semantic collision with this step. +- **#3638** (dual radio profiles) adds about 380 lines to `CustomSX1262.h` and + about 240 to `CustomLR1110.h` but leaves `isReceiving()` alone. Textual + collision here, a heavy one for steps 9b to 9d. +- **#2131** (error aware SX126x receive hook) adds a helper to all four SX126x + chip classes. Textual here, relevant to 9b. +- **#3496** (KISS modem gain control) changes `setRxBoostedGainMode()` in the LR + chips and the reset helpers. Relevant to 9c and 9d. +- **#2682**, **#2999**, **#2944** add further copies of the four members in new + or older chip classes; they would need the tracker instead. + +## Cut plan for the rest of F4 + +Each step is its own branch with characterization first, as here. + +- **9b: SX126x chip family.** One template `CustomSX126x` for SX1262, + SX1268, LLCC68, with `std_init()` taken from SX1262. The LDO option, register + patch and debug lines that SX1268 and LLCC68 lack are a behaviour change for + those boards and need a decision first; until then the template keeps them + per chip behind a flag. `CustomSTM32WLx` stays a class of its own (different + module type, no `std_init()`), using the tracker as now. +- **9c: one wrapper per family.** `setParams()`, `isReceivingPacket()`, + `getLastRSSI()`, `getLastSNR()`, `getCurrentRSSI()`, `getSpreadingFactor()`, + `doResetAGC()` are identical per family. A wrapper template on the chip class + replaces the casts in every method. Needs host tests through the wrappers, + which `test/test_radio_chips/stubs` can carry. +- **9d: LR11x0 chip family.** `begin()` and `getPacketLength()` of LR1110 and + LR1121 are identical; LR1121 adds its band dependent power handling. A template + for the shared part, LR1121 deriving from it. + +Flash cost expected for 9b to 9d: none or a few bytes, because a firmware still +links exactly one chip class; the gain is in the source, not in the image. From 675245a97b48d4afa99bfa78d1088f43c54167d8 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:34:57 +0200 Subject: [PATCH 092/136] ci: run the chip class receive detection suite native_radio_chips was only run locally; the unit test workflow lists its environments one by one, so add it there to run it under GCC as well. --- .github/workflows/run-unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6fca397a34e..fb0545fef61 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_chat -e native_identity_store -e native_node_prefs -e native_radio_wrappers -e native_server -e native_sensor -e native_repeater -e native_repeater_full -e native_region -vv + run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_chat -e native_identity_store -e native_node_prefs -e native_radio_wrappers -e native_server -e native_sensor -e native_repeater -e native_repeater_full -e native_region -e native_radio_chips -vv - name: Upload Test Results # Upload test results even if the test step failed. From b4f6f4a88ef42dcbbfee10c77a9fc48db9b824c5 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:39:52 +0200 Subject: [PATCH 093/136] docs(refactoring): the note for step 4g, the shared forwarding checks - docs/refactoring/0008-forward-policy.md: context, what moves and what does not, the alternatives, the characterization table, the diff proof (and the extraction mistake that was caught on the way), the mutation table, the host suites, and the build matrix against the flood hop limits branch. - 0007's list of next candidates points to 0008 for allowPacketForward(). - README: step 4 now runs from 4a to 4g. --- docs/refactoring/0007-boot-sequence.md | 6 +- docs/refactoring/0008-forward-policy.md | 140 ++++++++++++++++++++++++ docs/refactoring/README.md | 2 +- 3 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 docs/refactoring/0008-forward-policy.md diff --git a/docs/refactoring/0007-boot-sequence.md b/docs/refactoring/0007-boot-sequence.md index 773601c3366..f6332470197 100644 --- a/docs/refactoring/0007-boot-sequence.md +++ b/docs/refactoring/0007-boot-sequence.md @@ -388,10 +388,8 @@ test of this step is a before and after on one board with the same stored settin ## Next candidates -- **`allowPacketForward()`.** The leading `disable_fwd` check is the same in all three - roles. The room server's and the sensor's whole bodies are identical only once the - flood limit alignment (`refactor/sensor-flood-limits`) is on the branch; today the - sensor checks `flood_max` alone. Not touched here. +- **`allowPacketForward()`.** Done in step 4g, [0008](0008-forward-policy.md), once + the sensor applied the flood hop limits. - **The `CommonCLI _cli` member.** Constructed with the same arguments in all three roles. Moving it into the base removes `loadPrefs()` and the three copies of `savePrefs()`, at the price of the board in `ServerMesh`'s constructor. diff --git a/docs/refactoring/0008-forward-policy.md b/docs/refactoring/0008-forward-policy.md new file mode 100644 index 00000000000..1babda5917a --- /dev/null +++ b/docs/refactoring/0008-forward-policy.md @@ -0,0 +1,140 @@ +# 0008: The forwarding checks move into the shared base + +Status: implemented. Step 4g of the series described in +[0004](0004-shared-server-base.md), stacked on the sensor's flood hop limits +([0006](0006-sensor-flood-limits.md)), which made this step possible. + +It is a move, not a change: the characterization tests written before it pass after +it without being touched. + +## Context + +`allowPacketForward()` is the hook `mesh::Mesh` asks before it passes a received +packet on. Each server role had its own copy. Once the sensor applied the flood hop +limits like the other roles (0006), the room server's and the sensor's bodies were +the same six lines, and the repeater's began with the same five: + +| role | lines | made of | +|---|---|---| +| room server | 6 | forwarding switched off; the flood hop limits | +| sensor | 6 | the same text | +| repeater | 24 | the same first five lines; the Region a flood arrived on; loop detection | + +This is how the sensor fell behind before: the hop limits reached the repeater and +the room server and not the third copy. + +## What changes + +- `ServerMesh` overrides `allowPacketForward()` with the two checks every role makes: + `disable_fwd`, and `mesh::isFloodHopLimitExceeded()` for floods. +- The room server and the sensor lose their overrides. +- The repeater keeps its override, calls the shared one first, and then checks the + Region and loops exactly as before. + +What does not change: + +- **The order.** The shared checks run first in the repeater, as they did. A debug + build test pins that the Region check is not reached when forwarding is off. +- **Visibility.** The override is protected in `ServerMesh`, as it is in + `mesh::Mesh` and was in the three roles. No `main.cpp` calls it (checked by grep: + no match in any of the three). +- **The companion radio**, which has its own `allowPacketForward()` and is not a + `ServerMesh`. + +## Alternatives + +- **A hook for the repeater's extra checks** (`allowFloodForward()` or similar, + called by the shared body) instead of an override that calls the base. Rejected for + now: one role has extra checks, and an override that calls its base first says the + same with less machinery. If a second role ever needs its own checks, the hook is a + small next step. +- **Move the Region check into the base too.** Rejected: it would change what the + room server and the sensor do with a flood that arrives without a Region (today + they forward it, see the tests). That is a decision about behaviour, not a move. +- **Leave it.** Rejected: the three copies are the reason the sensor drifted. + +## Verification + +### Characterization first + +`test/mocks/ForwardPolicyChecks.h`, in its own commit before the move, included by the +suite of each role after the boot checks, so one set of expectations holds all three: + +| | what is pinned | roles | +|---|---|---| +| switch | forwarding off refuses every route type; a fresh node forwards only on the repeater | all | +| limits | a fresh flood of either kind is forwarded; `flood.max` stops both kinds at the limit (`>=`); `flood.max.unscoped` only un-scoped floods; `flood.max.advert` only adverts; no hop limit applies to a direct route | all | +| Region | a flood without a Region is refused, a direct route is not | repeater | +| loops | minimal loop detection refuses a flood holding this node's hash four times, not three; off by default | repeater | +| order | with forwarding off the Region check is not reached (debug log) | repeater, debug build | +| the others | no Region needed for a flood; loop detection not applied | room server, sensor | + +Green on the old bodies: 9 cases in each of `native_server`, `native_sensor` and +`native_repeater`, 10 in `native_repeater_full`. + +### Diff proof + +Function bodies extracted from the commit before the move and from the move (an awk +range anchored at `^bool ::allowPacketForward(`): + +- the room server's and the sensor's removed bodies are identical to the new + `ServerMesh` body (`diff -w`, no output); +- the repeater's old lines 1 to 5 are identical to the shared body's first five; its + old lines 6 to 24 are identical to its new lines 2 to 20, after the new first line + `if (!ServerMesh::allowPacketForward(packet)) return false;`. + +The first attempt at the extraction was wrong: an unanchored pattern took the new +call line for the start of a function and skipped it. Caught because the result +showed the repeater's body starting at the Region check. + +### Mutation tests + +Each against the committed move, the tree restored by copy after every run and +checked clean at the end. `pio test -e native_server -e native_sensor -e native_repeater -e native_repeater_full`. + +| # | mutation | caught by | +|---|---|---| +| G1 | the base ignores `disable_fwd` | forwarding off refuses every route, a fresh node forwards only on the repeater, the order test, the sensor's own switch test (all four environments) | +| G2 | the base ignores the hop limits | the three limit tests and the sensor's three (all four) | +| G3 | the hop limits also apply to direct routes | no hop limit on a direct route (all four) | +| G4 | the un-scoped and the advert limit swapped | the un-scoped and the advert tests, shared and sensor | +| G5 | `flood.max` ignored | `flood.max` stops both kinds, and the sensor's own | +| G6 | the repeater does not call the shared checks | five shared tests, repeater environments only | +| G7 | the repeater checks the Region before the shared checks | the order test, `native_repeater_full` only | + +All seven are caught, each by a different set of tests. G7 only by the debug build, +which is what that test is for: without the log the two orders refuse the same +packets. + +### Host suites + +`pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_server -e native_sensor -e native_repeater -e native_repeater_full`: +288 of 288 cases pass, 37 of them new. + +### Build matrix + +The 18 environments of the series, built on the move (`e5553e1d`) and on its base +(`24ce8b5f`, the flood hop limits) with the same toolchain: 36 of 36 succeed. RAM is +unchanged everywhere. + +| | flash | +|---|---| +| repeaters, bridges included (seven environments, four platforms) | +32 to +56 | +| room servers and sensors (seven environments) | -4 to +8 | +| companions (four environments) | +0 | + +The repeater pays for one extra call, now a call into the base rather than inline +code; the room server and the sensor lose their override and use the base's, which +costs or saves a few bytes depending on the platform. The companion numbers are equal +sizes, not a byte comparison. + +GCC: the fork's CI ran the host suites and fourteen firmware builds on the move, +15 of 15 checks green. + +## Next candidates + +- **The `CommonCLI _cli` member**, constructed with the same arguments in all three + roles (see 0007). +- **A Region check for floods in the room server and the sensor**, if they should + refuse a flood without a Region as the repeater does. A behaviour decision, not a + move. diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index be3cb611817..8c0f1731144 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -50,7 +50,7 @@ The full analysis, with file and line references for every finding, is in | 1 | Share the radio and GPS settings between the server roles' and the companion's NodePrefs | [0001](0001-shared-radio-gps-prefs.md) | ready for review | | 2 | Let the core read its tuning values from the radio settings instead of a getter per role | [0002](0002-core-reads-radio-prefs.md) | ready for review | | 3 | Drive the radio settings (JSON, CLI, key/value store) from one table | [0003](0003-radio-settings-table.md) | ready for review | -| 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4f; the boot sequence is the last) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md) | ready for review | +| 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4g; the boot sequence and the forwarding checks are the last two) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md), [0008](0008-forward-policy.md) | ready for review | Planned next steps (each needs its own design note before code), in order of expected benefit: one generic variant target From 0674597dd013eb50116a064d3e6c4dffd34e43ba Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:56:46 +0200 Subject: [PATCH 094/136] docs(refactoring): design note 0008, one generic variant target (cut A) The measurement of the copies in variants/ (with the script added in the first commit of this step), the cut plan from one shared radio_new_identity() to a generic target built from a board description, the alternatives considered, the upstream pull requests this collides with, and the evidence for cut A: host tests, mutation table and the build of 102 environments before and after. --- docs/refactoring/0008-variant-target.md | 447 ++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 docs/refactoring/0008-variant-target.md diff --git a/docs/refactoring/0008-variant-target.md b/docs/refactoring/0008-variant-target.md new file mode 100644 index 00000000000..105ac396213 --- /dev/null +++ b/docs/refactoring/0008-variant-target.md @@ -0,0 +1,447 @@ +# 0008: One generic variant target built from a board description + +Status: cut A (one shared `radio_new_identity()`) implemented. Cuts B to E are +planned and need their own review before code. Behaviour change: none. + +## Context + +Every board is a directory in `variants/` with a `target.h`, which declares the +globals the roles use (`board`, `radio_driver`, `rtc_clock`, `sensors`, and +`display` and `user_btn` where there is a screen), and a `target.cpp`, which +defines them and the functions `radio_init()` and `radio_new_identity()`. Each new +board starts as a copy of the closest existing one, so a change to what a variant +provides has to be made in every copy. The review lists the cost of that in +release 1.18: the remote RX gain toggle touched 162 variant files, moving it into +the radio wrappers 23 days later touched 156 again, and one line added to every +`radio_init()` touched 64 (see [architecture-review-v1.18.md](architecture-review-v1.18.md), F3). + +### Measured on the commit this step started from + +Measured with `tools/refactoring/variant_duplication.py` (in this step), which +extracts every function a `target.cpp` defines and compares the bodies twice: with +only runs of spaces collapsed ("strict"), and with comments and all white space +removed ("normalized"). Run it from the repository root to repeat the numbers. + +- 92 variant directories, 89 with a `target.cpp`, 4,690 lines in all of them. + The three without one (`m5stack_unit_c6l`, `xiao_c6`, `wio-tracker-l1-eink`) + are covered under "Observations"; the two C6 ones carry two more copies of the + same body in their board file. +- 618 firmware environments (631 with the host test environments). + +| function | files | strict bodies | normalized bodies | largest identical group | +|---|---:|---:|---:|---:| +| `radio_new_identity()` | 89 | 4 | 2 | 87 | +| `radio_init()` | 89 | 39 | 29 | 28 | +| `radio_set_tx_power()` | 3 | 1 | 1 | 3 | +| `radio_get_rng_seed()` | 2 | 1 | 1 | 2 | +| `radio_set_params()` | 2 | 1 | 1 | 2 | + +`radio_new_identity()` in detail: + +| copies | body | +|---:|---| +| 76 | `RadioNoiseListener rng(radio); return mesh::LocalIdentity(&rng);` with the comment `// create new random identity` | +| 10 | the same without the comment | +| 1 | the same with the opening brace on its own line | +| 2 | `ESP_RNG rng; return mesh::LocalIdentity(&rng);` (`generic_espnow`, `sensecap_indicator-espnow`, which have no LoRa radio and use the ESP32 hardware RNG) | + +The review counted 74 identical copies "ignoring repeated spaces"; the strict count +here is 76, and 87 once the comment and line breaks are ignored. Either way the 87 +compile to the same thing. + +The FEM gain handlers of the board classes, compared with the board class name +replaced by a placeholder: + +| function | HeltecV4, HeltecV4R8, T096, HeltecTrackerV2 | TBeam1W | StationG3 | WioTrackerL1 | +|---|---|---|---|---| +| `attachDynamicPrefs()` | one body (217 characters normalized) | own (fan prefs) | own | own | +| `handleCommand()` | one body (999 characters normalized) | own | own | own | + +What else repeats across `target.cpp`, by normalized line (each counted once per +file): `WRAPPER_CLASS radio_driver(radio, board);` in 87 files, +`AutoDiscoverRTCClock rtc_clock(fallback_clock);` in 79, `rtc_clock.begin(Wire);` in +76, `#ifdef DISPLAY_CLASS` in 72, `DISPLAY_CLASS display;` in 57, +`MomentaryButton user_btn(PIN_USER_BTN, 1000, true);` in 47, the GPS sensor manager +pair in 42 or 43. Replacing the board class name, 73 of the 89 files are still +different from each other; the largest group of whole-file copies is 4 +(`lilygo_t_impulse_plus`, `lilygo_techo`, `lilygo_techo_lite`, `thinknode_m6`). So +the copies are of building blocks, not of whole files, and the blocks are what a +generic target can share. + +## Goal and cut plan + +The goal from the [README](README.md): one generic variant target built from a board +description, so a new board says what it has (radio chip and pins, RTC, sensors, +display, button) and writes code only for what is really its own. + +Each cut is one concern, one pull request, and keeps every existing and downstream +variant compiling. The shared code goes into `src/helpers/radiolib/RadioLibTarget.cpp` +(later cuts may add a sibling for non-RadioLib parts) and uses weak definitions, so +a variant that keeps its own definition still links and its own wins (see "Why +weak" below). + +| cut | what | files touched | status | +|---|---|---|---| +| A | one `radio_new_identity()` for all RadioLib variants | 87 `target.cpp`, 1 new source, 1 header | this step | +| B | one class for the FEM gain handlers of HeltecV4, HeltecV4R8, T096, HeltecTrackerV2 | 4 board classes, 1 new helper | planned | +| C | a shared default `radio_init()` for the largest groups (28 + 18 + 5 + 4 + 3 of 89) | about 58 `target.cpp` | planned | +| D | the radio, wrapper and RTC globals from the board's build flags | about 87 `target.cpp` | needs a decision, see below | +| E | sensors, display and button from the board description | the rest | needs a decision | + +Cut B in more detail: the four bodies are the same once the class name is replaced, +and they all read and write the `fem_rxgain` key through `_prefs`. The open upstream +pull request meshcore-dev/MeshCore#3496 changes exactly those `setByKey` lines to +`if (_prefs) _prefs->setByKey(...)`, so a shared class has to accept a null prefs +pointer and the note for cut B has to say which of the two lands first. + +Cut C: the 28 bodies of the largest group are `rtc_clock.begin(Wire); return +radio.std_init(&SPI);`. The groups differ in whether there is a `fallback_clock` to +start and which SPI object is passed, which is board description, so cut C and D +are related: C can share the bodies that need no new description, D introduces the +description. + +What "board description" means is the open question for D and E. The options as +they look today: (1) the build flags and `target.h` macros the variants already use +(`RADIO_CLASS`, `WRAPPER_CLASS`, `P_LORA_*`, `DISPLAY_CLASS`) plus a few new ones, +read by the generic target; (2) a small `constexpr` struct per board in `target.h`; +(3) a data file per board from which the build generates `target.cpp`. Option 1 +changes the least and keeps the PlatformIO files as the one place a board is +described; option 3 would be a new build step and is likely rejected. This is a +product decision and is asked before cut D starts. + +## Cut A: one `radio_new_identity()` + +### What changes + +- `src/helpers/radiolib/RadioLibTarget.cpp` (new) defines `radio_new_identity()` + once, as a weak function, compiled only where `WRAPPER_CLASS` is set: + ```cpp + __attribute__((weak)) mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio_driver); + return mesh::LocalIdentity(&rng); + } + ``` +- `RadioNoiseListener` gets a constructor from a `RadioLibWrapper`, and + `RadioLibWrapper` names it a friend, so the listener can read the wrapped radio + without a new public accessor. +- The 87 identical copies are deleted from `variants/*/target.cpp`. The diff in + `variants/` is deletions only: 484 lines (each copy plus the blank line that + separated it), no line added or changed, line endings kept (3 of the files use + CRLF). +- The declaration stays in every `target.h`; the roles get it from there. +- The two ESP-NOW variants keep their own definition and are not touched. + +### Why it is the same function + +- `RadioLibTarget.cpp` is built in every firmware environment: `helpers/radiolib/*.cpp` + is part of `arduino_base.build_src_filter`, which every variant extends. Checked + with `pio project config --json-output` over all 631 environments: the 612 that set + `WRAPPER_CLASS` all build `helpers/radiolib/*.cpp`. +- `WRAPPER_CLASS` is what tells a RadioLib variant apart. The six environments that + build `helpers/radiolib/*.cpp` without it are the five ESP-NOW ones, which define + their own `radio_new_identity()`, and `Generic_E22_kiss_modem` (see + "Observations"). +- The copies used the variant's global `radio`, which no `target.h` declares, so the + shared code cannot name it. It uses `radio_driver` instead, which every `target.h` + declares. In all 87 variants (and only there) `target.cpp` contains + `WRAPPER_CLASS radio_driver(radio, board);`, so `radio_driver._radio` is `&radio` + and the listener reads the same `PhysicalLayer` as before. Checked by script: the + set of files with `RadioNoiseListener rng(radio)` and the set with that + constructor line are the same 87. +- No copy was inside an `#if` block (checked by script), so no environment loses a + definition it had or gains one it did not have. + +### Why weak + +A strong shared definition would make every remaining copy a duplicate symbol +error. Remaining copies exist in three places: the ESP-NOW variants, the variants in +downstream forks, and the open upstream pull requests that add a board (about 50 of +the 120 open pull requests whose diff was read carry a new `target.cpp` with its own +copy, see "Upstream"). With a weak definition all of them keep linking and their own +definition takes precedence. MeshCore already uses weak definitions for the same +reason in `src/armstubs.cpp`. + +The cost: a variant that defines the function by mistake silently replaces the +shared one instead of failing the build. This is the same situation as today, where +each variant has its own. + +Limit: weak precedence works because a variant's `target.cpp` is built as an object +of the firmware, as all variants are. A strong definition placed in a library +archive would not be pulled in, because the weak one already satisfies the +reference. No variant does that. + +### Alternatives considered + +- **A helper the variants call** (`return new_identity_from(radio);` in each + `target.cpp`). Rejected: it keeps 87 definitions, and the next change to the + signature touches 87 files again. +- **A strong definition and delete every copy.** Rejected: it breaks every downstream + variant and every open pull request that adds a board, which rule 4 of the series + (source compatible) excludes. +- **An opt-in macro per variant** (`#define TARGET_SHARED_NEW_IDENTITY`). Rejected: + it touches every `target.h` as well, and a variant that forgets it fails to link + with an error that does not say why. +- **Declare `extern RADIO_CLASS radio;` in the shared file.** Rejected: it relies on + a name that is not part of any variant's declared interface; `radio_driver` is. +- **A public `getRadio()` on `RadioLibWrapper`.** Rejected for now: it widens the + wrapper's interface for one caller. A friend keeps the change local; if cut D needs + the radio from the driver in more places, an accessor can be proposed there. +- **Move identity creation into the wrapper** (`radio_driver.newIdentity()`). + Rejected: identities are not the radio driver's concern. + +### Host tests + +`native_variant_target` (new) builds the real `RadioLibWrappers.cpp` and +`RadioLibTarget.cpp` against the RadioLib stand-in, with `test/mocks/radiolib_variant` +standing in for the `target.h` of a RadioLib variant. The Ed25519 constructor is +replaced by one that keeps the seed, which is the only input the real key derivation +has, so a test sees exactly what was drawn. + +The first commit pins the variants' body (copied verbatim into the test) before +anything moves: + +- the seed is 32 bytes, each the radio's random byte XOR the low byte of Arduino's + `random(0, 256)`, in order, +- two radios with different noise give different identities. + +The second commit adds, with the first two tests unchanged: + +- the shared definition draws the same seed as the variants' copy, +- it listens on the radio that `radio_driver` wraps and not on another one, +- a listener built from the driver produces the same bytes as one built from the + radio. + +`native_variant_target_override` (new) defines its own `radio_new_identity()` and +checks that it is the one called; it does not link if the shared definition is not +weak. + +The `PhysicalLayer` stand-in gets a per instance `random_byte` (default `0x5A`, the +value it returned so far), so a test can tell two radios apart. All 15 host +environments pass on both commits: 269 test cases on the first, 273 on the second. + +Mutation tests, each of which must fail: + +| mutation | result | +|---|---| +| M1: shared definition uses Arduino `random()` only, not the radio | 2 tests fail | +| M2: shared definition not weak | `native_variant_target_override` does not link (`duplicate symbol 'radio_new_identity()'`) | +| M3: shared definition guarded by `RADIO_CLASS` instead of `WRAPPER_CLASS` | `native_variant_target` does not link (`radio_new_identity()` undefined) | +| M4: `RadioNoiseListener` drops the `random()` mix | 2 tests fail | +| M5: `RadioNoiseListener` fills one byte less | 2 tests fail | + +### Builds + +102 environments were built from the commit this step started from (`2e456f74`, +`integration`) and from the code commit of this step (`248b1d12`), each in its own +worktree, with the same toolchain. 618 firmware environments is too many to build +twice for a change that is the same in every variant, so the set is chosen to cover +every file this step touches, every way the shared file can compile, and the usual +matrix of the series: + +- the 18 environments every step of the series is built for (all roles, all + platforms), +- one environment for each of the 87 variants whose `target.cpp` this step edits + (a repeater where the variant has one), so every edited file is compiled and + linked, +- one environment for each of the 13 pairs of `WRAPPER_CLASS` and platform that + occur in the 612 RadioLib environments, so `RadioLibTarget.cpp` and the changed + `RadioLibWrappers.h` are compiled against every wrapper class on every toolchain + (only `Generic_E22_sx1268_repeater` had to be added for that), +- `Xiao_C6_repeater_` and `M5Stack_Unit_C6L_repeater`, the variants without a + `target.cpp` (see below), and `Generic_ESPNOW_repeatr` for the variants without + `WRAPPER_CLASS`. + +Platforms in the set: 45 ESP32 (S3, C3, C6 and classic), 45 nRF52, 5 STM32WL, 7 RP2040. + +Result: + +- 100 build on both commits. 2 fail on both commits with the same errors, so not + because of this step: + - `ThinkNode_M8_repeater` does not link (`EnvironmentSensorManager` undefined, the + variant does not build `helpers/sensors`). Its `target.cpp` compiles on both + commits, and `radio_new_identity()` is not among the undefined symbols, so the + shared definition is what it links. It is the only edited variant without an + environment that links; the defect is the subject of a separate fix. + - `Generic_ESPNOW_repeatr` does not compile (`P_LORA_DIO_1` and `P_LORA_NSS` + undeclared in `ESP32Board.h`). The other three ESP-NOW environments tried + (`Generic_ESPNOW_room_svr`, `Generic_ESPNOW_comp_radio_usb`, + `SenseCapIndicator-ESPNow_comp_radio_usb`) fail the same way on both commits. + That `RadioLibTarget.cpp` stays empty there rests on the configuration check + above (none of the ESP-NOW environments sets `WRAPPER_CLASS`), not on a build. +- RAM is unchanged in all 100. +- Flash changes by -16 to +16 bytes: 38 environments +0, 11 smaller (-8 or -16), 51 + larger (+4 to +16). The function now loads the radio from `radio_driver` instead + of taking the address of `radio`, which is one load more, and it sits in another + translation unit, so the alignment padding around it moves. Measured with `nm`: + `radio_new_identity()` is 0x24 bytes on `RAK_4631_repeater` before and after, + 0x16 and 0x1b bytes on `Heltec_v3_repeater`, and it is a weak symbol (`W`) in the + new firmware where it was a normal one (`T`). +- The two C6 variants define `radio_new_identity()` in their board file, so they have + their own definition next to the weak shared one. Both build to exactly the same + size as before, and `nm` shows their own definition (`T`) in the firmware: the + precedence the override test checks on the host, on the RISC-V toolchain. + +
+All 102 environments + +| Environment | Platform | Wrapper | Flash before | Flash after | Flash delta | RAM delta | +|---|---|---|---:|---:|---:|---:| +| `Heltec_v3_repeater` | espressif32 | CustomSX1262Wrapper | 1,174,193 | 1,174,209 | +16 | +0 | +| `Heltec_v3_room_server` | espressif32 | CustomSX1262Wrapper | 1,169,481 | 1,169,497 | +16 | +0 | +| `Heltec_v3_companion_radio_ble` | espressif32 | CustomSX1262Wrapper | 1,666,081 | 1,666,097 | +16 | +0 | +| `Heltec_v3_companion_radio_wifi` | espressif32 | CustomSX1262Wrapper | 1,102,253 | 1,102,269 | +16 | +0 | +| `Heltec_v3_sensor` | espressif32 | CustomSX1262Wrapper | 1,180,701 | 1,180,717 | +16 | +0 | +| `RAK_4631_repeater` | nordicnrf52 | CustomSX1262Wrapper | 441,384 | 441,368 | -16 | +0 | +| `RAK_4631_room_server` | nordicnrf52 | CustomSX1262Wrapper | 434,552 | 434,552 | +0 | +0 | +| `RAK_4631_companion_radio_ble` | nordicnrf52 | CustomSX1262Wrapper | 462,084 | 462,068 | -16 | +0 | +| `RAK_4631_sensor` | nordicnrf52 | CustomSX1262Wrapper | 438,152 | 438,136 | -16 | +0 | +| `PicoW_repeater` | platform-raspberrypi.git | CustomSX1262Wrapper | 492,420 | 492,420 | +0 | +0 | +| `wio-e5-mini_repeater` | ststm32 | CustomSTM32WLxWrapper | 203,832 | 203,832 | +0 | +0 | +| `wio-e5-mini_sensor` | ststm32 | CustomSTM32WLxWrapper | 203,304 | 203,304 | +0 | +0 | +| `RAK_3112_repeater_bridge_espnow` | espressif32 | CustomSX1262Wrapper | 1,175,509 | 1,175,521 | +12 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | platform-raspberrypi.git | CustomSX1262Wrapper | 240,832 | 240,832 | +0 | +0 | +| `ProMicro_repeater` | nordicnrf52 | CustomSX1262Wrapper | 345,824 | 345,824 | +0 | +0 | +| `ProMicro_room_server` | nordicnrf52 | CustomSX1262Wrapper | 338,920 | 338,920 | +0 | +0 | +| `ProMicro_companion_radio_usb` | nordicnrf52 | CustomSX1262Wrapper | 356,836 | 356,836 | +0 | +0 | +| `ProMicro_sensor` | nordicnrf52 | CustomSX1262Wrapper | 342,476 | 342,476 | +0 | +0 | +| `Ebyte_EoRa-S3_Repeater` | espressif32 | CustomSX1262Wrapper | 1,107,901 | 1,107,913 | +12 | +0 | +| `GAT562_30S_Mesh_Kit_repeater` | nordicnrf52 | CustomSX1262Wrapper | 410,992 | 410,992 | +0 | +0 | +| `GAT562_Mesh_EVB_Pro_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,220 | 365,220 | +0 | +0 | +| `GAT562_Mesh_Tracker_Pro_repeater` | nordicnrf52 | CustomSX1262Wrapper | 380,684 | 380,700 | +16 | +0 | +| `GAT562_Mesh_Watch13_companion_radio_ble` | nordicnrf52 | CustomSX1262Wrapper | 397,480 | 397,480 | +0 | +0 | +| `Generic_E22_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,142,973 | 1,142,985 | +12 | +0 | +| `Heltec_ct62_repeater` | espressif32 | CustomSX1262Wrapper | 1,151,498 | 1,151,490 | -8 | +0 | +| `Heltec_E213_repeater` | espressif32 | CustomSX1262Wrapper | 1,161,701 | 1,161,713 | +12 | +0 | +| `Heltec_E290_repeater` | espressif32 | CustomSX1262Wrapper | 1,159,617 | 1,159,629 | +12 | +0 | +| `Heltec_mesh_solar_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,184 | 365,184 | +0 | +0 | +| `heltec_rc32_repeater` | espressif32 | CustomSX1262Wrapper | 1,168,277 | 1,168,293 | +16 | +0 | +| `Heltec_t096_repeater` | nordicnrf52 | CustomSX1262Wrapper | 413,888 | 413,904 | +16 | +0 | +| `Heltec_t1_repeater` | nordicnrf52 | CustomSX1262Wrapper | 412,948 | 412,964 | +16 | +0 | +| `Heltec_t114_repeater` | nordicnrf52 | CustomSX1262Wrapper | 397,884 | 397,884 | +0 | +0 | +| `Heltec_T190_repeater_` | espressif32 | CustomSX1262Wrapper | 1,138,889 | 1,138,897 | +8 | +0 | +| `Heltec_tower_v2_repeater` | nordicnrf52 | CustomSX1262Wrapper | 309,128 | 309,144 | +16 | +0 | +| `Heltec_Wireless_Tracker_repeater` | espressif32 | CustomSX1262Wrapper | 1,157,053 | 1,157,065 | +12 | +0 | +| `heltec_tracker_v2_repeater` | espressif32 | CustomSX1262Wrapper | 1,225,429 | 1,225,437 | +8 | +0 | +| `Heltec_v2_repeater` | espressif32 | CustomSX1276Wrapper | 1,151,425 | 1,151,437 | +12 | +0 | +| `heltec_v4_expansionkit_repeater` | espressif32 | CustomSX1262Wrapper | 1,172,333 | 1,172,341 | +8 | +0 | +| `heltec_v4_r8_repeater` | espressif32 | CustomSX1262Wrapper | 1,175,789 | 1,175,793 | +4 | +0 | +| `Heltec_Wireless_Paper_repeater` | espressif32 | CustomSX1262Wrapper | 1,121,353 | 1,121,365 | +12 | +0 | +| `ikoka_handheld_nrf_e22_30dbm_repeater` | nordicnrf52 | CustomSX1262Wrapper | 357,720 | 357,720 | +0 | +0 | +| `ikoka_nano_nrf_22dbm_repeater` | nordicnrf52 | CustomSX1262Wrapper | 360,616 | 360,616 | +0 | +0 | +| `ikoka_stick_nrf_22dbm_repeater` | nordicnrf52 | CustomSX1262Wrapper | 373,192 | 373,192 | +0 | +0 | +| `KeepteenLT1_repeater` | nordicnrf52 | CustomSX1262Wrapper | 321,612 | 321,628 | +16 | +0 | +| `LilyGo_T3S3_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,107,949 | 1,107,961 | +12 | +0 | +| `LilyGo_T3S3_sx1276_repeater` | espressif32 | CustomSX1276Wrapper | 1,105,021 | 1,105,033 | +12 | +0 | +| `LilyGo_T_Impulse_Plus_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,932 | 365,948 | +16 | +0 | +| `LilyGo_TBeam_1W_repeater` | espressif32 | CustomSX1262Wrapper | 1,154,409 | 1,154,425 | +16 | +0 | +| `Tbeam_SX1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,204,605 | 1,204,617 | +12 | +0 | +| `Tbeam_SX1276_repeater` | espressif32 | CustomSX1276Wrapper | 1,200,645 | 1,200,653 | +8 | +0 | +| `T_Beam_S3_Supreme_SX1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,154,977 | 1,154,993 | +16 | +0 | +| `LilyGo_TDeck_repeater` | espressif32 | CustomSX1262Wrapper | 1,144,121 | 1,144,137 | +16 | +0 | +| `LilyGo_T-Echo_repeater` | nordicnrf52 | CustomSX1262Wrapper | 349,488 | 349,488 | +0 | +0 | +| `LilyGo_T-Echo_Card_repeater` | nordicnrf52 | CustomSX1262Wrapper | 321,952 | 321,952 | +0 | +0 | +| `LilyGo_T-Echo-Lite_repeater` | nordicnrf52 | CustomSX1262Wrapper | 335,340 | 335,340 | +0 | +0 | +| `LilyGo_TETH_Elite_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,075,753 | 1,075,765 | +12 | +0 | +| `LilyGo_Tlora_C6_repeater_` | platform-espressif32.zip | CustomSX1262Wrapper | 542,067 | 542,077 | +10 | +0 | +| `LilyGo_TLora_V2_1_1_6_repeater` | espressif32 | CustomSX1276Wrapper | 1,662,685 | 1,662,693 | +8 | +0 | +| `Mesh_pocket_repeater` | nordicnrf52 | CustomSX1262Wrapper | 326,456 | 326,456 | +0 | +0 | +| `Meshadventurer_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,161,541 | 1,161,553 | +12 | +0 | +| `meshnology_w12_repeater` | espressif32 | CustomLR2021Wrapper | 1,182,665 | 1,182,677 | +12 | +0 | +| `Meshtiny_companion_radio_ble` | nordicnrf52 | CustomSX1262Wrapper | 335,568 | 335,584 | +16 | +0 | +| `MeshTracker_X1_repeater` | nordicnrf52 | CustomLR2021Wrapper | 300,752 | 300,736 | -16 | +0 | +| `Minewsemi_me25ls01_repeater` | nordicnrf52 | CustomLR1110Wrapper | 306,592 | 306,592 | +0 | +0 | +| `muzi_base_duo_repeater` | nordicnrf52 | CustomLR1121Wrapper | 296,948 | 296,932 | -16 | +0 | +| `R1Neo_repeater` | nordicnrf52 | CustomSX1262Wrapper | 368,356 | 368,356 | +0 | +0 | +| `Nano_G2_Ultra_repeater` | nordicnrf52 | CustomSX1262Wrapper | 319,084 | 319,084 | +0 | +0 | +| `nibble_screen_connect_repeater_` | espressif32 | CustomSX1262Wrapper | 1,102,645 | 1,102,657 | +12 | +0 | +| `nibble_zero_connect_repeater_` | espressif32 | CustomSX1262Wrapper | 1,102,745 | 1,102,757 | +12 | +0 | +| `RAK_11310_repeater` | platform-raspberrypi.git | CustomSX1262Wrapper | 235,924 | 235,924 | +0 | +0 | +| `RAK_3401_repeater` | nordicnrf52 | CustomSX1262Wrapper | 410,464 | 410,464 | +0 | +0 | +| `RAK_3x72_repeater` | ststm32 | CustomSTM32WLxWrapper | 196,472 | 196,472 | +0 | +0 | +| `RAK_WisMesh_Tag_repeater` | nordicnrf52 | CustomSX1262Wrapper | 368,516 | 368,516 | +0 | +0 | +| `SenseCap_Solar_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,532 | 365,548 | +16 | +0 | +| `Station_G2_logging_repeater` | espressif32 | CustomSX1262Wrapper | 1,207,117 | 1,207,129 | +12 | +0 | +| `Station_G3_ESP32_logging_repeater` | espressif32 | CustomSX1262Wrapper | 1,208,705 | 1,208,721 | +16 | +0 | +| `t1000e_repeater` | nordicnrf52 | CustomLR1110Wrapper | 294,360 | 294,344 | -16 | +0 | +| `Tenstar_C3_sx1262_repeater` | espressif32 | CustomSX1262Wrapper | 1,152,016 | 1,152,016 | +0 | +0 | +| `ThinkNode_M1_repeater` | nordicnrf52 | CustomSX1262Wrapper | 305,780 | 305,764 | -16 | +0 | +| `ThinkNode_M2_Repeater` | espressif32 | CustomSX1262Wrapper | 1,109,625 | 1,109,637 | +12 | +0 | +| `ThinkNode_M3_repeater` | nordicnrf52 | CustomLR1110Wrapper | 301,908 | 301,908 | +0 | +0 | +| `ThinkNode_M4_repeater` | nordicnrf52 | CustomLR1110Wrapper | 301,884 | 301,900 | +16 | +0 | +| `ThinkNode_M5_Repeater` | espressif32 | CustomSX1262Wrapper | 1,126,905 | 1,126,917 | +12 | +0 | +| `ThinkNode_M6_repeater` | nordicnrf52 | CustomSX1262Wrapper | 365,652 | 365,668 | +16 | +0 | +| `ThinkNode_M7_repeater` | espressif32 | CustomLR1110Wrapper | 1,088,661 | 1,088,673 | +12 | +0 | +| `ThinkNode_M8_repeater` | nordicnrf52 | CustomSX1262Wrapper | FAIL | FAIL | | | +| `ThinkNode_M9_repeater_` | espressif32 | CustomLR1110Wrapper | 1,182,137 | 1,182,153 | +16 | +0 | +| `Tiny_Relay_repeater` | ststm32 | CustomSTM32WLxWrapper | 196,480 | 196,480 | +0 | +0 | +| `Waveshare_S3_LR1121_repeater` | espressif32 | CustomLR1121Wrapper | 1,084,309 | 1,084,317 | +8 | +0 | +| `wio-e5-repeater_bridge_rs232` | ststm32 | CustomSTM32WLxWrapper | 198,760 | 198,768 | +8 | +0 | +| `WioTrackerL1_repeater` | nordicnrf52 | CustomSX1262Wrapper | 377,148 | 377,148 | +0 | +0 | +| `WioTrackerL1-1W_repeater` | nordicnrf52 | CustomSX1262Wrapper | 378,172 | 378,172 | +0 | +0 | +| `wio_wm1110_repeater` | nordicnrf52 | CustomLR1110Wrapper | 344,828 | 344,828 | +0 | +0 | +| `Xiao_C3_repeater` | espressif32 | CustomSX1262Wrapper | 1,157,598 | 1,157,590 | -8 | +0 | +| `Xiao_nrf52_repeater` | nordicnrf52 | CustomSX1262Wrapper | 361,224 | 361,224 | +0 | +0 | +| `Xiao_rp2040_repeater` | platform-raspberrypi.git | CustomSX1262Wrapper | 235,548 | 235,548 | +0 | +0 | +| `Xiao_S3_repeater` | espressif32 | CustomSX1262Wrapper | 1,136,809 | 1,136,821 | +12 | +0 | +| `Xiao_S3_WIO_repeater` | espressif32 | CustomSX1262Wrapper | 1,127,189 | 1,127,201 | +12 | +0 | +| `Generic_E22_sx1268_repeater` | espressif32 | CustomSX1268Wrapper | 1,142,769 | 1,142,781 | +12 | +0 | +| `Generic_ESPNOW_repeatr` | espressif32 | none | FAIL | FAIL | | | +| `Xiao_C6_repeater_` | platform-espressif32.zip | CustomSX1262Wrapper | 542,095 | 542,095 | +0 | +0 | +| `M5Stack_Unit_C6L_repeater` | platform-espressif32.zip | CustomSX1262Wrapper | 552,445 | 552,445 | +0 | +0 | + +
+ +### Hardware + +No hardware evidence. The change moves a function whose body is unchanged into +another translation unit, and the builds show the firmware links the shared +definition. A board run would only show that a new identity is created on first +boot, which nothing in this step can change differently per board. If a reviewer +wants one, the check is: erase the identity on a board (`/identity` on the file +system), boot this firmware, see a new public key in `ver`/advert. + +## Upstream + +Searched meshcore-dev/MeshCore on 20.10.2026: text queries over all pull requests +(open and closed) for `target.cpp`, `radio_new_identity`, `RadioNoiseListener`, +board abstraction, generic target, FEM and the board names; the file lists of all +497 open pull requests; and the diffs of the 120 that touch a `target.*`, the +radio wrappers or the four FEM boards. + +- No pull request proposes a board abstraction, a generic target, or a shared + `radio_new_identity()`. The only hit for `radio_new_identity` in text is the + closed, stale #1164. +- **#2280** (ascon based PRNG, open, last updated 2026-08) removes the callers of + `radio_new_identity()` from `examples/` and changes `RadioNoiseListener::random()` + to drop the `random()` mix. If it lands, cut A's function has no caller left, and + the test pinning the mix would fail by design. Whichever lands second has to + follow the other. +- **#3496** (KISS modem AGC and FEM gain, open) edits the `fem_rxgain` lines of all + four FEM boards and adds a virtual to `RadioLibWrapper` in a different place than + this step's friend line. Relevant for cut B. +- **#3638** (dual radio profiles, open) rewrites a large part of `RadioLibWrapper` + (members after `_radio`, not the class head this step touches) and 10 + `target.cpp`, among them `heltec_v4`, where it changes how the radio is + constructed. Relevant for cut D. +- Deleting the copies in cut A conflicts by context with open pull requests that + have `radio_new_identity` lines in their hunks (#1799, #1850, #1913, #2152, #2682, + #2927, #3213, #3472, #3638). Each is a trivial rebase (drop the function). + Four pull requests edit the declaration line in a `target.h` (#1885, #2152, #2568, + #3100); cut A does not touch any `target.h`, so they do not conflict. +- New board pull requests (about 50) carry their own copy; with the weak definition + they keep linking unchanged. +- Merged churn in `target.*` since 2026-06-01: 25 pull requests, at most 5 such files + each, mostly new boards. No merged mass edit; the mass edits are open pull + requests only (#1962 nRF52 RTC, 27 target files; #1799 GPS, 15; #2912 RadioLib + 7.7.1, 9 `radio_init()` bodies), which are the ones to watch for cuts C and D. + +## Observations (not part of this step) + +- `m5stack_unit_c6l` and `xiao_c6` have no `target.cpp`; their board file + (`UnitC6LBoard.cpp`, `XiaoC6Board.cpp`) defines the target globals and functions, + including a copy of `radio_new_identity()`. Those two copies are left for the cut + that touches the board files, so this step edits `target.cpp` files only. + `wio-tracker-l1-eink` builds the sources of `wio-tracker-l1`. +- All ESP-NOW environments fail to compile on `integration` (see "Builds"). +- `Generic_E22_kiss_modem` sets neither `RADIO_CLASS` nor `WRAPPER_CLASS`, unlike + every other environment of `generic-e22`. From ed64d3e46ceae7684ab8dea47048c8ba1f784462 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 19:08:13 +0200 Subject: [PATCH 095/136] fix(t-echo): clear a stuck I2C bus before Wire.begin() A soft reset (DFU, watchdog, reset button) does not reset I2C slaves. When the reset lands in the middle of a transfer, the RTC or a sensor keeps holding SDA low and waits for more clocks. The first Wire transfer after boot is the RTC autodiscovery probe, and the Adafruit nRF52 TwoWire driver waits for the TWIM events in loops without a timeout (Wire_nRF52.cpp endTransmission(): EVENTS_TXSTARTED/EVENTS_LASTTX/EVENTS_STOPPED). The firmware therefore never reaches radio_init() and the console stays silent. Root cause chain: reset mid transfer -> slave holds SDA low -> Wire.begin() only configures the pins -> first probe (AutoDiscoverRTCClock::i2c_probe) -> unbounded TWIM wait -> boot never completes. The missing timeout in the driver is a second, independent defect (adafruit/Adafruit_nRF52_Arduino#873, open). Bus clear removes the cause, a timeout would only bound the hang. Fix: add src/helpers/nrf52/I2cBusClear.h, the bus clear of the I2C spec (UM10204 3.1.16), and call it from TechoBoard::begin() before Wire.begin(). The sequence only ever drives a line low or releases it (never high), so a clock stretching slave is not overridden: - release both lines, wait 2 ms for the pull-ups - SCL held low: give up without touching anything - SDA high: do nothing (no clocks, no STOP) - SDA low: up to 9 SCL clocks at ~100 kHz, stop as soon as SDA is high, then generate a STOP - SDA still low after 9 clocks: no STOP, report failure The pin access is a template parameter, so the clock sequence is covered by a host test against an open-drain bus model. Scope: only the LilyGo T-Echo (variants/lilygo_techo). Many other nRF52 variants call Wire.begin() in their own Board::begin(); they can adopt the helper with one line, but are not changed here because no hardware was available to verify them. Verification: - host: pio test -e native -e native_kiss_modem: 64/64, test_i2c_bus_clear 10 tests (healthy bus, 1/5/9 clocks, SDA stuck, SCL stuck, clock stretching, stretch beyond limit, SDA low again after STOP, clock rate) - mutation tests: 8 deliberate defects (STOP removed, 9 -> 8 clocks, healthy bus clocked, stretch limit removed, no early exit, ok always true, STOP with stuck SDA, SCL abort removed), all fail the suite - board (LilyGo T-Echo sensor build, repeated DFU resets with a test image that polls the PCF8563 continuously so the reset lands mid transfer): without the fix: 2 of 10 boots, 8 hangs, SDA low at start in all 8, last step before RTC autodiscovery with the fix: 12 of 12 boots, SDA low at start in 5 (1, 3, 1, 1, 1 clocks), bus released every time Without the continuous polling the failure state did not occur (8/8 and 20/20 boots), so those runs only show the fix does no harm on a healthy bus. - build matrix of 23 environments against dev: all build; flash grows by 192 bytes in the five T-Echo environments only, all others unchanged Related: #3455 proposes the same recovery for the T-Echo only, as a local function in TechoBoard.cpp (draft, not verified on hardware by its author). --- src/helpers/nrf52/I2cBusClear.h | 104 +++++++++++++ .../test_i2c_bus_clear/test_i2c_bus_clear.cpp | 144 ++++++++++++++++++ variants/lilygo_techo/TechoBoard.cpp | 7 + 3 files changed, 255 insertions(+) create mode 100644 src/helpers/nrf52/I2cBusClear.h create mode 100644 test/test_i2c_bus_clear/test_i2c_bus_clear.cpp diff --git a/src/helpers/nrf52/I2cBusClear.h b/src/helpers/nrf52/I2cBusClear.h new file mode 100644 index 00000000000..fa1738a1b2b --- /dev/null +++ b/src/helpers/nrf52/I2cBusClear.h @@ -0,0 +1,104 @@ +#pragma once + +#include + +// I2C bus clear (NXP UM10204 section 3.1.16), for the first-boot case where a +// soft reset (DFU, watchdog, reset button) caught a slave mid-byte. Slaves are +// not reset by the MCU, so such a slave keeps holding SDA low and waits for +// more SCL clocks. The Adafruit nRF52 TwoWire driver then busy-waits forever +// on the first transfer (no timeout, no bus clear), which looks like a dead +// firmware before radio_init(). +// +// The logic is a template over a pin interface so the clock sequence can be +// tested on the host. Both lines are only ever driven low or released (never +// driven high), so a slave that stretches the clock is not overridden. +// +// Pin interface requirements: +// void sdaLow(); void sdaRelease(); bool sdaRead(); +// void sclLow(); void sclRelease(); bool sclRead(); +// void delayUs(uint32_t us); +// "Release" means high-impedance with the pull-up active. + +struct I2cBusClearResult { + bool sda_low_at_start; // SDA was held low when the clear began + uint8_t pulses; // SCL clocks issued + bool ok; // both lines high on return +}; + +#define I2C_BUS_CLEAR_MAX_PULSES 9 +#define I2C_BUS_CLEAR_HALF_PERIOD_US 5 // ~100 kHz +#define I2C_BUS_CLEAR_STRETCH_LIMIT_US 1000 // max wait for SCL to rise +#define I2C_BUS_CLEAR_SETTLE_US 2000 // let the pull-ups charge the lines after reset + +template +static bool i2cBusClearWaitScl(Pins& p) { + for (uint32_t waited = 0; !p.sclRead(); waited += 10) { + if (waited >= I2C_BUS_CLEAR_STRETCH_LIMIT_US) return false; + p.delayUs(10); + } + return true; +} + +template +I2cBusClearResult i2cBusClear(Pins& p) { + I2cBusClearResult r = {false, 0, false}; + + p.sdaRelease(); + p.sclRelease(); + p.delayUs(I2C_BUS_CLEAR_SETTLE_US); + + // SCL held low (by a slave or a short): clocking is impossible, give up + // without touching anything. + if (!i2cBusClearWaitScl(p)) return r; + + r.sda_low_at_start = !p.sdaRead(); + if (!r.sda_low_at_start) { + r.ok = true; // healthy bus: do nothing, no STOP either + return r; + } + + while (r.pulses < I2C_BUS_CLEAR_MAX_PULSES && !p.sdaRead()) { + p.sclLow(); + p.delayUs(I2C_BUS_CLEAR_HALF_PERIOD_US); + p.sclRelease(); + r.pulses++; + if (!i2cBusClearWaitScl(p)) return r; + p.delayUs(I2C_BUS_CLEAR_HALF_PERIOD_US); + } + + // Still stuck after nine clocks: not a mid-byte slave, a STOP would not help. + if (!p.sdaRead()) return r; + + // STOP: SDA low -> high while SCL is high, so the slave's state machine ends + // in idle. + p.sdaLow(); + p.delayUs(I2C_BUS_CLEAR_HALF_PERIOD_US); + p.sdaRelease(); + p.delayUs(I2C_BUS_CLEAR_HALF_PERIOD_US); + + r.ok = p.sdaRead() && p.sclRead(); + return r; +} + +#ifdef NRF52_PLATFORM +#include + +// Open-drain pin access on the nRF52 Arduino core: OUTPUT_S0D1 sinks but +// never sources, INPUT_PULLUP releases. +struct Nrf52I2cPins { + uint8_t sda, scl; + void sdaLow() { pinMode(sda, OUTPUT_S0D1); digitalWrite(sda, LOW); } + void sdaRelease() { pinMode(sda, INPUT_PULLUP); } + bool sdaRead() { return digitalRead(sda) != LOW; } + void sclLow() { pinMode(scl, OUTPUT_S0D1); digitalWrite(scl, LOW); } + void sclRelease() { pinMode(scl, INPUT_PULLUP); } + bool sclRead() { return digitalRead(scl) != LOW; } + void delayUs(uint32_t us) { delayMicroseconds(us); } +}; + +// Call before Wire.begin(). Leaves both pins released. +static inline I2cBusClearResult i2cBusClearNrf52(uint8_t sda, uint8_t scl) { + Nrf52I2cPins pins = {sda, scl}; + return i2cBusClear(pins); +} +#endif diff --git a/test/test_i2c_bus_clear/test_i2c_bus_clear.cpp b/test/test_i2c_bus_clear/test_i2c_bus_clear.cpp new file mode 100644 index 00000000000..ef05bbbc671 --- /dev/null +++ b/test/test_i2c_bus_clear/test_i2c_bus_clear.cpp @@ -0,0 +1,144 @@ +#include +#include +#include +#include "helpers/nrf52/I2cBusClear.h" + +// Open-drain bus model: a line is low if the master or a slave pulls it low. +// The interface only offers low/release, so "drive high" cannot be expressed. +struct FakeBus { + // slave holds SDA low until it has seen this many SCL rising edges + int slave_sda_clocks_needed = 0; + // slave holds SCL low (clock stretching) for this many delayUs() calls after each release + int stretch_calls = 0; + bool scl_stuck_low = false; // SCL shorted/held forever + bool sda_stuck_low = false; // SDA held forever (not a mid-byte slave) + bool relow_after_stop = false; // slave pulls SDA low again once a STOP was seen + int sda_low_calls = 0; // how often the master drove SDA low + int stops = 0; + + bool master_sda_low = false, master_scl_low = false; + int clocks_seen = 0; + int stretch_left = 0; + uint32_t elapsed_us = 0; + std::string log; // sequence of S/P and edges, for assertions + + bool sda() const { + bool slave_low = sda_stuck_low || clocks_seen < slave_sda_clocks_needed || + (relow_after_stop && stops > 0); + return !(master_sda_low || slave_low); + } + bool scl() const { return !(master_scl_low || scl_stuck_low || stretch_left > 0); } + + void sdaLow() { sda_low_calls++; bool was = sda(), c = scl(); master_sda_low = true; if (was && c) log += 'S'; } + void sdaRelease() { bool was = sda(), c = scl(), drove = master_sda_low; master_sda_low = false; if (!was && sda() && c) log += 'P'; if (c && drove) stops++; } + bool sdaRead() { return sda(); } + void sclLow() { master_scl_low = true; } + void sclRelease() { + bool was = master_scl_low; + master_scl_low = false; + if (was) { clocks_seen++; stretch_left = stretch_calls; log += 'c'; } + } + bool sclRead() { return scl(); } + void delayUs(uint32_t us) { + elapsed_us += us; + // a correct clear is bounded; fail instead of hanging the test run + if (elapsed_us > 100000) throw std::runtime_error("bus clear did not terminate"); if (stretch_left > 0) stretch_left--; + } +}; + +TEST(I2cBusClear, HealthyBusDoesNothing) { + FakeBus b; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_FALSE(r.sda_low_at_start); + EXPECT_EQ(0, r.pulses); + EXPECT_EQ("", b.log); // no clocks, no START, no STOP +} + +TEST(I2cBusClear, OneClockReleasesAndEndsWithStop) { + FakeBus b; + b.slave_sda_clocks_needed = 1; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_TRUE(r.sda_low_at_start); + EXPECT_EQ(1, r.pulses); + EXPECT_EQ("cSP", b.log); // one clock, then START-shape low, release = STOP +} + +TEST(I2cBusClear, StopsClockingAsSoonAsSdaIsFree) { + FakeBus b; + b.slave_sda_clocks_needed = 5; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_EQ(5, r.pulses); +} + +TEST(I2cBusClear, NineClocksSuffice) { + FakeBus b; + b.slave_sda_clocks_needed = 9; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_EQ(9, r.pulses); +} + +TEST(I2cBusClear, SdaStuckForeverGivesUpAfterNineClocksWithoutStop) { + FakeBus b; + b.sda_stuck_low = true; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_FALSE(r.ok); + EXPECT_TRUE(r.sda_low_at_start); + EXPECT_EQ(9, r.pulses); + EXPECT_EQ(std::string(9, 'c'), b.log); // no STOP attempted + EXPECT_EQ(0, b.sda_low_calls); // SDA never driven by the master +} + +TEST(I2cBusClear, ReportsNotOkWhenSdaFallsAgainAfterStop) { + FakeBus b; + b.slave_sda_clocks_needed = 2; + b.relow_after_stop = true; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_FALSE(r.ok); +} + +TEST(I2cBusClear, SclHeldLowTouchesNothing) { + FakeBus b; + b.scl_stuck_low = true; + b.slave_sda_clocks_needed = 1; + I2cBusClearResult r = i2cBusClear(b); + EXPECT_FALSE(r.ok); + EXPECT_EQ(0, r.pulses); + EXPECT_EQ("", b.log); + EXPECT_LE(b.elapsed_us, 5000u); // bounded wait, no endless loop +} + +TEST(I2cBusClear, ClockStretchingSlaveIsWaitedFor) { + FakeBus b; + b.slave_sda_clocks_needed = 3; + b.stretch_calls = 20; // 200 us of stretching per clock + I2cBusClearResult r = i2cBusClear(b); + EXPECT_TRUE(r.ok); + EXPECT_EQ(3, r.pulses); +} + +TEST(I2cBusClear, StretchBeyondLimitGivesUp) { + FakeBus b; + b.slave_sda_clocks_needed = 3; + b.stretch_calls = 1000; // far beyond the 1 ms limit + I2cBusClearResult r = i2cBusClear(b); + EXPECT_FALSE(r.ok); + EXPECT_LE(b.elapsed_us, 5000u); +} + +TEST(I2cBusClear, ClockRateStaysAtOrBelow100kHz) { + FakeBus b; + b.slave_sda_clocks_needed = 9; + i2cBusClear(b); + // 9 clocks, two half periods each, plus settle and STOP: well under 1 ms beyond the settle time + EXPECT_GE(b.elapsed_us, 9u * 2 * I2C_BUS_CLEAR_HALF_PERIOD_US); + EXPECT_LT(b.elapsed_us, I2C_BUS_CLEAR_SETTLE_US + 1000u); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/lilygo_techo/TechoBoard.cpp b/variants/lilygo_techo/TechoBoard.cpp index 81d3d0c9af9..f57c2119d41 100644 --- a/variants/lilygo_techo/TechoBoard.cpp +++ b/variants/lilygo_techo/TechoBoard.cpp @@ -2,12 +2,19 @@ #include #include "TechoBoard.h" +#include #ifdef LILYGO_TECHO void TechoBoard::begin() { NRF52Board::begin(); + // A soft reset can leave the RTC or sensor holding SDA low; without this the + // first Wire transfer (RTC autodiscovery) never returns. + I2cBusClearResult i2c = i2cBusClearNrf52(PIN_WIRE_SDA, PIN_WIRE_SCL); + if (i2c.sda_low_at_start) { + MESH_DEBUG_PRINTLN("I2C bus clear: %d clocks, %s", i2c.pulses, i2c.ok ? "released" : "still stuck"); + } Wire.begin(); pinMode(SX126X_POWER_EN, OUTPUT); From a3d3961eca9e2c9669a3b372afcca57c6da96570 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 19:08:14 +0200 Subject: [PATCH 096/136] docs(refactoring): record the board description decision in note 0008 --- docs/refactoring/0008-variant-target.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/refactoring/0008-variant-target.md b/docs/refactoring/0008-variant-target.md index 105ac396213..e4a114928c2 100644 --- a/docs/refactoring/0008-variant-target.md +++ b/docs/refactoring/0008-variant-target.md @@ -85,8 +85,8 @@ weak" below). | A | one `radio_new_identity()` for all RadioLib variants | 87 `target.cpp`, 1 new source, 1 header | this step | | B | one class for the FEM gain handlers of HeltecV4, HeltecV4R8, T096, HeltecTrackerV2 | 4 board classes, 1 new helper | planned | | C | a shared default `radio_init()` for the largest groups (28 + 18 + 5 + 4 + 3 of 89) | about 58 `target.cpp` | planned | -| D | the radio, wrapper and RTC globals from the board's build flags | about 87 `target.cpp` | needs a decision, see below | -| E | sensors, display and button from the board description | the rest | needs a decision | +| D | the radio, wrapper and RTC globals from the board's build flags | about 87 `target.cpp` | planned (description: build flags, decided) | +| E | sensors, display and button from the board description | the rest | planned | Cut B in more detail: the four bodies are the same once the class name is replaced, and they all read and write the `fem_rxgain` key through `_prefs`. The open upstream @@ -106,8 +106,12 @@ they look today: (1) the build flags and `target.h` macros the variants already read by the generic target; (2) a small `constexpr` struct per board in `target.h`; (3) a data file per board from which the build generates `target.cpp`. Option 1 changes the least and keeps the PlatformIO files as the one place a board is -described; option 3 would be a new build step and is likely rejected. This is a -product decision and is asked before cut D starts. +described; option 3 would be a new build step. + +Decision (Marcel, 20.10.2026): option 1. The board description is the build flags +and `target.h` macros a variant already has, plus the few new ones cuts D and E +need. Options 2 and 3 are rejected: option 2 rewrites every variant, option 3 adds +a build step. ## Cut A: one `radio_new_identity()` From fba65e89c0c65e4bf64090ff42b5023c6173de0c Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 19:10:49 +0200 Subject: [PATCH 097/136] docs(fixes): note on the T-Echo I2C bus clear Context, measurements, rejected alternatives and the verification of the bus clear before Wire.begin() on the LilyGo T-Echo. Kept in its own commit so a pull request upstream can leave it out. --- docs/fixes/nrf52-i2c-bus-clear.md | 171 ++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/fixes/nrf52-i2c-bus-clear.md diff --git a/docs/fixes/nrf52-i2c-bus-clear.md b/docs/fixes/nrf52-i2c-bus-clear.md new file mode 100644 index 00000000000..0560c8140f0 --- /dev/null +++ b/docs/fixes/nrf52-i2c-bus-clear.md @@ -0,0 +1,171 @@ +# T-Echo boot hangs after a soft reset because a slave holds SDA low + +## Context + +After a soft reset (DFU by 1200 baud touch, the CLI `reboot`, the reset button, +a watchdog) a LilyGo T-Echo sometimes never finishes booting. The USB port +enumerates, the console stays silent, the radio never starts. + +The boot path on `upstream/dev` 428a95c6: + +```cpp +void TechoBoard::begin() { + NRF52Board::begin(); + Wire.begin(); // variants/lilygo_techo/TechoBoard.cpp:11 + ... +} + +bool radio_init() { + rtc_clock.begin(Wire); // variants/lilygo_techo/target.cpp:29 + return radio.std_init(&SPI); +} +``` + +`AutoDiscoverRTCClock::begin()` probes up to four RTC addresses, and a probe is +`beginTransmission()` plus `endTransmission()` +(`src/helpers/AutoDiscoverRTCClock.cpp:23-26`, first probe at `:31`). + +The Adafruit nRF52 `TwoWire` driver (the pinned fork, commit d541301) waits for +the TWIM peripheral in loops that have no timeout: + +```cpp +while(!_p_twim->EVENTS_TXSTARTED && !_p_twim->EVENTS_ERROR); // Wire_nRF52.cpp:230 +while(!_p_twim->EVENTS_LASTTX && !_p_twim->EVENTS_ERROR); // :234 +while(!_p_twim->EVENTS_STOPPED); // :241, no error exit +``` + +`requestFrom()` has the same pattern (`:166`, `:169`, `:175`). `begin()` only +configures the pins (`:50-70`); nothing in the driver or in the MeshCore board +code clears a stuck bus. + +### What the board showed + +A reset does not reset the I2C slaves. The T-Echo has a PCF8563 RTC and, on +the sensor build, a BME280 on the same bus (P0.26 SDA, P0.27 SCL, +`variants/lilygo_techo/variant.h:48-49`). If the reset lands in the middle of +a transfer, a slave can be left holding SDA low and waiting for more clocks. + +Measured on a T-Echo sensor build with a log line before and after each boot +step: + +- Without any bus handling, the reset path through DFU ended in a hang at + "before RTC autodiscovery" in 4 of 4 boots of an upstream `dev` build. The + same hang was reported for a 1.17.1 build; that one was not measured again + for this note. +- Reading the pins before `Wire.begin()` showed SDA low in the hanging boots. +- One SCL clock was enough to release the bus in the cases seen, and the boot + then completed. + +What was not measured: which of the three loops the firmware sits in, and +which slave holds SDA. Neither matters for the fix. + +### Why the failure is intermittent + +Whether SDA is low depends on where in a transfer the reset lands. Plain DFU +cycles of the sensor build produced no hang at all in a later series (8 of 8 +and 20 of 20 boots, SDA high every time), so the state is not reproducible on +demand with a plain image. To force it, a test image polls the PCF8563 in a +tight loop after boot, so that nearly every reset lands inside a transfer. +That is a test device, not part of this change. + +## Decision + +Run the bus clear of the I2C specification (UM10204 section 3.1.16) before +`Wire.begin()`. It lives in a small header, `src/helpers/nrf52/I2cBusClear.h`, +so other boards can reuse it, and `TechoBoard::begin()` is the only caller. + +The sequence: + +1. Release both lines (input with pull-up) and wait 2 ms for the pull-ups. +2. SCL low after the wait: give up without touching anything. +3. SDA high: do nothing. No clocks, no STOP. +4. SDA low: up to nine SCL clocks at about 100 kHz, stop as soon as SDA reads + high, then generate a STOP (SDA low, SCL high, SDA released). +5. SDA still low after nine clocks: no STOP, report failure, continue the boot. + +Both lines are only ever driven low or released (`OUTPUT_S0D1` and +`INPUT_PULLUP`), never driven high. A slave that stretches the clock is not +overridden, and a wait for SCL to rise is bounded to 1 ms. + +The pin access is a template parameter, so the clock sequence runs unchanged +in a host test against an open-drain bus model. + +### Why not the alternatives + +- **A timeout in the driver** (adafruit/Adafruit_nRF52_Arduino#873, open) + bounds the hang but leaves the bus stuck, so the probe fails and the RTC is + not found. It is a separate defect and worth having, but it does not remove + the cause. +- **Skipping the RTC probe** on a bare board (the approach of #3331 for the + Xiao) hides the symptom on boards without an RTC and does nothing for boards + that have one. +- **Doing it inside `NRF52Board::begin()`.** The I2C pins differ per board + (the Xiao moves Wire to other pins in #2499) and boards without I2C should + not pay for it. A per board call with the pins as arguments is explicit. +- **Copying the sequence into `TechoBoard.cpp`** (#3455). The same logic would + be copied into every board that needs it, with no way to test it on the + host. + +## Verification + +Host: + +- `pio test -e native -e native_kiss_modem`: 64 of 64 test cases. +- `test/test_i2c_bus_clear` has ten tests against a bus model: healthy bus + (no clock, no STOP), release after 1, 5 and 9 clocks, SDA stuck forever (nine + clocks, no STOP), SCL stuck low (nothing touched, bounded), clock + stretching, stretch beyond the limit, SDA low again after the STOP, clock + rate. + +Mutation tests (each defect built into the header, the suite must go red): + +| mutation | result | +|---|---| +| STOP removed | red | +| nine clocks reduced to eight | red | +| healthy bus is clocked anyway | red | +| bound on the SCL wait removed | red | +| keep clocking after SDA is free | red | +| success flag always true | red | +| STOP issued with SDA stuck | red | +| abort on SCL held low removed | red | + +Board, LilyGo T-Echo sensor build, DFU reset before each boot, a test image +that polls the PCF8563 continuously: + +| image | boots that completed | SDA low at start | outcome | +|---|---|---|---| +| without the bus clear | 2 of 10 | 8 of 10 | all 8 hangs at "before RTC autodiscovery" | +| with the bus clear | 12 of 12 | 5 of 12 | cleared with 1, 3, 1, 1, 1 clocks, boot completed every time | + +Without the continuous polling the failure did not occur (8 of 8 and 20 of 20 +boots, SDA high), so those runs show only that the helper does no harm on a +healthy bus. The board was returned to the original 1.17.1 build and all 31 +reference values matched the state before the test. + +Build matrix of 23 environments against `dev`, same toolchain: all build, flash +grows by 192 bytes in the five T-Echo environments (repeater, room server, +companion BLE, companion USB, KISS modem) and by 0 bytes in all others. + +## Not covered + +- Other nRF52 boards. Many call `Wire.begin()` in their own `Board::begin()` + and can take the helper with one line (`i2cBusClearNrf52(PIN_WIRE_SDA, + PIN_WIRE_SCL)`). They are not changed because no hardware was available to + verify them. The Lite and Card variants of the T-Echo use other pins and are + also unchanged. +- ESP32. Its driver is a different code path; whether it can hang the same way + was not checked. +- How often the state occurs in normal operation. The stress image forces it. + +## Related upstream work + +- #3455 (draft) proposes the same recovery for the T-Echo only, as a static + function in `TechoBoard.cpp`. Both patches change the same lines of + `TechoBoard::begin()`, so they conflict. The helper here is meant to replace + that local function. +- adafruit/Adafruit_nRF52_Arduino#873 adds timeouts to the TWIM wait loops. It + is complementary: with it a stuck bus no longer blocks the boot, with this + change the bus is released in the first place. +- #3331 skips the RTC probe on the Xiao repeater for the same driver defect. + It does not touch the T-Echo. From a5f9b6957313b62b5437186de9f7f51a8e1f7661 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 19:27:38 +0200 Subject: [PATCH 098/136] test(variants): pin the FEM RX gain handlers of the four Heltec boards HeltecV4Board, HeltecV4R8Board, T096Board and HeltecTrackerV2Board carry the same attachDynamicPrefs() and handleCommand() once the class name is replaced (tools/refactoring/variant_duplication.py). The tests run a verbatim copy of the HeltecV4Board bodies against a recording key/value store and a stand-in FEM control, and pin: - attach reads `fem_rxgain` once with seven characters and switches the LNA on only for exactly "1", even when the LNA cannot be controlled, - `get radio.fem.rxgain` replies "> on", "> off" or "Error: unsupported", - `set radio.fem.rxgain on|off` switches the LNA, writes "1" or "0" to the store attached last and replies "OK - LoRa FEM RX gain on|off", ignoring what the store returns, - the state is matched by its first two or three characters, anything else is "Error: state must be on or off", a failed switch stores nothing, - every other command is left unhandled with the reply untouched. The suite is typed, so the next commit can run the same cases against the shared code. --- test/test_fem_rxgain/test_fem_rxgain.cpp | 349 +++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 test/test_fem_rxgain/test_fem_rxgain.cpp diff --git a/test/test_fem_rxgain/test_fem_rxgain.cpp b/test/test_fem_rxgain/test_fem_rxgain.cpp new file mode 100644 index 00000000000..c07300385a5 --- /dev/null +++ b/test/test_fem_rxgain/test_fem_rxgain.cpp @@ -0,0 +1,349 @@ +#include + +#include +#include +#include +#include +#include + +#include + +// The RX gain (LNA) setting of the boards whose LoRa front end module can switch +// its LNA: Heltec V4, V4 R8, T096 and Tracker V2. Their board classes carry the +// same attachDynamicPrefs() and handleCommand() (the board class name aside, +// see tools/refactoring/variant_duplication.py), which read the `fem_rxgain` key +// from the store the role attaches, answer `get/set radio.fem.rxgain` and write +// the key back. These tests pin what the CLI replies and what lands in the store. + +namespace { + +// The board's FEM control as far as the handlers see it (the variants' +// LoRaFEMControl), recording what the board does with it. +struct FakeFEMControl { + bool can_control = true; + bool lna_enabled = false; + int rx_mode_calls = 0; + + bool isLnaCanControl() const { return can_control; } + bool isLNAEnabled() const { return lna_enabled; } + void setLNAEnable(bool enabled) { lna_enabled = enabled; } + void setRxModeEnable() { rx_mode_calls++; } +}; + +// A key/value store that records every access. getByKey() copies the value the +// way DynamicConfigSerializer does (strncpy, then a terminator at max_len). +class RecordingStore : public KeyValueStore { +public: + std::map values; + std::vector> reads; + std::vector> writes; + bool accept_writes = true; + + bool setByKey(const char* key, const char* value) override { + writes.emplace_back(key, value); + if (!accept_writes) return false; + values[key] = value; + return true; + } + bool getByKey(const char* key, char* value, size_t max_len) override { + reads.emplace_back(key, max_len); + auto it = values.find(key); + if (it == values.end()) return false; + strncpy(value, it->second.c_str(), max_len); + value[max_len] = 0; + return true; + } +}; + +// What the four boards have around the handlers: the public FEM control and the +// two private helpers, whose bodies are the boards' own (HeltecV4Board.cpp), plus +// a switch that makes a switch of the LNA fail although it can be controlled, +// which no real board does but which reaches the handlers' error branch. +class FakeBoardBase { +public: + FakeFEMControl loRaFEMControl; + bool fail_set = false; + std::vector set_calls; + +protected: + bool setLoRaFemLnaEnabled(bool enable) { + set_calls.push_back(enable); + if (!loRaFEMControl.isLnaCanControl()) { + return false; + } + if (fail_set) { + return false; + } + + loRaFEMControl.setLNAEnable(enable); + loRaFEMControl.setRxModeEnable(); + return true; + } + + bool isLoRaFemLnaEnabled() const { + return loRaFEMControl.isLNAEnabled(); + } +}; + +// The handlers as the four boards have them. +class VariantCopyBoard : public FakeBoardBase { + KeyValueStore* _prefs = NULL; + +public: + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); +}; + +// Copied verbatim from variants/heltec_v4/HeltecV4Board.cpp, class name replaced. +void VariantCopyBoard::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + + char radio_fem_rxgain[8] = { 0 }; + _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values + + setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); +} + +bool VariantCopyBoard::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else if (memcmp(&command[21], "on", 2) == 0) { + if (setLoRaFemLnaEnabled(true)) { + _prefs->setByKey("fem_rxgain", "1"); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (memcmp(&command[21], "off", 3) == 0) { + if (setLoRaFemLnaEnabled(false)) { + _prefs->setByKey("fem_rxgain", "0"); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled +} + +template +class FemRxGainTest : public ::testing::Test { +protected: + BOARD board; + RecordingStore store; + char reply[160]; + + // Runs a CLI command the way CommonCLI hands it over: in a zero filled buffer, + // so the handlers' fixed length compares stay inside it. + bool run(const char* command) { + char buf[80] = { 0 }; + strcpy(buf, command); + strcpy(reply, "untouched"); + return board.handleCommand(buf, 1234, reply); + } + + void attach(const char* stored) { + if (stored) store.values["fem_rxgain"] = stored; + board.attachDynamicPrefs(&store); + board.set_calls.clear(); + store.reads.clear(); + } +}; + +using Implementations = ::testing::Types; +TYPED_TEST_SUITE(FemRxGainTest, Implementations); + +TYPED_TEST(FemRxGainTest, AttachReadsTheKeyOnceAndSwitchesTheLnaOn) { + this->store.values["fem_rxgain"] = "1"; + this->board.attachDynamicPrefs(&this->store); + + ASSERT_EQ(this->store.reads.size(), 1u); + EXPECT_EQ(this->store.reads[0].first, "fem_rxgain"); + EXPECT_EQ(this->store.reads[0].second, 7u); + EXPECT_EQ(this->board.set_calls, std::vector{ true }); + EXPECT_TRUE(this->board.loRaFEMControl.lna_enabled); + EXPECT_EQ(this->board.loRaFEMControl.rx_mode_calls, 1); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, AttachSwitchesTheLnaOffForAnyOtherValue) { + for (const char* stored : { "0", "", "on", "10", " 1", "1 ", "true" }) { + TypeParam board; + board.loRaFEMControl.lna_enabled = true; + RecordingStore store; + store.values["fem_rxgain"] = stored; + board.attachDynamicPrefs(&store); + + EXPECT_EQ(board.set_calls, std::vector{ false }) << "stored: '" << stored << "'"; + EXPECT_FALSE(board.loRaFEMControl.lna_enabled) << "stored: '" << stored << "'"; + EXPECT_TRUE(store.writes.empty()); + } +} + +TYPED_TEST(FemRxGainTest, AttachSwitchesTheLnaOffWhenTheKeyIsMissing) { + this->board.loRaFEMControl.lna_enabled = true; + this->board.attachDynamicPrefs(&this->store); + + EXPECT_EQ(this->store.reads.size(), 1u); + EXPECT_EQ(this->board.set_calls, std::vector{ false }); + EXPECT_FALSE(this->board.loRaFEMControl.lna_enabled); +} + +TYPED_TEST(FemRxGainTest, AttachReadsOnlySevenCharacters) { + // "1" followed by spaces is cut to seven characters and is still not "1". + this->store.values["fem_rxgain"] = "1 "; + this->board.attachDynamicPrefs(&this->store); + EXPECT_EQ(this->board.set_calls, std::vector{ false }); +} + +TYPED_TEST(FemRxGainTest, AttachTriesTheLnaEvenWhenItCannotBeControlled) { + this->board.loRaFEMControl.can_control = false; + this->store.values["fem_rxgain"] = "1"; + this->board.attachDynamicPrefs(&this->store); + + EXPECT_EQ(this->board.set_calls, std::vector{ true }); + EXPECT_FALSE(this->board.loRaFEMControl.lna_enabled); + EXPECT_EQ(this->board.loRaFEMControl.rx_mode_calls, 0); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, GetReportsTheLnaState) { + this->attach("1"); + EXPECT_TRUE(this->run("get radio.fem.rxgain")); + EXPECT_STREQ(this->reply, "> on"); + + this->board.loRaFEMControl.lna_enabled = false; + EXPECT_TRUE(this->run("get radio.fem.rxgain")); + EXPECT_STREQ(this->reply, "> off"); + + EXPECT_TRUE(this->board.set_calls.empty()); + EXPECT_TRUE(this->store.reads.empty()); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, GetIsUnsupportedWhenTheLnaCannotBeControlled) { + this->attach("1"); + this->board.loRaFEMControl.can_control = false; + EXPECT_TRUE(this->run("get radio.fem.rxgain")); + EXPECT_STREQ(this->reply, "Error: unsupported"); +} + +TYPED_TEST(FemRxGainTest, SetOnSwitchesTheLnaAndStoresOne) { + this->attach("0"); + EXPECT_TRUE(this->run("set radio.fem.rxgain on")); + + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain on"); + EXPECT_EQ(this->board.set_calls, std::vector{ true }); + EXPECT_TRUE(this->board.loRaFEMControl.lna_enabled); + ASSERT_EQ(this->store.writes.size(), 1u); + EXPECT_EQ(this->store.writes[0], std::make_pair(std::string("fem_rxgain"), std::string("1"))); + EXPECT_TRUE(this->store.reads.empty()); +} + +TYPED_TEST(FemRxGainTest, SetOffSwitchesTheLnaAndStoresZero) { + this->attach("1"); + EXPECT_TRUE(this->run("set radio.fem.rxgain off")); + + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain off"); + EXPECT_EQ(this->board.set_calls, std::vector{ false }); + EXPECT_FALSE(this->board.loRaFEMControl.lna_enabled); + ASSERT_EQ(this->store.writes.size(), 1u); + EXPECT_EQ(this->store.writes[0], std::make_pair(std::string("fem_rxgain"), std::string("0"))); +} + +TYPED_TEST(FemRxGainTest, SetMatchesOnlyTheStartOfTheState) { + this->attach("0"); + EXPECT_TRUE(this->run("set radio.fem.rxgain onward")); + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain on"); + EXPECT_TRUE(this->run("set radio.fem.rxgain offline")); + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain off"); + EXPECT_EQ(this->board.set_calls, (std::vector{ true, false })); + EXPECT_EQ(this->store.writes.size(), 2u); +} + +TYPED_TEST(FemRxGainTest, SetRejectsAnyOtherState) { + this->attach("0"); + for (const char* command : { "set radio.fem.rxgain 1", "set radio.fem.rxgain 0", + "set radio.fem.rxgain on", "set radio.fem.rxgain of", + "set radio.fem.rxgain ON", "set radio.fem.rxgain o", + "set radio.fem.rxgain " }) { + EXPECT_TRUE(this->run(command)) << command; + EXPECT_STREQ(this->reply, "Error: state must be on or off") << command; + } + EXPECT_TRUE(this->board.set_calls.empty()); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, SetIsUnsupportedWhenTheLnaCannotBeControlled) { + this->attach("0"); + this->board.loRaFEMControl.can_control = false; + for (const char* command : { "set radio.fem.rxgain on", "set radio.fem.rxgain off", + "set radio.fem.rxgain x" }) { + EXPECT_TRUE(this->run(command)) << command; + EXPECT_STREQ(this->reply, "Error: unsupported") << command; + } + EXPECT_TRUE(this->board.set_calls.empty()); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, SetReportsAFailedSwitchAndStoresNothing) { + this->attach("0"); + this->board.fail_set = true; + EXPECT_TRUE(this->run("set radio.fem.rxgain on")); + EXPECT_STREQ(this->reply, "Error: failed to apply LoRa FEM RX gain"); + EXPECT_TRUE(this->run("set radio.fem.rxgain off")); + EXPECT_STREQ(this->reply, "Error: failed to apply LoRa FEM RX gain"); + EXPECT_EQ(this->board.set_calls, (std::vector{ true, false })); + EXPECT_TRUE(this->store.writes.empty()); +} + +TYPED_TEST(FemRxGainTest, SetIgnoresWhetherTheStoreTookTheValue) { + this->attach("0"); + this->store.accept_writes = false; + EXPECT_TRUE(this->run("set radio.fem.rxgain on")); + EXPECT_STREQ(this->reply, "OK - LoRa FEM RX gain on"); + EXPECT_EQ(this->store.writes.size(), 1u); +} + +TYPED_TEST(FemRxGainTest, SetWritesToTheStoreAttachedLast) { + RecordingStore first; + this->board.attachDynamicPrefs(&first); + this->board.attachDynamicPrefs(&this->store); + EXPECT_TRUE(this->run("set radio.fem.rxgain on")); + EXPECT_TRUE(first.writes.empty()); + EXPECT_EQ(this->store.writes.size(), 1u); +} + +TYPED_TEST(FemRxGainTest, OtherCommandsAreNotHandled) { + this->attach("1"); + for (const char* command : { "get radio.fem.rxgain ", "get radio.fem.rxgain on", + "set radio.fem.rxgain", "set radio.fem.rxgainon", + "get radio.rxgain", "set radio.fem.txgain on", + "SET radio.fem.rxgain on", "", "ver" }) { + EXPECT_FALSE(this->run(command)) << command; + EXPECT_STREQ(this->reply, "untouched") << command; + } + EXPECT_TRUE(this->board.set_calls.empty()); + EXPECT_TRUE(this->store.reads.empty()); + EXPECT_TRUE(this->store.writes.empty()); +} + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 2884fd3464617ff72d57df8d060199c9c31d46ec Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 19:30:45 +0200 Subject: [PATCH 099/136] refactor(variants): share the FEM RX gain handlers of the four Heltec boards HeltecV4Board, HeltecV4R8Board, T096Board and HeltecTrackerV2Board carried the same attachDynamicPrefs() and handleCommand(): read `fem_rxgain` from the store the role attaches, answer `get/set radio.fem.rxgain` and write the key back. Only the class name differed. The bodies now live once in src/helpers/FemRxGain.h, a class template on the board class. It holds the store pointer that each board held as `_prefs`, so the boards keep their size and layout, and it reaches the board's own setLoRaFemLnaEnabled() and isLoRaFemLnaEnabled() as a friend, so those stay private. The boards keep their public interface and forward the two calls: void HeltecV4Board::attachDynamicPrefs(KeyValueStore* prefs) { fem_rxgain.attachPrefs(*this, prefs); } A template, and not a base class with virtual calls, because each board has its own LoRaFEMControl class and a firmware contains exactly one board: the template is instantiated once per firmware, with no vtable and no extra member. One difference to the copies: `set radio.fem.rxgain on|off` writes the key only if a store is attached (`if (_prefs) _prefs->setByKey(...)`), the change the open upstream pull request meshcore-dev/MeshCore#3496 makes to the four copies for the KISS modem, which calls handleCommand() without attaching a store. In this tree every caller attaches the store first (ServerMesh::begin(), the companion's begin()), so no reply and no store write changes; only the case that dereferenced NULL before is now defined. Behaviour: unchanged. The characterization tests of the previous commit run unchanged against the shared code (34 cases in test_fem_rxgain), plus one for the missing store and one for the size. Builds of the 50 environments of the four boards and the usual 18 compared in docs/refactoring/0008. --- src/helpers/FemRxGain.h | 76 +++++++++++++++++++ test/test_fem_rxgain/test_fem_rxgain.cpp | 48 +++++++++++- variants/heltec_t096/T096Board.cpp | 40 +--------- variants/heltec_t096/T096Board.h | 4 +- .../HeltecTrackerV2Board.cpp | 40 +--------- .../heltec_tracker_v2/HeltecTrackerV2Board.h | 4 +- variants/heltec_v4/HeltecV4Board.cpp | 40 +--------- variants/heltec_v4/HeltecV4Board.h | 4 +- variants/heltec_v4_r8/HeltecV4R8Board.cpp | 40 +--------- variants/heltec_v4_r8/HeltecV4R8Board.h | 4 +- 10 files changed, 142 insertions(+), 158 deletions(-) create mode 100644 src/helpers/FemRxGain.h diff --git a/src/helpers/FemRxGain.h b/src/helpers/FemRxGain.h new file mode 100644 index 00000000000..adbffef3cff --- /dev/null +++ b/src/helpers/FemRxGain.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include + +/* + * The RX gain (LNA) setting of a LoRa front end module: the `fem_rxgain` key in + * the key/value store the role attaches, and the CLI commands + * `get radio.fem.rxgain` and `set radio.fem.rxgain on|off`. + * + * BOARD is the board class. It needs a public `loRaFEMControl` with + * isLnaCanControl(), and the two members setLoRaFemLnaEnabled(bool), which + * returns whether the LNA was switched, and isLoRaFemLnaEnabled(). These may be + * private if the board names this class a friend. The board keeps one FemRxGain + * member and forwards attachDynamicPrefs() and handleCommand() to it: + * + * class MyBoard : public ESP32Board { + * friend class FemRxGain; + * FemRxGain fem_rxgain; + * ... + * void MyBoard::attachDynamicPrefs(KeyValueStore* prefs) { fem_rxgain.attachPrefs(*this, prefs); } + * + * The store is NULL until attachPrefs() is called. A role that calls + * handleCommand() without attaching one gets the same replies, and `set` then + * switches the LNA and stores nothing. + */ +template +class FemRxGain { + KeyValueStore* _prefs = NULL; + +public: + void attachPrefs(BOARD& board, KeyValueStore* prefs) { + _prefs = prefs; + + char radio_fem_rxgain[8] = { 0 }; + _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values + + board.setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + } + + bool handleCommand(BOARD& board, const char* command, char* reply) { + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!board.loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", board.isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { + if (!board.loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else if (memcmp(&command[21], "on", 2) == 0) { + if (board.setLoRaFemLnaEnabled(true)) { + if (_prefs) _prefs->setByKey("fem_rxgain", "1"); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (memcmp(&command[21], "off", 3) == 0) { + if (board.setLoRaFemLnaEnabled(false)) { + if (_prefs) _prefs->setByKey("fem_rxgain", "0"); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled + } +}; diff --git a/test/test_fem_rxgain/test_fem_rxgain.cpp b/test/test_fem_rxgain/test_fem_rxgain.cpp index c07300385a5..e07f46e3db4 100644 --- a/test/test_fem_rxgain/test_fem_rxgain.cpp +++ b/test/test_fem_rxgain/test_fem_rxgain.cpp @@ -6,6 +6,7 @@ #include #include +#include #include // The RX gain (LNA) setting of the boards whose LoRa front end module can switch @@ -13,7 +14,9 @@ // same attachDynamicPrefs() and handleCommand() (the board class name aside, // see tools/refactoring/variant_duplication.py), which read the `fem_rxgain` key // from the store the role attaches, answer `get/set radio.fem.rxgain` and write -// the key back. These tests pin what the CLI replies and what lands in the store. +// the key back. These tests pin what the CLI replies and what lands in the store, +// for a verbatim copy of those bodies and for the shared FemRxGain the boards +// forward to now. namespace { @@ -139,6 +142,19 @@ bool VariantCopyBoard::handleCommand(const char* command, uint32_t sender_timest return false; // not handled } +// A board as the four are now: the handlers forward to the shared FemRxGain, +// which reaches the private helpers as a friend. +class SharedBoard : public FakeBoardBase { + friend class FemRxGain; + FemRxGain fem_rxgain; + +public: + void attachDynamicPrefs(KeyValueStore* prefs) { fem_rxgain.attachPrefs(*this, prefs); } + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + return fem_rxgain.handleCommand(*this, command, reply); + } +}; + template class FemRxGainTest : public ::testing::Test { protected: @@ -163,7 +179,7 @@ class FemRxGainTest : public ::testing::Test { } }; -using Implementations = ::testing::Types; +using Implementations = ::testing::Types; TYPED_TEST_SUITE(FemRxGainTest, Implementations); TYPED_TEST(FemRxGainTest, AttachReadsTheKeyOnceAndSwitchesTheLnaOn) { @@ -341,6 +357,34 @@ TYPED_TEST(FemRxGainTest, OtherCommandsAreNotHandled) { EXPECT_TRUE(this->store.writes.empty()); } +// Without a store the boards' copies dereference NULL on `set` (the change +// meshcore-dev/MeshCore#3496 makes to them); the shared code stores nothing and +// replies as with a store. No role in this tree calls handleCommand() before it +// attaches the store, so this case is new, not changed. +TEST(FemRxGainWithoutStore, SetSwitchesTheLnaAndStoresNothing) { + SharedBoard board; + char reply[160]; + char on[80] = "set radio.fem.rxgain on"; + char off[80] = "set radio.fem.rxgain off"; + char get[80] = "get radio.fem.rxgain"; + + EXPECT_TRUE(board.handleCommand(on, 0, reply)); + EXPECT_STREQ(reply, "OK - LoRa FEM RX gain on"); + EXPECT_TRUE(board.loRaFEMControl.lna_enabled); + EXPECT_TRUE(board.handleCommand(get, 0, reply)); + EXPECT_STREQ(reply, "> on"); + EXPECT_TRUE(board.handleCommand(off, 0, reply)); + EXPECT_STREQ(reply, "OK - LoRa FEM RX gain off"); + EXPECT_FALSE(board.loRaFEMControl.lna_enabled); + EXPECT_EQ(board.set_calls, (std::vector{ true, false })); +} + +// The shared object holds nothing but the store pointer, so the boards keep +// their size and layout (the pointer replaces their own `_prefs`). +TEST(FemRxGainWithoutStore, IsTheSizeOfAPointer) { + EXPECT_EQ(sizeof(FemRxGain), sizeof(KeyValueStore*)); +} + } // namespace int main(int argc, char **argv) { diff --git a/variants/heltec_t096/T096Board.cpp b/variants/heltec_t096/T096Board.cpp index ea26d2cc15a..860f79d23e1 100644 --- a/variants/heltec_t096/T096Board.cpp +++ b/variants/heltec_t096/T096Board.cpp @@ -136,45 +136,9 @@ bool T096Board::isLoRaFemLnaEnabled() const { } void T096Board::attachDynamicPrefs(KeyValueStore* prefs) { - _prefs = prefs; - - char radio_fem_rxgain[8] = { 0 }; - _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values - - setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + fem_rxgain.attachPrefs(*this, prefs); } bool T096Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { - if (strcmp(command, "get radio.fem.rxgain") == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); - } - return true; - } - if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else if (memcmp(&command[21], "on", 2) == 0) { - if (setLoRaFemLnaEnabled(true)) { - _prefs->setByKey("fem_rxgain", "1"); - strcpy(reply, "OK - LoRa FEM RX gain on"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else if (memcmp(&command[21], "off", 3) == 0) { - if (setLoRaFemLnaEnabled(false)) { - _prefs->setByKey("fem_rxgain", "0"); - strcpy(reply, "OK - LoRa FEM RX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else { - strcpy(reply, "Error: state must be on or off"); - } - return true; - } - - return false; // not handled + return fem_rxgain.handleCommand(*this, command, reply); } diff --git a/variants/heltec_t096/T096Board.h b/variants/heltec_t096/T096Board.h index e926c8a6a31..b9715ae18c0 100644 --- a/variants/heltec_t096/T096Board.h +++ b/variants/heltec_t096/T096Board.h @@ -5,10 +5,12 @@ #include #include #include +#include #include "LoRaFEMControl.h" class T096Board : public NRF52BoardDCDC { - KeyValueStore* _prefs = NULL; + friend class FemRxGain; + FemRxGain fem_rxgain; protected: #ifdef NRF52_POWER_MANAGEMENT diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 753824a05ae..a420ca63bc1 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -77,45 +77,9 @@ void HeltecTrackerV2Board::begin() { } void HeltecTrackerV2Board::attachDynamicPrefs(KeyValueStore* prefs) { - _prefs = prefs; - - char radio_fem_rxgain[8] = { 0 }; - _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values - - setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + fem_rxgain.attachPrefs(*this, prefs); } bool HeltecTrackerV2Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { - if (strcmp(command, "get radio.fem.rxgain") == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); - } - return true; - } - if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else if (memcmp(&command[21], "on", 2) == 0) { - if (setLoRaFemLnaEnabled(true)) { - _prefs->setByKey("fem_rxgain", "1"); - strcpy(reply, "OK - LoRa FEM RX gain on"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else if (memcmp(&command[21], "off", 3) == 0) { - if (setLoRaFemLnaEnabled(false)) { - _prefs->setByKey("fem_rxgain", "0"); - strcpy(reply, "OK - LoRa FEM RX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else { - strcpy(reply, "Error: state must be on or off"); - } - return true; - } - - return false; // not handled + return fem_rxgain.handleCommand(*this, command, reply); } diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h index 5fed2b65559..098fec4c860 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h @@ -3,10 +3,12 @@ #include #include #include +#include #include "LoRaFEMControl.h" class HeltecTrackerV2Board : public ESP32Board { - KeyValueStore* _prefs = NULL; + friend class FemRxGain; + FemRxGain fem_rxgain; bool setLoRaFemLnaEnabled(bool enable); bool isLoRaFemLnaEnabled() const; diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 794257d76ad..f5c1937032f 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -78,45 +78,9 @@ void HeltecV4Board::begin() { } void HeltecV4Board::attachDynamicPrefs(KeyValueStore* prefs) { - _prefs = prefs; - - char radio_fem_rxgain[8] = { 0 }; - _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values - - setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + fem_rxgain.attachPrefs(*this, prefs); } bool HeltecV4Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { - if (strcmp(command, "get radio.fem.rxgain") == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); - } - return true; - } - if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else if (memcmp(&command[21], "on", 2) == 0) { - if (setLoRaFemLnaEnabled(true)) { - _prefs->setByKey("fem_rxgain", "1"); - strcpy(reply, "OK - LoRa FEM RX gain on"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else if (memcmp(&command[21], "off", 3) == 0) { - if (setLoRaFemLnaEnabled(false)) { - _prefs->setByKey("fem_rxgain", "0"); - strcpy(reply, "OK - LoRa FEM RX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else { - strcpy(reply, "Error: state must be on or off"); - } - return true; - } - - return false; // not handled + return fem_rxgain.handleCommand(*this, command, reply); } diff --git a/variants/heltec_v4/HeltecV4Board.h b/variants/heltec_v4/HeltecV4Board.h index 79a690e8407..89c6c4cff91 100644 --- a/variants/heltec_v4/HeltecV4Board.h +++ b/variants/heltec_v4/HeltecV4Board.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "LoRaFEMControl.h" #ifndef ADC_MULTIPLIER @@ -10,7 +11,8 @@ #endif class HeltecV4Board : public ESP32Board { - KeyValueStore* _prefs = NULL; + friend class FemRxGain; + FemRxGain fem_rxgain; bool setLoRaFemLnaEnabled(bool enable); bool isLoRaFemLnaEnabled() const; diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index ea2cfbedb4a..d8f7585ab9d 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -100,45 +100,9 @@ bool HeltecV4R8Board::isLoRaFemLnaEnabled() const { } void HeltecV4R8Board::attachDynamicPrefs(KeyValueStore* prefs) { - _prefs = prefs; - - char radio_fem_rxgain[8] = { 0 }; - _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values - - setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); + fem_rxgain.attachPrefs(*this, prefs); } bool HeltecV4R8Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { - if (strcmp(command, "get radio.fem.rxgain") == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else { - sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); - } - return true; - } - if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { - if (!loRaFEMControl.isLnaCanControl()) { - strcpy(reply, "Error: unsupported"); - } else if (memcmp(&command[21], "on", 2) == 0) { - if (setLoRaFemLnaEnabled(true)) { - _prefs->setByKey("fem_rxgain", "1"); - strcpy(reply, "OK - LoRa FEM RX gain on"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else if (memcmp(&command[21], "off", 3) == 0) { - if (setLoRaFemLnaEnabled(false)) { - _prefs->setByKey("fem_rxgain", "0"); - strcpy(reply, "OK - LoRa FEM RX gain off"); - } else { - strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); - } - } else { - strcpy(reply, "Error: state must be on or off"); - } - return true; - } - - return false; // not handled + return fem_rxgain.handleCommand(*this, command, reply); } diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.h b/variants/heltec_v4_r8/HeltecV4R8Board.h index 7ab29f96200..7f9956317ea 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.h +++ b/variants/heltec_v4_r8/HeltecV4R8Board.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "LoRaFEMControl.h" #ifndef ADC_MULTIPLIER @@ -11,7 +12,8 @@ #endif class HeltecV4R8Board : public ESP32Board { - KeyValueStore* _prefs = NULL; + friend class FemRxGain; + FemRxGain fem_rxgain; bool setLoRaFemLnaEnabled(bool enable); bool isLoRaFemLnaEnabled() const; From e555194252aeb4c494f5436aba447e935b7457e7 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 19:31:45 +0200 Subject: [PATCH 100/136] fix(radio): refuse "nan" and "inf" by the text, not by the bits inRange() refused NaN and infinity by testing the bits of the parsed float. That test is not reliable: under -ffast-math (nRF52 builds use -Ofast) the compiler may assume a float is never NaN or infinite, and clang does fold the bit test away (measured: "nan" passed it at -Ofast, while arm-none-eabi-gcc and GCC 14 kept it). inRange() now takes the text as well and refuses the only spellings strtof()/atof() turn into NaN or infinity: after optional white space and sign, text that starts with 'n' or 'i'. An infinity from digits ("1e40") still fails the comparison. Every other input keeps its old meaning, including garbage that reads as 0 ("set rxdelay abc" -> OK, 0), which RadioPrefsChecks pins on purpose. This is why the integration branch does not take the stricter "must start with a digit or '.'" check of the upstream pull request: for "set af" and "set dutycycle" both give the same answers, for the generic float settings only this one leaves the garbage behaviour alone. Honest limit: the tests cannot tell this change from the old code or from no NaN check at all. The comparison inRange() already uses, "v >= 0 && v <= max", is false for a NaN, and it stayed false with clang and GCC 14 at -O2 and -Ofast (mutation without the text check: all prefs suites still green). The text check is what makes that independent of the compiler. RadioPrefsChecks gains "+nan", "infinity", " nan" for af and dutycycle and "+nan", "INF" for rxdelay, and its two comments now say text instead of bits. Verified: all 13 host environments, 354 of 354; GCC 14 in Docker builds test_common_node_prefs at -O2 and -Ofast with this, the old and the mutated code, all green; LilyGo_T-Echo_room_server, Heltec_v3_repeater and LilyGo_T-Echo_companion_radio_usb build. --- src/helpers/CommonRadioPrefs.cpp | 36 +++++++++++++++++++++----------- test/mocks/RadioPrefsChecks.h | 9 ++++++-- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 8fb97a1626e..c8709351d46 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -3,6 +3,7 @@ #include "Utils.h" #include "target.h" #include +#include namespace { @@ -59,14 +60,25 @@ const RadioSetting SETTINGS[] = { const int NUM_SETTINGS = sizeof(SETTINGS) / sizeof(SETTINGS[0]); -// True if v is a number in [0, max]. NaN and infinity are recognised by their bits: -// nRF52 builds use -Ofast, and its -ffast-math lets float comparisons assume that -// NaN and infinity never occur, so a comparison alone may let them through. -bool inRange(float v, float max) { - uint32_t bits; - memcpy(&bits, &v, sizeof(bits)); - if ((bits & 0x7F800000) == 0x7F800000) return false; - return v >= 0 && v <= max; +// strtof() and atof() also read "nan" and "inf" (any case, also "infinity" and +// "nan(...)"). A NaN cannot be refused by a range check: no ordered comparison is +// true for it, and under -ffast-math, which nRF52 builds use, the compiler may +// assume it never occurs and fold any test on the parsed value away (clang does +// that even with a test on the bits). So the text is checked: after optional +// white space and sign, those are the only spellings that start with 'n' or 'i'. +// Anything else keeps its old meaning, including garbage that reads as 0. +bool spellsNanOrInf(const char* s) { + while (isspace((unsigned char)*s)) s++; + if (*s == '+' || *s == '-') s++; + char c = (char) tolower((unsigned char)*s); + return c == 'n' || c == 'i'; +} + +// True if text is a number in [0, max]; v is its parsed value. An infinity from +// digits ("1e40") fails the comparison, which the compiler cannot fold because +// the value is only known at run time. +bool inRange(const char* text, float v, float max) { + return !spellsNanOrInf(text) && v >= 0 && v <= max; } // "get " @@ -154,10 +166,10 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest if (memcmp(command, "set af ", 7) == 0) { char* end; float af = strtof(&command[7], &end); - // inRange() rejects NaN and infinity by their bits. Two comparisons alone + // inRange() refuses "nan" and "inf" by the text. Two comparisons alone // cannot: no ordered comparison is true for a NaN, so "af < 0 || af > 9" // let "set af nan" through, which a T-Echo accepted and reported back as 0. - if (end == &command[7] || !inRange(af, 9)) { + if (end == &command[7] || !inRange(&command[7], af, 9)) { strcpy(reply, "ERROR: af must be 0-9"); } else { setAirtimeFactor(af); @@ -175,7 +187,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest } if (memcmp(command, "set dutycycle ", 14) == 0) { float dc = atof(&command[14]); - if (!inRange(dc, 100) || dc < 1) { + if (!inRange(&command[14], dc, 100) || dc < 1) { strcpy(reply, "ERROR: dutycycle must be 1-100"); } else { setAirtimeFactor((100.0f / dc) - 1.0f); @@ -247,7 +259,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest if (value == NULL) continue; if (s.cli == CLI_FLOAT) { float v = atof(value); - if (!inRange(v, s.max)) { + if (!inRange(value, v, s.max)) { strcpy(reply, s.range_error); return true; } diff --git a/test/mocks/RadioPrefsChecks.h b/test/mocks/RadioPrefsChecks.h index 5d39b90c40f..65dea6d46be 100644 --- a/test/mocks/RadioPrefsChecks.h +++ b/test/mocks/RadioPrefsChecks.h @@ -238,7 +238,7 @@ void expectRadioCliEdgeCases(P& p) { // "set af nan" used to be accepted, and the result depended on the compiler: // no ordered comparison is true for a NaN, and -ffast-math (nRF52 builds use // -Ofast) may additionally assume that NaN never occurs. inRange() reads the - // bits, so the answer is now the same on every build and needs no guard here. + // text, so the answer is now the same on every build and needs no guard here. EXPECT_EQ("ERROR: af must be 0-9", rejected("set af nan")); EXPECT_FALSE(isNanBits(p.airtime_factor)); EXPECT_FLOAT_EQ(0.0f, p.airtime_factor); @@ -253,7 +253,7 @@ void expectRadioCliEdgeCases(P& p) { EXPECT_EQ("OK - 33.0%", accepted("set dutycycle 33")); // Neither command takes a value that is not a finite number. inRange() - // decides that on the bits, so the answer does not depend on -ffast-math and + // decides that on the text, so the answer does not depend on -ffast-math and // the cases need no guard. "set dutycycle" writes airtime_factor through a // division and had the same hole as "set af". EXPECT_EQ("ERROR: af must be 0-9", rejected("set af -nan")); @@ -261,6 +261,9 @@ void expectRadioCliEdgeCases(P& p) { EXPECT_EQ("ERROR: af must be 0-9", rejected("set af 1e40")); EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle nan")); EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle inf")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af +nan")); + EXPECT_EQ("ERROR: af must be 0-9", rejected("set af infinity")); + EXPECT_EQ("ERROR: dutycycle must be 1-100", rejected("set dutycycle nan")); EXPECT_FALSE(isNanBits(p.airtime_factor)); // int.thresh: atoi, no range, stored as uint8_t @@ -299,6 +302,8 @@ void expectRadioCliEdgeCases(P& p) { EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay -0.01")); EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay nan")); EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay inf")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay +nan")); + EXPECT_EQ("Error, must be 0-20", rejected("set rxdelay INF")); EXPECT_FLOAT_EQ(20.0f, p.rx_delay_base); EXPECT_EQ("OK", accepted("set rxdelay abc")); EXPECT_FLOAT_EQ(0.0f, p.rx_delay_base); From a12d301b0d68d8f9b3852b12aa166128d5c9920f Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 19:34:24 +0200 Subject: [PATCH 101/136] docs(refactoring): list steps 9 and 11 in the step table Both arrived without touching the shared README. The table now names their notes, 0009 and 0011, and the paragraph of planned steps says why the numbers have gaps and that step 10 (note 0010) is still on its own branch. --- docs/refactoring/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index 8c0f1731144..959d692b5a0 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -51,11 +51,13 @@ The full analysis, with file and line references for every finding, is in | 2 | Let the core read its tuning values from the radio settings instead of a getter per role | [0002](0002-core-reads-radio-prefs.md) | ready for review | | 3 | Drive the radio settings (JSON, CLI, key/value store) from one table | [0003](0003-radio-settings-table.md) | ready for review | | 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4g; the boot sequence and the forwarding checks are the last two) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md), [0008](0008-forward-policy.md) | ready for review | +| 9 | One RadioLib adapter per chip family, part one: the receive detection the seven chip classes shared as copies (step 9a; 9b to 9d planned in the note) | [0009](0009-radio-chip-adapter.md) | 9a ready for review | +| 11 | One generic variant target built from a board description, cut A: one shared `radio_new_identity()` (cuts B to E planned in the note) | [0011](0011-variant-target.md) | cut A ready for review | -Planned next steps (each needs its own design note before code), in order of -expected benefit: one generic variant target -built from a board description; one RadioLib adapter per chip family; a -companion frame codec with a command table. See the review for the reasoning. +Steps are numbered after their design note, so the numbers have gaps: 5 to 8 +are notes of fixes and of step 4. Step 10, a companion frame codec with a +command table (note 0010), is in progress on its own branch. See the review for +the reasoning behind the order. ## Verifying a step yourself From 0a47cade50914d7ccd636c5541697324a73035c0 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:55:26 +0200 Subject: [PATCH 102/136] test(companion): characterize the frame protocol on the host handleCmdFrame() in examples/companion_radio/MyMesh.cpp is one chain of 62 branches that each state their own minimum frame length. Before it is restructured into a command table (docs/refactoring/0010), its behaviour is pinned on the host. [env:native_companion] builds the real MyMesh.cpp and DataStore.cpp against the mocks in test/mocks. The mocks gain what the companion needs: FSInfo and a (always empty) directory listing in FS.h, and the declaration of radio_new_identity() in target.h. MAX_CONTACTS and MAX_GROUP_CHANNELS are set for the whole build, as every companion environment does; with MAX_CONTACTS only in MyMesh.h, BaseChatMesh.cpp and MyMesh.cpp disagreed about the class layout. The harness drives a node only through the protocol (a fake serial interface and MyMesh::loop()), in zeroed storage, and observes the rest on the collaborators: flash, queued packets, reboot, radio and clock. - test_frame_sweep.cpp: every code 0 to 255 at every length 1 to 176 with five fills, one fresh node per frame, against frame_sweep.golden (replies byte for byte, side effect flags, and a digest of queued packets and stored files per code). Checked to be independent of test order and code order. The stack is scrubbed before each frame because CMD_SET_CHANNEL writes uninitialised stack bytes behind the channel name to /channels2; those bytes are zeroed before the file digest is taken. - test_commands.cpp: 49 tests with the frames an app sends: reply fields, state read back through the protocol, the boundary at each minimum, sub commands. Pins two oddities as they are: a contact added by hand cannot be shared, and a key of 32 zero bytes names one of the reserved anonymous slots, so REMOVE_CONTACT with it removes a slot and the contact count wraps. Mutation checked: 9 of 10 deliberate faults in MyMesh.cpp are caught, the tenth is an equivalent mutant (the len < 4 check of CMD_SEND_CHANNEL_DATA is covered by its later data_type check). No firmware source changes. --- .github/workflows/run-unit-tests.yml | 2 +- platformio.ini | 48 + test/mocks/FS.h | 29 + test/mocks/target.h | 4 + test/test_companion_frames/CompanionHarness.h | 214 ++ test/test_companion_frames/frame_sweep.golden | 3271 +++++++++++++++++ test/test_companion_frames/identity_stubs.cpp | 116 + test/test_companion_frames/test_commands.cpp | 796 ++++ .../test_companion_frames.cpp | 30 + .../test_frame_sweep.cpp | 219 ++ 10 files changed, 4728 insertions(+), 1 deletion(-) create mode 100644 test/test_companion_frames/CompanionHarness.h create mode 100644 test/test_companion_frames/frame_sweep.golden create mode 100644 test/test_companion_frames/identity_stubs.cpp create mode 100644 test/test_companion_frames/test_commands.cpp create mode 100644 test/test_companion_frames/test_companion_frames.cpp create mode 100644 test/test_companion_frames/test_frame_sweep.cpp diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6fca397a34e..4c4132f1158 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_chat -e native_identity_store -e native_node_prefs -e native_radio_wrappers -e native_server -e native_sensor -e native_repeater -e native_repeater_full -e native_region -vv + run: pio test -e native -e native_kiss_modem -e native_core -e native_ofast -e native_chat -e native_identity_store -e native_node_prefs -e native_radio_wrappers -e native_server -e native_sensor -e native_repeater -e native_repeater_full -e native_region -e native_companion -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/platformio.ini b/platformio.ini index 1cce9190f0c..c24238bb31e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -178,6 +178,7 @@ test_ignore = test_sensor_role test_repeater_role test_region_map + test_companion_frames build_src_filter = -<*> +<../src/Utils.cpp> @@ -335,6 +336,53 @@ build_src_filter = ${env:native_repeater.build_src_filter} lib_deps = google/googletest @ 1.17.0 +[env:native_companion] +; The companion on the host: the real MyMesh.cpp and DataStore.cpp against the mocks in +; test/mocks, so the frame protocol (handleCmdFrame) can be characterized before it +; is restructured (docs/refactoring/0010-companion-command-table.md). RP2040_PLATFORM +; selects the plain string file modes of the data store, which the in-memory file +; system mock implements. Private key export and import are compiled in, so both +; sides of those two branches are reachable. +platform = native +test_framework = googletest +build_flags = -std=c++17 + -D RP2040_PLATFORM + -D ENABLE_PRIVATE_KEY_EXPORT=1 + -D ENABLE_PRIVATE_KEY_IMPORT=1 + -D MAX_LORA_TX_POWER=22 + -D MAX_GROUP_CHANNELS=8 + ; set for the whole build, as every companion env does: BaseChatMesh.h sizes its + ; contact table from it, and a default in one file only would give two layouts + -D MAX_CONTACTS=100 + -I src + -I test/mocks + -I examples/companion_radio + ; the chat layer decodes channel secrets with this header-only library (lib_deps + ; below); the dependency finder does not scan sources pulled in from outside src_dir + -I ${platformio.libdeps_dir}/${this.__env__}/base64/src +test_build_src = yes +test_filter = test_companion_frames +build_src_filter = + -<*> + +<../src/Utils.cpp> + +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> + +<../src/Mesh.cpp> + +<../src/helpers/AdvertDataHelpers.cpp> + +<../src/helpers/BaseChatMesh.cpp> + +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/IdentityStore.cpp> + +<../src/helpers/StaticPoolPacketManager.cpp> + +<../src/helpers/TransportKeyStore.cpp> + +<../src/helpers/TxtDataHelpers.cpp> + +<../examples/companion_radio/DataStore.cpp> + +<../examples/companion_radio/MyMesh.cpp> +lib_deps = + google/googletest @ 1.17.0 + densaugeo/base64 @ ~1.4.0 + [env:native_core] platform = native test_framework = googletest diff --git a/test/mocks/FS.h b/test/mocks/FS.h index 2f0b594c190..e919724a3c4 100644 --- a/test/mocks/FS.h +++ b/test/mocks/FS.h @@ -61,10 +61,24 @@ class File : public Stream { void close() { _valid = false; } + // Directory listing, reached only by the companion's rescue CLI. Directories are + // not modelled (see FS::mkdir), so a listing is always empty. + File openNextFile() { return File(); } + bool isDirectory() const { return false; } + const char* name() const { return _name.c_str(); } + size_t size() const; + // Test helper: the position reached so far. size_t position() const { return _pos; } }; +// The RP2040 core's LittleFS reports its usage through this, and the companion's +// data store reads it for CMD_GET_BATT_AND_STORAGE. +struct FSInfo { + size_t totalBytes = 0; + size_t usedBytes = 0; +}; + namespace fs { class FS { @@ -104,6 +118,16 @@ class FS { bool remove(const char* name) { return files.erase(std::string(name)) > 0; } bool format() { files.clear(); return true; } + // A fixed capacity, and the bytes the files hold as the usage, so a reply that + // carries them is repeatable. + size_t total_bytes = 512 * 1024; + bool info(FSInfo& out) { + out.totalBytes = total_bytes; + out.usedBytes = 0; + for (auto& f : files) out.usedBytes += f.second.size(); + return true; + } + // Test helpers. void reset() { files.clear(); } size_t sizeOf(const char* name) { @@ -120,6 +144,11 @@ inline std::vector* File::data() const { return it == _fs->files.end() ? nullptr : &it->second; } +inline size_t File::size() const { + auto* d = data(); + return d ? d->size() : 0; +} + inline int File::read(void* dest, size_t len) { auto* d = data(); if (!d) return 0; diff --git a/test/mocks/target.h b/test/mocks/target.h index c9aa29212ee..00fb87f5660 100644 --- a/test/mocks/target.h +++ b/test/mocks/target.h @@ -139,6 +139,10 @@ class MockRTCClock : public mesh::RTCClock { }; inline MockBoard board; + +// A variant's target.cpp defines this; the companion calls it in begin() when no +// identity is stored yet. A suite that boots a companion defines it. +mesh::LocalIdentity radio_new_identity(); inline MockRTCClock rtc_clock; // SensorManager is concrete, so the real class serves as the stand-in; this one only diff --git a/test/test_companion_frames/CompanionHarness.h b/test/test_companion_frames/CompanionHarness.h new file mode 100644 index 00000000000..07bb4fa7b05 --- /dev/null +++ b/test/test_companion_frames/CompanionHarness.h @@ -0,0 +1,214 @@ +#pragma once + +// The companion on the host, driven through its frame protocol. A test hands a +// command frame to a fake serial interface, runs one MyMesh::loop() (which is where +// the firmware picks frames up), and gets back every frame the node wrote. Nothing +// reaches into the node: what the protocol cannot report is observed on its +// collaborators (the file system, the radio, the board, the clock, the packets +// queued for the air). +// +// Each node is built in zeroed storage. On a board, MyMesh is a global and starts +// zeroed, and the bytes of cmd_frame above the current frame's length are whatever +// an earlier frame left there. Zeroed storage makes that tail the same on every run, +// so a reply never depends on what the heap held before. + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "MyMesh.h" + +namespace companion { + +using Bytes = std::vector; + +// Stands in for the BLE, USB or WiFi interface. checkRecvFrame() returns what +// ArduinoSerialInterface returns for a complete frame: its bytes, copied into the +// node's cmd_frame, and its length. +class FakeSerial : public BaseSerialInterface { +public: + std::deque inbound; + std::vector written; + bool enabled = false; + int disable_calls = 0; + + void enable() override { enabled = true; } + void disable() override { enabled = false; disable_calls++; } + bool isEnabled() const override { return enabled; } + bool isConnected() const override { return true; } + bool isWriteBusy() const override { return false; } + + size_t writeFrame(const uint8_t src[], size_t len) override { + written.emplace_back(src, src + len); + return len; + } + + size_t checkRecvFrame(uint8_t dest[]) override { + if (inbound.empty()) return 0; + Bytes f = inbound.front(); + inbound.pop_front(); + memcpy(dest, f.data(), f.size()); + return f.size(); + } +}; + +// Opens up what the destructor needs: MyMesh allocates its clock and packet manager +// with new and never frees them, which is fine for a global and not for thousands +// of nodes in one test binary. +class Node : public MyMesh { +public: + using MyMesh::MyMesh; + int outboundTotal() const { return _mgr->getOutboundTotal(); } + // The packets waiting for the air, as they would be transmitted. + std::vector outbound() { + std::vector out; + for (int i = 0; i < _mgr->getOutboundTotal(); i++) { + uint8_t raw[MAX_TRANS_UNIT + 8]; + int n = _mgr->getOutboundByIdx(i)->writeTo(raw); + out.emplace_back(raw, raw + n); + } + return out; + } + void freeOwned() { + delete static_cast(_mgr); + delete static_cast(_ms); + } +}; + +constexpr uint32_t T0 = 1000; +constexpr uint32_t RTC0 = 1760000000; + +class Harness { +public: + // The RP2040 data store formats the LittleFS global (CMD_FACTORY_RESET), so the + // node runs on that instance. + fs::FS& fs = LittleFS; + StdRNG rng; + SimpleMeshTables tables; + FakeSerial serial; + std::unique_ptr store; + + Harness() { reset(); } + ~Harness() { destroy(); } + + void reset() { + destroy(); + g_mock_millis = T0; + board.reset(); + radio_driver.reset(); + rtc_clock = MockRTCClock(); // also clears the base's last unique timestamp + rtc_clock.now = RTC0; + sensors.node_lat = sensors.node_lon = 0; + fs.reset(); + rng.begin(1); + g_events.clear(); + serial = FakeSerial(); + } + + // Boots a node the way main.cpp does: the data store, then the mesh, then the + // interface. The file system is left as it is, so building a node again after + // a test stored something is a reboot. + Node& boot() { + destroy(); + store.reset(new DataStore(fs, rtc_clock)); + storage = std::unique_ptr(new unsigned char[sizeof(Node) + alignof(Node)]); + memset(storage.get(), 0, sizeof(Node) + alignof(Node)); + void* p = storage.get(); + size_t space = sizeof(Node) + alignof(Node); + p = std::align(alignof(Node), sizeof(Node), p, space); + node = new (p) Node(radio_driver, rng, rtc_clock, tables, *store); + store->begin(); + node->begin(); + node->startInterface(serial); + serial.written.clear(); + g_events.clear(); + return *node; + } + + Node& mesh() { return *node; } + + // One frame in, the frames the node wrote while handling it out. + std::vector send(const Bytes& frame) { + EXPECT_GE(frame.size(), 1u); + EXPECT_LE(frame.size(), (size_t)MAX_FRAME_SIZE); + serial.written.clear(); + serial.inbound.push_back(frame); + node->loop(); + return serial.written; + } + + // The only reply to a frame, or an empty frame if there was not exactly one. + Bytes reply(const Bytes& frame) { + auto out = send(frame); + EXPECT_EQ(out.size(), 1u) << "expected one reply frame"; + return out.size() == 1 ? out[0] : Bytes(); + } + +private: + std::unique_ptr storage; + Node* node = nullptr; + + void destroy() { + if (node) { + node->freeOwned(); + node->~Node(); + node = nullptr; + } + storage.reset(); + } +}; + +// ---- frame building ----------------------------------------------------------- + +inline Bytes frame(std::initializer_list b) { return Bytes(b); } + +inline void put(Bytes& f, const void* src, size_t n) { + const uint8_t* s = static_cast(src); + f.insert(f.end(), s, s + n); +} +inline void putU8(Bytes& f, uint8_t v) { f.push_back(v); } +inline void putU16(Bytes& f, uint16_t v) { put(f, &v, 2); } +inline void putU32(Bytes& f, uint32_t v) { put(f, &v, 4); } +inline void putI32(Bytes& f, int32_t v) { put(f, &v, 4); } +inline void putFill(Bytes& f, size_t n, uint8_t v) { f.insert(f.end(), n, v); } +inline void putStr(Bytes& f, const char* s) { put(f, s, strlen(s)); } +// A fixed width, zero padded text field. +inline void putField(Bytes& f, const char* s, size_t width) { + Bytes b(width, 0); + memcpy(b.data(), s, std::min(strlen(s), width)); + put(f, b.data(), width); +} + +inline uint32_t u32At(const Bytes& b, size_t at) { + uint32_t v = 0; + if (at + 4 <= b.size()) memcpy(&v, &b[at], 4); + return v; +} +inline uint16_t u16At(const Bytes& b, size_t at) { + uint16_t v = 0; + if (at + 2 <= b.size()) memcpy(&v, &b[at], 2); + return v; +} + +inline std::string hex(const Bytes& b) { + static const char* digits = "0123456789abcdef"; + std::string s; + for (uint8_t c : b) { s += digits[c >> 4]; s += digits[c & 15]; } + return s; +} + +} // namespace companion diff --git a/test/test_companion_frames/frame_sweep.golden b/test/test_companion_frames/frame_sweep.golden new file mode 100644 index 00000000000..f5a7e836d69 --- /dev/null +++ b/test/test_companion_frames/frame_sweep.golden @@ -0,0 +1,3271 @@ +00 00 1-176: 0101 +00 00 packets b5fd26618458b725 files f195e1bfb415df25 +00 01 1-176: 0101 +00 01 packets b5fd26618458b725 files f195e1bfb415df25 +00 81 1-176: 0101 +00 81 packets b5fd26618458b725 files f195e1bfb415df25 +00 ff 1-176: 0101 +00 ff packets b5fd26618458b725 files f195e1bfb415df25 +00 00/5a 1-176: 0101 +00 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +01 00 1-7: 0101 +01 00 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 00 packets b5fd26618458b725 files f195e1bfb415df25 +01 01 1-7: 0101 +01 01 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 01 packets b5fd26618458b725 files f195e1bfb415df25 +01 81 1-7: 0101 +01 81 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 81 packets b5fd26618458b725 files f195e1bfb415df25 +01 ff 1-7: 0101 +01 ff 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 ff packets b5fd26618458b725 files f195e1bfb415df25 +01 00/5a 1-7: 0101 +01 00/5a 8-176: 05011416a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf00000000000000000000000038f60d0090d003000a054130413141324133 +01 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +02 00 1-13: 0101 +02 00 14-176: 0600e661e360bc190000 [Q] +02 00 packets 8bb0a0113ef03351 files f195e1bfb415df25 +02 01 1-13: 0101 +02 01 14-176: 0102 +02 01 packets b5fd26618458b725 files f195e1bfb415df25 +02 81 1-13: 0101 +02 81 14-176: 0102 +02 81 packets b5fd26618458b725 files f195e1bfb415df25 +02 ff 1-13: 0101 +02 ff 14-176: 0102 +02 ff packets b5fd26618458b725 files f195e1bfb415df25 +02 00/5a 1-13: 0101 +02 00/5a 14-176: 0600e661e360bc190000 [Q] +02 00/5a packets 8bb0a0113ef03351 files f195e1bfb415df25 +03 00 1-6: 0101 +03 00 7-176: 00 [Q] +03 00 packets 57e0ad4e64c2e5a5 files f195e1bfb415df25 +03 01 1-176: 0101 +03 01 packets b5fd26618458b725 files f195e1bfb415df25 +03 81 1-176: 0101 +03 81 packets b5fd26618458b725 files f195e1bfb415df25 +03 ff 1-176: 0101 +03 ff packets b5fd26618458b725 files f195e1bfb415df25 +03 00/5a 1-6: 0101 +03 00/5a 7-176: 00 [Q] +03 00/5a packets 57e0ad4e64c2e5a5 files f195e1bfb415df25 +04 00 1-176: 0200000000 +04 00 packets b5fd26618458b725 files f195e1bfb415df25 +04 01 1-176: 0200000000 +04 01 packets b5fd26618458b725 files f195e1bfb415df25 +04 81 1-176: 0200000000 +04 81 packets b5fd26618458b725 files f195e1bfb415df25 +04 ff 1-176: 0200000000 +04 ff packets b5fd26618458b725 files f195e1bfb415df25 +04 00/5a 1-176: 0200000000 +04 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +05 00 1-176: 090078e768 +05 00 packets b5fd26618458b725 files f195e1bfb415df25 +05 01 1-176: 090078e768 +05 01 packets b5fd26618458b725 files f195e1bfb415df25 +05 81 1-176: 090078e768 +05 81 packets b5fd26618458b725 files f195e1bfb415df25 +05 ff 1-176: 090078e768 +05 ff packets b5fd26618458b725 files f195e1bfb415df25 +05 00/5a 1-176: 090078e768 +05 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +06 00 1-4: 0101 +06 00 5-176: 0106 +06 00 packets b5fd26618458b725 files f195e1bfb415df25 +06 01 1-4: 0101 +06 01 5-176: 0106 +06 01 packets b5fd26618458b725 files f195e1bfb415df25 +06 81 1-4: 0101 +06 81 5-176: 00 [T] +06 81 packets b5fd26618458b725 files f195e1bfb415df25 +06 ff 1-4: 0101 +06 ff 5-176: 00 [T] +06 ff packets b5fd26618458b725 files f195e1bfb415df25 +06 00/5a 1-4: 0101 +06 00/5a 5-176: 0106 +06 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +07 00 1-176: 00 [Q] +07 00 packets 8200a34eb6e907e5 files f195e1bfb415df25 +07 01 1-176: 00 [Q] +07 01 packets f4d08250bd74ae66 files f195e1bfb415df25 +07 81 1-176: 00 [Q] +07 81 packets 8200a34eb6e907e5 files f195e1bfb415df25 +07 ff 1-176: 00 [Q] +07 ff packets 8200a34eb6e907e5 files f195e1bfb415df25 +07 00/5a 1-176: 00 [Q] +07 00/5a packets 8200a34eb6e907e5 files f195e1bfb415df25 +08 00 1: 0101 +08 00 2-176: 00 [F] +08 00 packets b5fd26618458b725 files c7dcd4ae421fbfe6 +08 01 1: 0101 +08 01 2-176: 00 [F] +08 01 packets b5fd26618458b725 files adbb082f9a3cda76 +08 81 1: 0101 +08 81 2-176: 00 [F] +08 81 packets b5fd26618458b725 files d1e96f24ee203876 +08 ff 1: 0101 +08 ff 2-176: 00 [F] +08 ff packets b5fd26618458b725 files 9894030443024b26 +08 00/5a 1: 0101 +08 00/5a 2-176: 00 [F] +08 00/5a packets b5fd26618458b725 files c7dcd4ae421fbfe6 +09 00 1-135: 0101 +09 00 136-176: 00 +09 00 packets b5fd26618458b725 files f195e1bfb415df25 +09 01 1-135: 0101 +09 01 136-176: 00 +09 01 packets b5fd26618458b725 files f195e1bfb415df25 +09 81 1-135: 0101 +09 81 136-176: 00 +09 81 packets b5fd26618458b725 files f195e1bfb415df25 +09 ff 1-135: 0101 +09 ff 136-176: 00 +09 ff packets b5fd26618458b725 files f195e1bfb415df25 +09 00/5a 1-135: 0101 +09 00/5a 136-176: 00 +09 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +0a 00 1-176: 0a +0a 00 packets b5fd26618458b725 files f195e1bfb415df25 +0a 01 1-176: 0a +0a 01 packets b5fd26618458b725 files f195e1bfb415df25 +0a 81 1-176: 0a +0a 81 packets b5fd26618458b725 files f195e1bfb415df25 +0a ff 1-176: 0a +0a ff packets b5fd26618458b725 files f195e1bfb415df25 +0a 00/5a 1-176: 0a +0a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +0b 00 1-10: 0101 +0b 00 11-176: 0106 +0b 00 packets b5fd26618458b725 files f195e1bfb415df25 +0b 01 1-10: 0101 +0b 01 11-176: 0106 +0b 01 packets b5fd26618458b725 files f195e1bfb415df25 +0b 81 1-10: 0101 +0b 81 11-176: 0106 +0b 81 packets b5fd26618458b725 files f195e1bfb415df25 +0b ff 1-10: 0101 +0b ff 11-176: 0106 +0b ff packets b5fd26618458b725 files f195e1bfb415df25 +0b 00/5a 1-10: 0101 +0b 00/5a 11-176: 0106 +0b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +0c 00 1: 0101 +0c 00 2-176: 00 [FW] +0c 00 packets b5fd26618458b725 files e50d92948a033b33 +0c 01 1: 0101 +0c 01 2-176: 00 [FW] +0c 01 packets b5fd26618458b725 files bbd3e4d2730e5290 +0c 81 1: 0101 +0c 81 2-176: 0106 +0c 81 packets b5fd26618458b725 files f195e1bfb415df25 +0c ff 1: 0101 +0c ff 2-176: 00 [FW] +0c ff packets b5fd26618458b725 files 35799e75500886fe +0c 00/5a 1: 0101 +0c 00/5a 2-176: 00 [FW] +0c 00/5a packets b5fd26618458b725 files e50d92948a033b33 +0d 00 1-32: 0101 +0d 00 33-176: 00 +0d 00 packets b5fd26618458b725 files f195e1bfb415df25 +0d 01 1-32: 0101 +0d 01 33-176: 0102 +0d 01 packets b5fd26618458b725 files f195e1bfb415df25 +0d 81 1-32: 0101 +0d 81 33-176: 0102 +0d 81 packets b5fd26618458b725 files f195e1bfb415df25 +0d ff 1-32: 0101 +0d ff 33-176: 0102 +0d ff packets b5fd26618458b725 files f195e1bfb415df25 +0d 00/5a 1-32: 0101 +0d 00/5a 33-176: 00 +0d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +0e 00 1-8: 0101 +0e 00 9-176: 00 [F] +0e 00 packets b5fd26618458b725 files fd4cd485ec94d53d +0e 01 1-8: 0101 +0e 01 9-176: 00 [F] +0e 01 packets b5fd26618458b725 files 048e87c2142cb11d +0e 81 1-8: 0101 +0e 81 9-176: 0106 +0e 81 packets b5fd26618458b725 files f195e1bfb415df25 +0e ff 1-8: 0101 +0e ff 9-176: 00 [F] +0e ff packets b5fd26618458b725 files ee302cd8197b759d +0e 00/5a 1-8: 0101 +0e 00/5a 9-176: 00 [F] +0e 00/5a packets b5fd26618458b725 files fd4cd485ec94d53d +0f 00 1-32: 0101 +0f 00 33-176: 00 +0f 00 packets b5fd26618458b725 files f195e1bfb415df25 +0f 01 1-32: 0101 +0f 01 33-176: 0102 +0f 01 packets b5fd26618458b725 files f195e1bfb415df25 +0f 81 1-32: 0101 +0f 81 33-176: 0102 +0f 81 packets b5fd26618458b725 files f195e1bfb415df25 +0f ff 1-32: 0101 +0f ff 33-176: 0102 +0f ff packets b5fd26618458b725 files f195e1bfb415df25 +0f 00/5a 1-32: 0101 +0f 00/5a 33-176: 00 +0f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +10 00 1-32: 0101 +10 00 33-176: 0103 +10 00 packets b5fd26618458b725 files f195e1bfb415df25 +10 01 1-32: 0101 +10 01 33-176: 0102 +10 01 packets b5fd26618458b725 files f195e1bfb415df25 +10 81 1-32: 0101 +10 81 33-176: 0102 +10 81 packets b5fd26618458b725 files f195e1bfb415df25 +10 ff 1-32: 0101 +10 ff 33-176: 0102 +10 ff packets b5fd26618458b725 files f195e1bfb415df25 +10 00/5a 1-32: 0101 +10 00/5a 33-176: 0103 +10 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +11 00 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 00 33-176: 0102 +11 00 packets b5fd26618458b725 files f195e1bfb415df25 +11 01 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 01 33-176: 0102 +11 01 packets b5fd26618458b725 files f195e1bfb415df25 +11 81 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 81 33-176: 0102 +11 81 packets b5fd26618458b725 files f195e1bfb415df25 +11 ff 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 ff 33-176: 0102 +11 ff packets b5fd26618458b725 files f195e1bfb415df25 +11 00/5a 1-32: 0b1100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf0078e768c041e14202432344444565468647a748c849e94a0a4b2b4c4c4d6d4e8e4faf50d051f15212533354545575569657b758d859f95a1a5b3b5c5c5d7d5e9e5fbf60814130413141324133 +11 00/5a 33-176: 0102 +11 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +12 00 1-98: 0101 +12 00 99-176: 0106 +12 00 packets b5fd26618458b725 files f195e1bfb415df25 +12 01 1-98: 0101 +12 01 99-176: 0106 +12 01 packets b5fd26618458b725 files f195e1bfb415df25 +12 81 1-98: 0101 +12 81 99-176: 0106 +12 81 packets b5fd26618458b725 files f195e1bfb415df25 +12 ff 1-98: 0101 +12 ff 99-176: 0106 +12 ff packets b5fd26618458b725 files f195e1bfb415df25 +12 00/5a 1-98: 0101 +12 00/5a 99-176: 0106 +12 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +13 00 1-176: 0101 +13 00 packets b5fd26618458b725 files f195e1bfb415df25 +13 01 1-176: 0101 +13 01 packets b5fd26618458b725 files f195e1bfb415df25 +13 81 1-176: 0101 +13 81 packets b5fd26618458b725 files f195e1bfb415df25 +13 ff 1-176: 0101 +13 ff packets b5fd26618458b725 files f195e1bfb415df25 +13 00/5a 1-176: 0101 +13 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +14 00 1-176: 0c04100000000000020000 +14 00 packets b5fd26618458b725 files f195e1bfb415df25 +14 01 1-176: 0c04100000000000020000 +14 01 packets b5fd26618458b725 files f195e1bfb415df25 +14 81 1-176: 0c04100000000000020000 +14 81 packets b5fd26618458b725 files f195e1bfb415df25 +14 ff 1-176: 0c04100000000000020000 +14 ff packets b5fd26618458b725 files f195e1bfb415df25 +14 00/5a 1-176: 0c04100000000000020000 +14 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +15 00 1-8: 0101 +15 00 9-176: 00 [F] +15 00 packets b5fd26618458b725 files e6c8083d5c7fa2dd +15 01 1-8: 0101 +15 01 9-176: 00 [F] +15 01 packets b5fd26618458b725 files 2e96465e78984c3d +15 81 1-8: 0101 +15 81 9-176: 00 [F] +15 81 packets b5fd26618458b725 files 7bbd7ec3a51439bd +15 ff 1-8: 0101 +15 ff 9-176: 00 [F] +15 ff packets b5fd26618458b725 files e96ba48c6c34d11d +15 00/5a 1-8: 0101 +15 00/5a 9-176: 00 [F] +15 00/5a packets b5fd26618458b725 files e6c8083d5c7fa2dd +16 00 1: 0101 +16 00 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 00 packets b5fd26618458b725 files f195e1bfb415df25 +16 01 1: 0101 +16 01 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 01 packets b5fd26618458b725 files f195e1bfb415df25 +16 81 1: 0101 +16 81 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 81 packets b5fd26618458b725 files f195e1bfb415df25 +16 ff 1: 0101 +16 ff 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 ff packets b5fd26618458b725 files f195e1bfb415df25 +16 00/5a 1: 0101 +16 00/5a 2-176: 0d0e3208000000003135204f63742032303236004d6f636b00000000000000000000000000000000000000000000000000000000000000000000000076312e31382e30000000000000000000000000000000 +16 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +17 00 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 00 packets b5fd26618458b725 files f195e1bfb415df25 +17 01 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 01 packets b5fd26618458b725 files f195e1bfb415df25 +17 81 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 81 packets b5fd26618458b725 files f195e1bfb415df25 +17 ff 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 ff packets b5fd26618458b725 files f195e1bfb415df25 +17 00/5a 1-176: 0e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f +17 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +18 00 1-64: 0101 +18 00 65-176: 00 [F] +18 00 packets b5fd26618458b725 files 53e1cf9bb9c40a85 +18 01 1-64: 0101 +18 01 65-176: 00 [F] +18 01 packets b5fd26618458b725 files 4230f4c26f12ea85 +18 81 1-64: 0101 +18 81 65-176: 00 [F] +18 81 packets b5fd26618458b725 files b025383cc18e6a85 +18 ff 1-64: 0101 +18 ff 65-176: 00 [F] +18 ff packets b5fd26618458b725 files 63767c7047cbb285 +18 00/5a 1-64: 0101 +18 00/5a 65-176: 00 [F] +18 00/5a packets b5fd26618458b725 files 53e1cf9bb9c40a85 +19 00 1-5: 0101 +19 00 6-176: 00 [Q] +19 00 packets e362c0b056690971 files f195e1bfb415df25 +19 01 1-5: 0101 +19 01 6: 0106 +19 01 7-176: 00 [Q] +19 01 packets fed9c7f995ac7792 files f195e1bfb415df25 +19 81 1-5: 0101 +19 81 6-8: 0106 +19 81 9-176: 00 [Q] +19 81 packets 1a7cefb61ee8c8a1 files f195e1bfb415df25 +19 ff 1-176: 0101 +19 ff packets b5fd26618458b725 files f195e1bfb415df25 +19 00/5a 1-5: 0101 +19 00/5a 6-176: 00 [Q] +19 00/5a packets e362c0b056690971 files f195e1bfb415df25 +1a 00 1-32: 0101 +1a 00 33-176: 0600000000009e340000 [Q] +1a 00 packets 2cf9726923d80a25 files f195e1bfb415df25 +1a 01 1-32: 0101 +1a 01 33-176: 0102 +1a 01 packets b5fd26618458b725 files f195e1bfb415df25 +1a 81 1-32: 0101 +1a 81 33-176: 0102 +1a 81 packets b5fd26618458b725 files f195e1bfb415df25 +1a ff 1-32: 0101 +1a ff 33-176: 0102 +1a ff packets b5fd26618458b725 files f195e1bfb415df25 +1a 00/5a 1-32: 0101 +1a 00/5a 33-176: 0600000000009e340000 [Q] +1a 00/5a packets 2cf9726923d80a25 files f195e1bfb415df25 +1b 00 1-32: 0101 +1b 00 33-176: 06000078e768bc190000 [Q] +1b 00 packets 617c36223b95db65 files f195e1bfb415df25 +1b 01 1-32: 0101 +1b 01 33-176: 0102 +1b 01 packets b5fd26618458b725 files f195e1bfb415df25 +1b 81 1-32: 0101 +1b 81 33-176: 0102 +1b 81 packets b5fd26618458b725 files f195e1bfb415df25 +1b ff 1-32: 0101 +1b ff 33-176: 0102 +1b ff packets b5fd26618458b725 files f195e1bfb415df25 +1b 00/5a 1-32: 0101 +1b 00/5a 33-176: 06000078e768bc190000 [Q] +1b 00/5a packets 617c36223b95db65 files f195e1bfb415df25 +1c 00 1-32: 0101 +1c 00 33-176: 0102 +1c 00 packets b5fd26618458b725 files f195e1bfb415df25 +1c 01 1-32: 0101 +1c 01 33-176: 0102 +1c 01 packets b5fd26618458b725 files f195e1bfb415df25 +1c 81 1-32: 0101 +1c 81 33-176: 0102 +1c 81 packets b5fd26618458b725 files f195e1bfb415df25 +1c ff 1-32: 0101 +1c ff 33-176: 0102 +1c ff packets b5fd26618458b725 files f195e1bfb415df25 +1c 00/5a 1-32: 0101 +1c 00/5a 33-176: 0102 +1c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +1d 00 1-32: 0101 +1d 00 33-176: 00 +1d 00 packets b5fd26618458b725 files f195e1bfb415df25 +1d 01 1-32: 0101 +1d 01 33-176: 00 +1d 01 packets b5fd26618458b725 files f195e1bfb415df25 +1d 81 1-32: 0101 +1d 81 33-176: 00 +1d 81 packets b5fd26618458b725 files f195e1bfb415df25 +1d ff 1-32: 0101 +1d ff 33-176: 00 +1d ff packets b5fd26618458b725 files f195e1bfb415df25 +1d 00/5a 1-32: 0101 +1d 00/5a 33-176: 00 +1d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +1e 00 1-32: 0101 +1e 00 33-176: 03000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +1e 00 packets b5fd26618458b725 files f195e1bfb415df25 +1e 01 1-32: 0101 +1e 01 33-176: 0102 +1e 01 packets b5fd26618458b725 files f195e1bfb415df25 +1e 81 1-32: 0101 +1e 81 33-176: 0102 +1e 81 packets b5fd26618458b725 files f195e1bfb415df25 +1e ff 1-32: 0101 +1e ff 33-176: 0102 +1e ff packets b5fd26618458b725 files f195e1bfb415df25 +1e 00/5a 1-32: 0101 +1e 00/5a 33-176: 03000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +1e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +1f 00 1: 0101 +1f 00 2-176: 12005075626c696300000000000000000000000000000000000000000000000000008b3387e9c5cdea6ac9e5edbaa115cd72 +1f 00 packets b5fd26618458b725 files f195e1bfb415df25 +1f 01 1: 0101 +1f 01 2-176: 1201000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +1f 01 packets b5fd26618458b725 files f195e1bfb415df25 +1f 81 1: 0101 +1f 81 2-176: 0102 +1f 81 packets b5fd26618458b725 files f195e1bfb415df25 +1f ff 1: 0101 +1f ff 2-176: 0102 +1f ff packets b5fd26618458b725 files f195e1bfb415df25 +1f 00/5a 1: 0101 +1f 00/5a 2-176: 12005075626c696300000000000000000000000000000000000000000000000000008b3387e9c5cdea6ac9e5edbaa115cd72 +1f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +20 00 1-49: 0101 +20 00 50-65: 00 [F] +20 00 66-176: 0101 +20 00 packets b5fd26618458b725 files 434dff1aba15f905 +20 01 1-49: 0101 +20 01 50-65: 00 [F] +20 01 66-176: 0101 +20 01 packets b5fd26618458b725 files 6848feb295978b45 +20 81 1-49: 0101 +20 81 50-65: 0102 +20 81 66-176: 0101 +20 81 packets b5fd26618458b725 files f195e1bfb415df25 +20 ff 1-49: 0101 +20 ff 50-65: 0102 +20 ff 66-176: 0101 +20 ff packets b5fd26618458b725 files f195e1bfb415df25 +20 00/5a 1-49: 0101 +20 00/5a 50-65: 00 [F] +20 00/5a 66-176: 0101 +20 00/5a packets b5fd26618458b725 files 434dff1aba15f905 +21 00 1-176: 130000200000 +21 00 packets b5fd26618458b725 files f195e1bfb415df25 +21 01 1-176: 130000200000 +21 01 packets b5fd26618458b725 files f195e1bfb415df25 +21 81 1-176: 130000200000 +21 81 packets b5fd26618458b725 files f195e1bfb415df25 +21 ff 1-176: 130000200000 +21 ff packets b5fd26618458b725 files f195e1bfb415df25 +21 00/5a 1-176: 130000200000 +21 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +22 00 1: 0101 +22 00 2-176: 0104 +22 00 packets b5fd26618458b725 files f195e1bfb415df25 +22 01 1: 0101 +22 01 2-176: 0104 +22 01 packets b5fd26618458b725 files f195e1bfb415df25 +22 81 1: 0101 +22 81 2-176: 0104 +22 81 packets b5fd26618458b725 files f195e1bfb415df25 +22 ff 1: 0101 +22 ff 2-176: 0104 +22 ff packets b5fd26618458b725 files f195e1bfb415df25 +22 00/5a 1: 0101 +22 00/5a 2-176: 0104 +22 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +23 00 1-176: 0104 +23 00 packets b5fd26618458b725 files f195e1bfb415df25 +23 01 1-176: 0104 +23 01 packets b5fd26618458b725 files f195e1bfb415df25 +23 81 1-176: 0104 +23 81 packets b5fd26618458b725 files f195e1bfb415df25 +23 ff 1-176: 0104 +23 ff packets b5fd26618458b725 files f195e1bfb415df25 +23 00/5a 1-176: 0104 +23 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +24 00 1-10: 0101 +24 00 11: 0600000000003c190000 [Q] +24 00 12: 0600000000007a270000 [Q] +24 00 13: 06000000000074370000 [Q] +24 00 14: 0600000000002a490000 [Q] +24 00 15: 0600000000009c5c0000 [Q] +24 00 16: 060000000000ca710000 [Q] +24 00 17: 060000000000b4880000 [Q] +24 00 18: 0600000000005aa10000 [Q] +24 00 19: 060000000000bcbb0000 [Q] +24 00 20: 060000000000dad70000 [Q] +24 00 21: 060000000000b4f50000 [Q] +24 00 22: 0600000000004a150100 [Q] +24 00 23: 0600000000009c360100 [Q] +24 00 24: 060000000000aa590100 [Q] +24 00 25: 060000000000747e0100 [Q] +24 00 26: 060000000000faa40100 [Q] +24 00 27: 0600000000003ccd0100 [Q] +24 00 28: 0600000000003af70100 [Q] +24 00 29: 060000000000f4220200 [Q] +24 00 30: 0600000000006a500200 [Q] +24 00 31: 0600000000009c7f0200 [Q] +24 00 32: 0600000000008ab00200 [Q] +24 00 33: 06000000000034e30200 [Q] +24 00 34: 0600000000009a170300 [Q] +24 00 35: 060000000000bc4d0300 [Q] +24 00 36: 0600000000009a850300 [Q] +24 00 37: 06000000000034bf0300 [Q] +24 00 38: 0600000000008afa0300 [Q] +24 00 39: 0600000000009c370400 [Q] +24 00 40: 0600000000006a760400 [Q] +24 00 41: 060000000000f4b60400 [Q] +24 00 42: 0600000000003af90400 [Q] +24 00 43: 0600000000003c3d0500 [Q] +24 00 44: 060000000000fa820500 [Q] +24 00 45: 06000000000074ca0500 [Q] +24 00 46: 060000000000aa130600 [Q] +24 00 47: 0600000000009c5e0600 [Q] +24 00 48: 0600000000004aab0600 [Q] +24 00 49: 060000000000b4f90600 [Q] +24 00 50: 060000000000da490700 [Q] +24 00 51: 060000000000bc9b0700 [Q] +24 00 52: 0600000000005aef0700 [Q] +24 00 53: 060000000000b4440800 [Q] +24 00 54: 060000000000ca9b0800 [Q] +24 00 55: 0600000000009cf40800 [Q] +24 00 56: 0600000000002a4f0900 [Q] +24 00 57: 06000000000074ab0900 [Q] +24 00 58: 0600000000007a090a00 [Q] +24 00 59: 0600000000003c690a00 [Q] +24 00 60: 060000000000baca0a00 [Q] +24 00 61: 060000000000f42d0b00 [Q] +24 00 62: 060000000000ea920b00 [Q] +24 00 63: 0600000000009cf90b00 [Q] +24 00 64: 0600000000000a620c00 [Q] +24 00 65: 06000000000034cc0c00 [Q] +24 00 66: 0600000000001a380d00 [Q] +24 00 67: 060000000000bca50d00 [Q] +24 00 68: 0600000000001a150e00 [Q] +24 00 69: 06000000000034860e00 [Q] +24 00 70: 0600000000000af90e00 [Q] +24 00 71: 0600000000009c6d0f00 [Q] +24 00 72: 060000000000eae30f00 [Q] +24 00 73: 060000000000f45b1000 [Q] +24 00 74: 0600000000003a440000 [Q] +24 00 75-176: 0106 +24 00 packets 4d57cbd52ed197a5 files f195e1bfb415df25 +24 01 1-10: 0101 +24 01 11: 0106 +24 01 12: 060001010101f81a0000 [Q] +24 01 13: 0106 +24 01 14: 060001010101ae2c0000 [Q] +24 01 15: 0106 +24 01 16: 060001010101dc410000 [Q] +24 01 17: 0106 +24 01 18: 060001010101825a0000 [Q] +24 01 19: 0106 +24 01 20: 060001010101a0760000 [Q] +24 01 21: 0106 +24 01 22: 06000101010136960000 [Q] +24 01 23: 0106 +24 01 24: 06000101010144b90000 [Q] +24 01 25: 0106 +24 01 26: 060001010101cadf0000 [Q] +24 01 27: 0106 +24 01 28: 060001010101c8090100 [Q] +24 01 29: 0106 +24 01 30: 0600010101013e370100 [Q] +24 01 31: 0106 +24 01 32: 0600010101012c680100 [Q] +24 01 33: 0106 +24 01 34: 060001010101929c0100 [Q] +24 01 35: 0106 +24 01 36: 06000101010170d40100 [Q] +24 01 37: 0106 +24 01 38: 060001010101c60f0200 [Q] +24 01 39: 0106 +24 01 40: 060001010101944e0200 [Q] +24 01 41: 0106 +24 01 42: 060001010101da900200 [Q] +24 01 43: 0106 +24 01 44: 06000101010198d60200 [Q] +24 01 45: 0106 +24 01 46: 060001010101ce1f0300 [Q] +24 01 47: 0106 +24 01 48: 0600010101017c6c0300 [Q] +24 01 49: 0106 +24 01 50: 060001010101a2bc0300 [Q] +24 01 51: 0106 +24 01 52: 06000101010140100400 [Q] +24 01 53: 0106 +24 01 54: 06000101010156670400 [Q] +24 01 55: 0106 +24 01 56: 060001010101e4c10400 [Q] +24 01 57: 0106 +24 01 58: 060001010101ea1f0500 [Q] +24 01 59: 0106 +24 01 60: 06000101010168810500 [Q] +24 01 61: 0106 +24 01 62: 0600010101015ee60500 [Q] +24 01 63: 0106 +24 01 64: 060001010101cc4e0600 [Q] +24 01 65: 0106 +24 01 66: 060001010101b2ba0600 [Q] +24 01 67: 0106 +24 01 68: 060001010101102a0700 [Q] +24 01 69: 0106 +24 01 70: 060001010101e69c0700 [Q] +24 01 71: 0106 +24 01 72: 06000101010134130800 [Q] +24 01 73: 0106 +24 01 74: 060001010101fa8c0800 [Q] +24 01 75: 0106 +24 01 76: 060001010101380a0900 [Q] +24 01 77: 0106 +24 01 78: 060001010101ee8a0900 [Q] +24 01 79: 0106 +24 01 80: 0600010101011c0f0a00 [Q] +24 01 81: 0106 +24 01 82: 060001010101c2960a00 [Q] +24 01 83: 0106 +24 01 84: 060001010101e0210b00 [Q] +24 01 85: 0106 +24 01 86: 06000101010176b00b00 [Q] +24 01 87: 0106 +24 01 88: 06000101010184420c00 [Q] +24 01 89: 0106 +24 01 90: 0600010101010ad80c00 [Q] +24 01 91: 0106 +24 01 92: 06000101010108710d00 [Q] +24 01 93: 0106 +24 01 94: 0600010101017e0d0e00 [Q] +24 01 95: 0106 +24 01 96: 0600010101016cad0e00 [Q] +24 01 97: 0106 +24 01 98: 060001010101d2500f00 [Q] +24 01 99: 0106 +24 01 100: 060001010101b0f70f00 [Q] +24 01 101: 0106 +24 01 102: 06000101010106a21000 [Q] +24 01 103: 0106 +24 01 104: 060001010101d44f1100 [Q] +24 01 105: 0106 +24 01 106: 0600010101011a011200 [Q] +24 01 107: 0106 +24 01 108: 060001010101d8b51200 [Q] +24 01 109: 0106 +24 01 110: 0600010101010e6e1300 [Q] +24 01 111: 0106 +24 01 112: 060001010101bc291400 [Q] +24 01 113: 0106 +24 01 114: 060001010101e2e81400 [Q] +24 01 115: 0106 +24 01 116: 06000101010180ab1500 [Q] +24 01 117: 0106 +24 01 118: 06000101010196711600 [Q] +24 01 119: 0106 +24 01 120: 060001010101243b1700 [Q] +24 01 121: 0106 +24 01 122: 0600010101012a081800 [Q] +24 01 123: 0106 +24 01 124: 060001010101a8d81800 [Q] +24 01 125: 0106 +24 01 126: 0600010101019eac1900 [Q] +24 01 127: 0106 +24 01 128: 0600010101010c841a00 [Q] +24 01 129: 0106 +24 01 130: 060001010101f25e1b00 [Q] +24 01 131: 0106 +24 01 132: 060001010101503d1c00 [Q] +24 01 133: 0106 +24 01 134: 060001010101261f1d00 [Q] +24 01 135: 0106 +24 01 136: 06000101010174041e00 [Q] +24 01 137: 0106 +24 01 138: 060001010101ba7b0000 [Q] +24 01 139-176: 0106 +24 01 packets 48df205980681825 files f195e1bfb415df25 +24 81 1-10: 0101 +24 81 11: 0106 +24 81 12: 060081818181f81a0000 [Q] +24 81 13: 0106 +24 81 14: 060081818181ae2c0000 [Q] +24 81 15: 0106 +24 81 16: 060081818181dc410000 [Q] +24 81 17: 0106 +24 81 18: 060081818181825a0000 [Q] +24 81 19: 0106 +24 81 20: 060081818181a0760000 [Q] +24 81 21: 0106 +24 81 22: 06008181818136960000 [Q] +24 81 23: 0106 +24 81 24: 06008181818144b90000 [Q] +24 81 25: 0106 +24 81 26: 060081818181cadf0000 [Q] +24 81 27: 0106 +24 81 28: 060081818181c8090100 [Q] +24 81 29: 0106 +24 81 30: 0600818181813e370100 [Q] +24 81 31: 0106 +24 81 32: 0600818181812c680100 [Q] +24 81 33: 0106 +24 81 34: 060081818181929c0100 [Q] +24 81 35: 0106 +24 81 36: 06008181818170d40100 [Q] +24 81 37: 0106 +24 81 38: 060081818181c60f0200 [Q] +24 81 39: 0106 +24 81 40: 060081818181944e0200 [Q] +24 81 41: 0106 +24 81 42: 060081818181da900200 [Q] +24 81 43: 0106 +24 81 44: 06008181818198d60200 [Q] +24 81 45: 0106 +24 81 46: 060081818181ce1f0300 [Q] +24 81 47: 0106 +24 81 48: 0600818181817c6c0300 [Q] +24 81 49: 0106 +24 81 50: 060081818181a2bc0300 [Q] +24 81 51: 0106 +24 81 52: 06008181818140100400 [Q] +24 81 53: 0106 +24 81 54: 06008181818156670400 [Q] +24 81 55: 0106 +24 81 56: 060081818181e4c10400 [Q] +24 81 57: 0106 +24 81 58: 060081818181ea1f0500 [Q] +24 81 59: 0106 +24 81 60: 06008181818168810500 [Q] +24 81 61: 0106 +24 81 62: 0600818181815ee60500 [Q] +24 81 63: 0106 +24 81 64: 060081818181cc4e0600 [Q] +24 81 65: 0106 +24 81 66: 060081818181b2ba0600 [Q] +24 81 67: 0106 +24 81 68: 060081818181102a0700 [Q] +24 81 69: 0106 +24 81 70: 060081818181e69c0700 [Q] +24 81 71: 0106 +24 81 72: 06008181818134130800 [Q] +24 81 73: 0106 +24 81 74: 060081818181fa8c0800 [Q] +24 81 75: 0106 +24 81 76: 060081818181380a0900 [Q] +24 81 77: 0106 +24 81 78: 060081818181ee8a0900 [Q] +24 81 79: 0106 +24 81 80: 0600818181811c0f0a00 [Q] +24 81 81: 0106 +24 81 82: 060081818181c2960a00 [Q] +24 81 83: 0106 +24 81 84: 060081818181e0210b00 [Q] +24 81 85: 0106 +24 81 86: 06008181818176b00b00 [Q] +24 81 87: 0106 +24 81 88: 06008181818184420c00 [Q] +24 81 89: 0106 +24 81 90: 0600818181810ad80c00 [Q] +24 81 91: 0106 +24 81 92: 06008181818108710d00 [Q] +24 81 93: 0106 +24 81 94: 0600818181817e0d0e00 [Q] +24 81 95: 0106 +24 81 96: 0600818181816cad0e00 [Q] +24 81 97: 0106 +24 81 98: 060081818181d2500f00 [Q] +24 81 99: 0106 +24 81 100: 060081818181b0f70f00 [Q] +24 81 101: 0106 +24 81 102: 06008181818106a21000 [Q] +24 81 103: 0106 +24 81 104: 060081818181d44f1100 [Q] +24 81 105: 0106 +24 81 106: 0600818181811a011200 [Q] +24 81 107: 0106 +24 81 108: 060081818181d8b51200 [Q] +24 81 109: 0106 +24 81 110: 0600818181810e6e1300 [Q] +24 81 111: 0106 +24 81 112: 060081818181bc291400 [Q] +24 81 113: 0106 +24 81 114: 060081818181e2e81400 [Q] +24 81 115: 0106 +24 81 116: 06008181818180ab1500 [Q] +24 81 117: 0106 +24 81 118: 06008181818196711600 [Q] +24 81 119: 0106 +24 81 120: 060081818181243b1700 [Q] +24 81 121: 0106 +24 81 122: 0600818181812a081800 [Q] +24 81 123: 0106 +24 81 124: 060081818181a8d81800 [Q] +24 81 125: 0106 +24 81 126: 0600818181819eac1900 [Q] +24 81 127: 0106 +24 81 128: 0600818181810c841a00 [Q] +24 81 129: 0106 +24 81 130: 060081818181f25e1b00 [Q] +24 81 131: 0106 +24 81 132: 060081818181503d1c00 [Q] +24 81 133: 0106 +24 81 134: 060081818181261f1d00 [Q] +24 81 135: 0106 +24 81 136: 06008181818174041e00 [Q] +24 81 137: 0106 +24 81 138: 060081818181ba7b0000 [Q] +24 81 139-176: 0106 +24 81 packets 10b7b541e9b0c725 files f195e1bfb415df25 +24 ff 1-10: 0101 +24 ff 11-17: 0106 +24 ff 18: 0600ffffffff60250000 [Q] +24 ff 19-25: 0106 +24 ff 26: 0600ffffffffe64b0000 [Q] +24 ff 27-33: 0106 +24 ff 34: 0600ffffffff4c800000 [Q] +24 ff 35-41: 0106 +24 ff 42: 0600ffffffff92c20000 [Q] +24 ff 43-49: 0106 +24 ff 50: 0600ffffffffb8120100 [Q] +24 ff 51-57: 0106 +24 ff 58: 0600ffffffffbe700100 [Q] +24 ff 59-65: 0106 +24 ff 66: 0600ffffffffa4dc0100 [Q] +24 ff 67-73: 0106 +24 ff 74: 0600ffffffff6a560200 [Q] +24 ff 75-81: 0106 +24 ff 82: 0600ffffffff10de0200 [Q] +24 ff 83-89: 0106 +24 ff 90: 0600ffffffff96730300 [Q] +24 ff 91-97: 0106 +24 ff 98: 0600fffffffffc160400 [Q] +24 ff 99-105: 0106 +24 ff 106: 0600ffffffff42c80400 [Q] +24 ff 107-113: 0106 +24 ff 114: 0600ffffffff68870500 [Q] +24 ff 115-121: 0106 +24 ff 122: 0600ffffffff6e540600 [Q] +24 ff 123-129: 0106 +24 ff 130: 0600ffffffff542f0700 [Q] +24 ff 131-137: 0106 +24 ff 138: 0600ffffffff1a180800 [Q] +24 ff 139-145: 0106 +24 ff 146: 0600ffffffffc00e0900 [Q] +24 ff 147-153: 0106 +24 ff 154: 0600ffffffff46130a00 [Q] +24 ff 155-161: 0106 +24 ff 162: 0600ffffffffac250b00 [Q] +24 ff 163-169: 0106 +24 ff 170: 0600fffffffff2450c00 [Q] +24 ff 171-176: 0106 +24 ff packets 8a5cb1e2a44f0ca9 files f195e1bfb415df25 +24 00/5a 1-10: 0101 +24 00/5a 11: 0600000000003c190000 [Q] +24 00/5a 12: 0600000000007a270000 [Q] +24 00/5a 13: 06000000000074370000 [Q] +24 00/5a 14: 0600000000002a490000 [Q] +24 00/5a 15: 0600000000009c5c0000 [Q] +24 00/5a 16: 060000000000ca710000 [Q] +24 00/5a 17: 060000000000b4880000 [Q] +24 00/5a 18: 0600000000005aa10000 [Q] +24 00/5a 19: 060000000000bcbb0000 [Q] +24 00/5a 20: 060000000000dad70000 [Q] +24 00/5a 21: 060000000000b4f50000 [Q] +24 00/5a 22: 0600000000004a150100 [Q] +24 00/5a 23: 0600000000009c360100 [Q] +24 00/5a 24: 060000000000aa590100 [Q] +24 00/5a 25: 060000000000747e0100 [Q] +24 00/5a 26: 060000000000faa40100 [Q] +24 00/5a 27: 0600000000003ccd0100 [Q] +24 00/5a 28: 0600000000003af70100 [Q] +24 00/5a 29: 060000000000f4220200 [Q] +24 00/5a 30: 0600000000006a500200 [Q] +24 00/5a 31: 0600000000009c7f0200 [Q] +24 00/5a 32: 0600000000008ab00200 [Q] +24 00/5a 33: 06000000000034e30200 [Q] +24 00/5a 34: 0600000000009a170300 [Q] +24 00/5a 35: 060000000000bc4d0300 [Q] +24 00/5a 36: 0600000000009a850300 [Q] +24 00/5a 37: 06000000000034bf0300 [Q] +24 00/5a 38: 0600000000008afa0300 [Q] +24 00/5a 39: 0600000000009c370400 [Q] +24 00/5a 40: 0600000000006a760400 [Q] +24 00/5a 41: 060000000000f4b60400 [Q] +24 00/5a 42: 0600000000003af90400 [Q] +24 00/5a 43: 0600000000003c3d0500 [Q] +24 00/5a 44: 060000000000fa820500 [Q] +24 00/5a 45: 06000000000074ca0500 [Q] +24 00/5a 46: 060000000000aa130600 [Q] +24 00/5a 47: 0600000000009c5e0600 [Q] +24 00/5a 48: 0600000000004aab0600 [Q] +24 00/5a 49: 060000000000b4f90600 [Q] +24 00/5a 50: 060000000000da490700 [Q] +24 00/5a 51: 060000000000bc9b0700 [Q] +24 00/5a 52: 0600000000005aef0700 [Q] +24 00/5a 53: 060000000000b4440800 [Q] +24 00/5a 54: 060000000000ca9b0800 [Q] +24 00/5a 55: 0600000000009cf40800 [Q] +24 00/5a 56: 0600000000002a4f0900 [Q] +24 00/5a 57: 06000000000074ab0900 [Q] +24 00/5a 58: 0600000000007a090a00 [Q] +24 00/5a 59: 0600000000003c690a00 [Q] +24 00/5a 60: 060000000000baca0a00 [Q] +24 00/5a 61: 060000000000f42d0b00 [Q] +24 00/5a 62: 060000000000ea920b00 [Q] +24 00/5a 63: 0600000000009cf90b00 [Q] +24 00/5a 64: 0600000000000a620c00 [Q] +24 00/5a 65: 06000000000034cc0c00 [Q] +24 00/5a 66: 0600000000001a380d00 [Q] +24 00/5a 67: 060000000000bca50d00 [Q] +24 00/5a 68: 0600000000001a150e00 [Q] +24 00/5a 69: 06000000000034860e00 [Q] +24 00/5a 70: 0600000000000af90e00 [Q] +24 00/5a 71: 0600000000009c6d0f00 [Q] +24 00/5a 72: 060000000000eae30f00 [Q] +24 00/5a 73: 060000000000f45b1000 [Q] +24 00/5a 74: 0600000000003a440000 [Q] +24 00/5a 75-176: 0106 +24 00/5a packets 4d57cbd52ed197a5 files f195e1bfb415df25 +25 00 1-4: 0101 +25 00 5-176: 00 [F] +25 00 packets b5fd26618458b725 files b06f83b83abc3241 +25 01 1-4: 0101 +25 01 5-176: 0106 +25 01 packets b5fd26618458b725 files f195e1bfb415df25 +25 81 1-4: 0101 +25 81 5-176: 0106 +25 81 packets b5fd26618458b725 files f195e1bfb415df25 +25 ff 1-4: 0101 +25 ff 5-176: 0106 +25 ff packets b5fd26618458b725 files f195e1bfb415df25 +25 00/5a 1-4: 0101 +25 00/5a 5-176: 00 [F] +25 00/5a packets b5fd26618458b725 files b06f83b83abc3241 +26 00 1: 0101 +26 00 2-176: 00 [F] +26 00 packets b5fd26618458b725 files 77dbac27de29b1ca +26 01 1: 0101 +26 01 2-176: 00 [F] +26 01 packets b5fd26618458b725 files 34cec5c4b7293b4e +26 81 1: 0101 +26 81 2-176: 00 [F] +26 81 packets b5fd26618458b725 files 10e015bb537a01f8 +26 ff 1: 0101 +26 ff 2-176: 00 [F] +26 ff packets b5fd26618458b725 files ec5902fa4fea213c +26 00/5a 1: 0101 +26 00/5a 2-176: 00 [F] +26 00/5a packets b5fd26618458b725 files 77dbac27de29b1ca +27 00 1-3: 0101 +27 00 4: 8b00a0a1a2a3a4a5 +27 00 5-35: 0101 +27 00 36-176: 06000078e768bc190000 [Q] +27 00 packets 8c2b2b2703ad3c61 files f195e1bfb415df25 +27 01 1-3: 0101 +27 01 4: 8b00a0a1a2a3a4a5 +27 01 5-35: 0101 +27 01 36-176: 0102 +27 01 packets b5fd26618458b725 files f195e1bfb415df25 +27 81 1-3: 0101 +27 81 4: 8b00a0a1a2a3a4a5 +27 81 5-35: 0101 +27 81 36-176: 0102 +27 81 packets b5fd26618458b725 files f195e1bfb415df25 +27 ff 1-3: 0101 +27 ff 4: 8b00a0a1a2a3a4a5 +27 ff 5-35: 0101 +27 ff 36-176: 0102 +27 ff packets b5fd26618458b725 files f195e1bfb415df25 +27 00/5a 1-3: 0101 +27 00/5a 4: 8b00a0a1a2a3a4a5 +27 00/5a 5-35: 0101 +27 00/5a 36-176: 06000078e768bc190000 [Q] +27 00/5a packets 8c2b2b2703ad3c61 files f195e1bfb415df25 +28 00 1-176: 15 +28 00 packets b5fd26618458b725 files f195e1bfb415df25 +28 01 1-176: 15 +28 01 packets b5fd26618458b725 files f195e1bfb415df25 +28 81 1-176: 15 +28 81 packets b5fd26618458b725 files f195e1bfb415df25 +28 ff 1-176: 15 +28 ff packets b5fd26618458b725 files f195e1bfb415df25 +28 00/5a 1-176: 15 +28 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +29 00 1-3: 0101 +29 00 4-176: 0106 +29 00 packets b5fd26618458b725 files f195e1bfb415df25 +29 01 1-3: 0101 +29 01 4-176: 0106 +29 01 packets b5fd26618458b725 files f195e1bfb415df25 +29 81 1-3: 0101 +29 81 4-176: 0106 +29 81 packets b5fd26618458b725 files f195e1bfb415df25 +29 ff 1-3: 0101 +29 ff 4-176: 0106 +29 ff packets b5fd26618458b725 files f195e1bfb415df25 +29 00/5a 1-3: 0101 +29 00/5a 4-176: 0106 +29 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2a 00 1-33: 0101 +2a 00 34-176: 160000000000 +2a 00 packets b5fd26618458b725 files f195e1bfb415df25 +2a 01 1-33: 0101 +2a 01 34-176: 0102 +2a 01 packets b5fd26618458b725 files f195e1bfb415df25 +2a 81 1-33: 0101 +2a 81 34-176: 0102 +2a 81 packets b5fd26618458b725 files f195e1bfb415df25 +2a ff 1-33: 0101 +2a ff 34-176: 0102 +2a ff packets b5fd26618458b725 files f195e1bfb415df25 +2a 00/5a 1-33: 0101 +2a 00/5a 34-176: 160000000000 +2a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2b 00 1-176: 1700000000e8030000 +2b 00 packets b5fd26618458b725 files f195e1bfb415df25 +2b 01 1-176: 1700000000e8030000 +2b 01 packets b5fd26618458b725 files f195e1bfb415df25 +2b 81 1-176: 1700000000e8030000 +2b 81 packets b5fd26618458b725 files f195e1bfb415df25 +2b ff 1-176: 1700000000e8030000 +2b ff packets b5fd26618458b725 files f195e1bfb415df25 +2b 00/5a 1-176: 1700000000e8030000 +2b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2c 00 1-176: 0101 +2c 00 packets b5fd26618458b725 files f195e1bfb415df25 +2c 01 1-176: 0101 +2c 01 packets b5fd26618458b725 files f195e1bfb415df25 +2c 81 1-176: 0101 +2c 81 packets b5fd26618458b725 files f195e1bfb415df25 +2c ff 1-176: 0101 +2c ff packets b5fd26618458b725 files f195e1bfb415df25 +2c 00/5a 1-176: 0101 +2c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2d 00 1-176: 0101 +2d 00 packets b5fd26618458b725 files f195e1bfb415df25 +2d 01 1-176: 0101 +2d 01 packets b5fd26618458b725 files f195e1bfb415df25 +2d 81 1-176: 0101 +2d 81 packets b5fd26618458b725 files f195e1bfb415df25 +2d ff 1-176: 0101 +2d ff packets b5fd26618458b725 files f195e1bfb415df25 +2d 00/5a 1-176: 0101 +2d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2e 00 1-176: 0101 +2e 00 packets b5fd26618458b725 files f195e1bfb415df25 +2e 01 1-176: 0101 +2e 01 packets b5fd26618458b725 files f195e1bfb415df25 +2e 81 1-176: 0101 +2e 81 packets b5fd26618458b725 files f195e1bfb415df25 +2e ff 1-176: 0101 +2e ff packets b5fd26618458b725 files f195e1bfb415df25 +2e 00/5a 1-176: 0101 +2e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +2f 00 1-176: 0101 +2f 00 packets b5fd26618458b725 files f195e1bfb415df25 +2f 01 1-176: 0101 +2f 01 packets b5fd26618458b725 files f195e1bfb415df25 +2f 81 1-176: 0101 +2f 81 packets b5fd26618458b725 files f195e1bfb415df25 +2f ff 1-176: 0101 +2f ff packets b5fd26618458b725 files f195e1bfb415df25 +2f 00/5a 1-176: 0101 +2f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +30 00 1-176: 0101 +30 00 packets b5fd26618458b725 files f195e1bfb415df25 +30 01 1-176: 0101 +30 01 packets b5fd26618458b725 files f195e1bfb415df25 +30 81 1-176: 0101 +30 81 packets b5fd26618458b725 files f195e1bfb415df25 +30 ff 1-176: 0101 +30 ff packets b5fd26618458b725 files f195e1bfb415df25 +30 00/5a 1-176: 0101 +30 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +31 00 1-176: 0101 +31 00 packets b5fd26618458b725 files f195e1bfb415df25 +31 01 1-176: 0101 +31 01 packets b5fd26618458b725 files f195e1bfb415df25 +31 81 1-176: 0101 +31 81 packets b5fd26618458b725 files f195e1bfb415df25 +31 ff 1-176: 0101 +31 ff packets b5fd26618458b725 files f195e1bfb415df25 +31 00/5a 1-176: 0101 +31 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +32 00 1-33: 0101 +32 00 34-45: 06000078e768bc190000 [Q] +32 00 46-61: 06000078e7689c270000 [Q] +32 00 62-77: 06000078e7687c350000 [Q] +32 00 78-93: 06000078e7685c430000 [Q] +32 00 94-109: 06000078e7683c510000 [Q] +32 00 110-125: 06000078e7681c5f0000 [Q] +32 00 126-141: 06000078e768fc6c0000 [Q] +32 00 142-157: 06000078e768dc7a0000 [Q] +32 00 158-173: 06000078e768bc880000 [Q] +32 00 174-176: 06000078e7689c960000 [Q] +32 00 packets fc24cf834a5057ed files f195e1bfb415df25 +32 01 1-33: 0101 +32 01 34-176: 0102 +32 01 packets b5fd26618458b725 files f195e1bfb415df25 +32 81 1-33: 0101 +32 81 34-176: 0102 +32 81 packets b5fd26618458b725 files f195e1bfb415df25 +32 ff 1-33: 0101 +32 ff 34-176: 0102 +32 ff packets b5fd26618458b725 files f195e1bfb415df25 +32 00/5a 1-33: 0101 +32 00/5a 34-45: 06000078e768bc190000 [Q] +32 00/5a 46-61: 06000078e7689c270000 [Q] +32 00/5a 62-77: 06000078e7687c350000 [Q] +32 00/5a 78-93: 06000078e7685c430000 [Q] +32 00/5a 94-109: 06000078e7683c510000 [Q] +32 00/5a 110-125: 06000078e7681c5f0000 [Q] +32 00/5a 126-141: 06000078e768fc6c0000 [Q] +32 00/5a 142-157: 06000078e768dc7a0000 [Q] +32 00/5a 158-173: 06000078e768bc880000 [Q] +32 00/5a 174-176: 06000078e7689c960000 [Q] +32 00/5a packets fc24cf834a5057ed files f195e1bfb415df25 +33 00 1-176: 0101 +33 00 packets b5fd26618458b725 files f195e1bfb415df25 +33 01 1-176: 0101 +33 01 packets b5fd26618458b725 files f195e1bfb415df25 +33 81 1-176: 0101 +33 81 packets b5fd26618458b725 files f195e1bfb415df25 +33 ff 1-176: 0101 +33 ff packets b5fd26618458b725 files f195e1bfb415df25 +33 00/5a 1-176: 0101 +33 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +34 00 1-33: 0101 +34 00 34-176: 06010078e768c43e0000 [Q] +34 00 packets 6a3e13be22963d9a files f195e1bfb415df25 +34 01 1-176: 0101 +34 01 packets b5fd26618458b725 files f195e1bfb415df25 +34 81 1-176: 0101 +34 81 packets b5fd26618458b725 files f195e1bfb415df25 +34 ff 1-176: 0101 +34 ff packets b5fd26618458b725 files f195e1bfb415df25 +34 00/5a 1-33: 0101 +34 00/5a 34-176: 06010078e768c43e0000 [Q] +34 00/5a packets 6a3e13be22963d9a files f195e1bfb415df25 +35 00 1-176: 0101 +35 00 packets b5fd26618458b725 files f195e1bfb415df25 +35 01 1-176: 0101 +35 01 packets b5fd26618458b725 files f195e1bfb415df25 +35 81 1-176: 0101 +35 81 packets b5fd26618458b725 files f195e1bfb415df25 +35 ff 1-176: 0101 +35 ff packets b5fd26618458b725 files f195e1bfb415df25 +35 00/5a 1-176: 0101 +35 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +36 00 1: 0101 +36 00 2-176: 00 +36 00 packets b5fd26618458b725 files f195e1bfb415df25 +36 01 1: 0101 +36 01 2-176: 00 +36 01 packets b5fd26618458b725 files f195e1bfb415df25 +36 81 1-176: 0101 +36 81 packets b5fd26618458b725 files f195e1bfb415df25 +36 ff 1-176: 0101 +36 ff packets b5fd26618458b725 files f195e1bfb415df25 +36 00/5a 1: 0101 +36 00/5a 2-176: 00 +36 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +37 00 1-176: 0101 +37 00 packets b5fd26618458b725 files f195e1bfb415df25 +37 01 1-176: 0101 +37 01 packets b5fd26618458b725 files f195e1bfb415df25 +37 81 1: 0101 +37 81 2-176: 00 [Q] +37 81 packets 815d342b1e6f51eb files f195e1bfb415df25 +37 ff 1: 0101 +37 ff 2-176: 00 [Q] +37 ff packets 212d91109c8dd2db files f195e1bfb415df25 +37 00/5a 1-176: 0101 +37 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +38 00 1: 0101 +38 00 2-176: 1800041001000000000000 +38 00 packets b5fd26618458b725 files f195e1bfb415df25 +38 01 1: 0101 +38 01 2-176: 18010000a6140000000000000000 +38 01 packets b5fd26618458b725 files f195e1bfb415df25 +38 81 1: 0101 +38 81 2-176: 0106 +38 81 packets b5fd26618458b725 files f195e1bfb415df25 +38 ff 1: 0101 +38 ff 2-176: 0106 +38 ff packets b5fd26618458b725 files f195e1bfb415df25 +38 00/5a 1: 0101 +38 00/5a 2-176: 1800041001000000000000 +38 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +39 00 1-33: 0101 +39 00 34-45: 06000078e7689e340000 [Q] +39 00 46-61: 06000078e7687e420000 [Q] +39 00 62-77: 06000078e7685e500000 [Q] +39 00 78-93: 06000078e7683e5e0000 [Q] +39 00 94-109: 06000078e7681e6c0000 [Q] +39 00 110-125: 06000078e768fe790000 [Q] +39 00 126-141: 06000078e768de870000 [Q] +39 00 142-157: 06000078e768be950000 [Q] +39 00 158-165: 06000078e7689ea30000 [Q] +39 00 166-176: 0103 +39 00 packets e878a09a4c9f36e5 files f195e1bfb415df25 +39 01 1-33: 0101 +39 01 34-45: 06000078e7689e340000 [Q] +39 01 46-61: 06000078e7687e420000 [Q] +39 01 62-77: 06000078e7685e500000 [Q] +39 01 78-93: 06000078e7683e5e0000 [Q] +39 01 94-109: 06000078e7681e6c0000 [Q] +39 01 110-125: 06000078e768fe790000 [Q] +39 01 126-141: 06000078e768de870000 [Q] +39 01 142-157: 06000078e768be950000 [Q] +39 01 158-165: 06000078e7689ea30000 [Q] +39 01 166-176: 0103 +39 01 packets a3397a1b28a6c44d files f195e1bfb415df25 +39 81 1-33: 0101 +39 81 34-45: 06000078e7689e340000 [Q] +39 81 46-61: 06000078e7687e420000 [Q] +39 81 62-77: 06000078e7685e500000 [Q] +39 81 78-93: 06000078e7683e5e0000 [Q] +39 81 94-109: 06000078e7681e6c0000 [Q] +39 81 110-125: 06000078e768fe790000 [Q] +39 81 126-141: 06000078e768de870000 [Q] +39 81 142-157: 06000078e768be950000 [Q] +39 81 158-165: 06000078e7689ea30000 [Q] +39 81 166-176: 0103 +39 81 packets 5238752c6ba4ca4d files f195e1bfb415df25 +39 ff 1-33: 0101 +39 ff 34-45: 06000078e7689e340000 [Q] +39 ff 46-61: 06000078e7687e420000 [Q] +39 ff 62-77: 06000078e7685e500000 [Q] +39 ff 78-93: 06000078e7683e5e0000 [Q] +39 ff 94-109: 06000078e7681e6c0000 [Q] +39 ff 110-125: 06000078e768fe790000 [Q] +39 ff 126-141: 06000078e768de870000 [Q] +39 ff 142-157: 06000078e768be950000 [Q] +39 ff 158-165: 06000078e7689ea30000 [Q] +39 ff 166-176: 0103 +39 ff packets 381a33b5b15bc49d files f195e1bfb415df25 +39 00/5a 1-33: 0101 +39 00/5a 34-45: 06000078e7689e340000 [Q] +39 00/5a 46-61: 06000078e7687e420000 [Q] +39 00/5a 62-77: 06000078e7685e500000 [Q] +39 00/5a 78-93: 06000078e7683e5e0000 [Q] +39 00/5a 94-109: 06000078e7681e6c0000 [Q] +39 00/5a 110-125: 06000078e768fe790000 [Q] +39 00/5a 126-141: 06000078e768de870000 [Q] +39 00/5a 142-157: 06000078e768be950000 [Q] +39 00/5a 158-165: 06000078e7689ea30000 [Q] +39 00/5a 166-176: 0103 +39 00/5a packets e878a09a4c9f36e5 files f195e1bfb415df25 +3a 00 1: 0101 +3a 00 2-176: 00 [F] +3a 00 packets b5fd26618458b725 files 77dbac27de29b1ca +3a 01 1: 0101 +3a 01 2-176: 00 [F] +3a 01 packets b5fd26618458b725 files b298525f87cb9bb3 +3a 81 1: 0101 +3a 81 2-176: 00 [F] +3a 81 packets b5fd26618458b725 files 4a341421d4fedde6 +3a ff 1: 0101 +3a ff 2-176: 00 [F] +3a ff packets b5fd26618458b725 files 4858dd4e662df412 +3a 00/5a 1: 0101 +3a 00/5a 2-176: 00 [F] +3a 00/5a packets b5fd26618458b725 files 77dbac27de29b1ca +3b 00 1-176: 190000 +3b 00 packets b5fd26618458b725 files f195e1bfb415df25 +3b 01 1-176: 190000 +3b 01 packets b5fd26618458b725 files f195e1bfb415df25 +3b 81 1-176: 190000 +3b 81 packets b5fd26618458b725 files f195e1bfb415df25 +3b ff 1-176: 190000 +3b ff packets b5fd26618458b725 files f195e1bfb415df25 +3b 00/5a 1-176: 190000 +3b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +3c 00 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c 00 packets b5fd26618458b725 files f195e1bfb415df25 +3c 01 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c 01 packets b5fd26618458b725 files f195e1bfb415df25 +3c 81 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c 81 packets b5fd26618458b725 files f195e1bfb415df25 +3c ff 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c ff packets b5fd26618458b725 files f195e1bfb415df25 +3c 00/5a 1-176: 1a689b0600689b060077440d0077440d00f0010e00f0010e00 +3c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +3d 00 1-2: 0101 +3d 00 3-176: 00 [F] +3d 00 packets b5fd26618458b725 files 60613ecd903e87d3 +3d 01 1-176: 0101 +3d 01 packets b5fd26618458b725 files f195e1bfb415df25 +3d 81 1-176: 0101 +3d 81 packets b5fd26618458b725 files f195e1bfb415df25 +3d ff 1-176: 0101 +3d ff packets b5fd26618458b725 files f195e1bfb415df25 +3d 00/5a 1-2: 0101 +3d 00/5a 3-176: 00 [F] +3d 00/5a packets b5fd26618458b725 files 60613ecd903e87d3 +3e 00 1-176: 0106 +3e 00 packets b5fd26618458b725 files f195e1bfb415df25 +3e 01 1-5: 0106 +3e 01 6-171: 00 [Q] +3e 01 172-176: 0106 +3e 01 packets 34db05b6aed41ba9 files f195e1bfb415df25 +3e 81 1-7: 0106 +3e 81 8-176: 0102 +3e 81 packets b5fd26618458b725 files f195e1bfb415df25 +3e ff 1-4: 0106 +3e ff 5-176: 0102 +3e ff packets b5fd26618458b725 files f195e1bfb415df25 +3e 00/5a 1-176: 0106 +3e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +3f 00 1-47: 00 [F] +3f 00 48-176: 0106 +3f 00 packets b5fd26618458b725 files d6801517bf6cf4f6 +3f 01 1-47: 00 [F] +3f 01 48-176: 0106 +3f 01 packets b5fd26618458b725 files d6801517bf6cf4f6 +3f 81 1-47: 00 [F] +3f 81 48-176: 0106 +3f 81 packets b5fd26618458b725 files d6801517bf6cf4f6 +3f ff 1-47: 00 [F] +3f ff 48-176: 0106 +3f ff packets b5fd26618458b725 files d6801517bf6cf4f6 +3f 00/5a 1-47: 00 [F] +3f 00/5a 48-176: 0106 +3f 00/5a packets b5fd26618458b725 files d6801517bf6cf4f6 +40 00 1-176: 1c +40 00 packets b5fd26618458b725 files f195e1bfb415df25 +40 01 1-176: 1c +40 01 packets b5fd26618458b725 files f195e1bfb415df25 +40 81 1-176: 1c +40 81 packets b5fd26618458b725 files f195e1bfb415df25 +40 ff 1-176: 1c +40 ff packets b5fd26618458b725 files f195e1bfb415df25 +40 00/5a 1-176: 1c +40 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +41 00 1-3: 0101 +41 00 4-7: 0106 +41 00 8-176: 00 [Q] +41 00 packets 508d0b6c5fa8e95d files f195e1bfb415df25 +41 01 1-3: 0101 +41 01 4: 0106 +41 01 5-176: 00 [Q] +41 01 packets 1d6d5a7c3341c9cf files f195e1bfb415df25 +41 81 1-3: 0101 +41 81 4-176: 0106 +41 81 packets b5fd26618458b725 files f195e1bfb415df25 +41 ff 1-3: 0101 +41 ff 4-176: 0106 +41 ff packets b5fd26618458b725 files f195e1bfb415df25 +41 00/5a 1-3: 0101 +41 00/5a 4-7: 0106 +41 00/5a 8-176: 00 [Q] +41 00/5a packets 508d0b6c5fa8e95d files f195e1bfb415df25 +42 00 1-2: 0101 +42 00 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 00 packets b5fd26618458b725 files f195e1bfb415df25 +42 01 1-2: 0101 +42 01 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 01 packets b5fd26618458b725 files f195e1bfb415df25 +42 81 1-2: 0101 +42 81 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 81 packets b5fd26618458b725 files f195e1bfb415df25 +42 ff 1-2: 0101 +42 ff 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 ff packets b5fd26618458b725 files f195e1bfb415df25 +42 00/5a 1-2: 0101 +42 00/5a 3-176: 1d556e6b6e6f776e20636f6d6d616e64 +42 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +43 00 1-176: 0101 +43 00 packets b5fd26618458b725 files f195e1bfb415df25 +43 01 1-176: 0101 +43 01 packets b5fd26618458b725 files f195e1bfb415df25 +43 81 1-176: 0101 +43 81 packets b5fd26618458b725 files f195e1bfb415df25 +43 ff 1-176: 0101 +43 ff packets b5fd26618458b725 files f195e1bfb415df25 +43 00/5a 1-176: 0101 +43 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +44 00 1-176: 0101 +44 00 packets b5fd26618458b725 files f195e1bfb415df25 +44 01 1-176: 0101 +44 01 packets b5fd26618458b725 files f195e1bfb415df25 +44 81 1-176: 0101 +44 81 packets b5fd26618458b725 files f195e1bfb415df25 +44 ff 1-176: 0101 +44 ff packets b5fd26618458b725 files f195e1bfb415df25 +44 00/5a 1-176: 0101 +44 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +45 00 1-176: 0101 +45 00 packets b5fd26618458b725 files f195e1bfb415df25 +45 01 1-176: 0101 +45 01 packets b5fd26618458b725 files f195e1bfb415df25 +45 81 1-176: 0101 +45 81 packets b5fd26618458b725 files f195e1bfb415df25 +45 ff 1-176: 0101 +45 ff packets b5fd26618458b725 files f195e1bfb415df25 +45 00/5a 1-176: 0101 +45 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +46 00 1-176: 0101 +46 00 packets b5fd26618458b725 files f195e1bfb415df25 +46 01 1-176: 0101 +46 01 packets b5fd26618458b725 files f195e1bfb415df25 +46 81 1-176: 0101 +46 81 packets b5fd26618458b725 files f195e1bfb415df25 +46 ff 1-176: 0101 +46 ff packets b5fd26618458b725 files f195e1bfb415df25 +46 00/5a 1-176: 0101 +46 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +47 00 1-176: 0101 +47 00 packets b5fd26618458b725 files f195e1bfb415df25 +47 01 1-176: 0101 +47 01 packets b5fd26618458b725 files f195e1bfb415df25 +47 81 1-176: 0101 +47 81 packets b5fd26618458b725 files f195e1bfb415df25 +47 ff 1-176: 0101 +47 ff packets b5fd26618458b725 files f195e1bfb415df25 +47 00/5a 1-176: 0101 +47 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +48 00 1-176: 0101 +48 00 packets b5fd26618458b725 files f195e1bfb415df25 +48 01 1-176: 0101 +48 01 packets b5fd26618458b725 files f195e1bfb415df25 +48 81 1-176: 0101 +48 81 packets b5fd26618458b725 files f195e1bfb415df25 +48 ff 1-176: 0101 +48 ff packets b5fd26618458b725 files f195e1bfb415df25 +48 00/5a 1-176: 0101 +48 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +49 00 1-176: 0101 +49 00 packets b5fd26618458b725 files f195e1bfb415df25 +49 01 1-176: 0101 +49 01 packets b5fd26618458b725 files f195e1bfb415df25 +49 81 1-176: 0101 +49 81 packets b5fd26618458b725 files f195e1bfb415df25 +49 ff 1-176: 0101 +49 ff packets b5fd26618458b725 files f195e1bfb415df25 +49 00/5a 1-176: 0101 +49 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4a 00 1-176: 0101 +4a 00 packets b5fd26618458b725 files f195e1bfb415df25 +4a 01 1-176: 0101 +4a 01 packets b5fd26618458b725 files f195e1bfb415df25 +4a 81 1-176: 0101 +4a 81 packets b5fd26618458b725 files f195e1bfb415df25 +4a ff 1-176: 0101 +4a ff packets b5fd26618458b725 files f195e1bfb415df25 +4a 00/5a 1-176: 0101 +4a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4b 00 1-176: 0101 +4b 00 packets b5fd26618458b725 files f195e1bfb415df25 +4b 01 1-176: 0101 +4b 01 packets b5fd26618458b725 files f195e1bfb415df25 +4b 81 1-176: 0101 +4b 81 packets b5fd26618458b725 files f195e1bfb415df25 +4b ff 1-176: 0101 +4b ff packets b5fd26618458b725 files f195e1bfb415df25 +4b 00/5a 1-176: 0101 +4b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4c 00 1-176: 0101 +4c 00 packets b5fd26618458b725 files f195e1bfb415df25 +4c 01 1-176: 0101 +4c 01 packets b5fd26618458b725 files f195e1bfb415df25 +4c 81 1-176: 0101 +4c 81 packets b5fd26618458b725 files f195e1bfb415df25 +4c ff 1-176: 0101 +4c ff packets b5fd26618458b725 files f195e1bfb415df25 +4c 00/5a 1-176: 0101 +4c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4d 00 1-176: 0101 +4d 00 packets b5fd26618458b725 files f195e1bfb415df25 +4d 01 1-176: 0101 +4d 01 packets b5fd26618458b725 files f195e1bfb415df25 +4d 81 1-176: 0101 +4d 81 packets b5fd26618458b725 files f195e1bfb415df25 +4d ff 1-176: 0101 +4d ff packets b5fd26618458b725 files f195e1bfb415df25 +4d 00/5a 1-176: 0101 +4d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4e 00 1-176: 0101 +4e 00 packets b5fd26618458b725 files f195e1bfb415df25 +4e 01 1-176: 0101 +4e 01 packets b5fd26618458b725 files f195e1bfb415df25 +4e 81 1-176: 0101 +4e 81 packets b5fd26618458b725 files f195e1bfb415df25 +4e ff 1-176: 0101 +4e ff packets b5fd26618458b725 files f195e1bfb415df25 +4e 00/5a 1-176: 0101 +4e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +4f 00 1-176: 0101 +4f 00 packets b5fd26618458b725 files f195e1bfb415df25 +4f 01 1-176: 0101 +4f 01 packets b5fd26618458b725 files f195e1bfb415df25 +4f 81 1-176: 0101 +4f 81 packets b5fd26618458b725 files f195e1bfb415df25 +4f ff 1-176: 0101 +4f ff packets b5fd26618458b725 files f195e1bfb415df25 +4f 00/5a 1-176: 0101 +4f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +50 00 1-176: 0101 +50 00 packets b5fd26618458b725 files f195e1bfb415df25 +50 01 1-176: 0101 +50 01 packets b5fd26618458b725 files f195e1bfb415df25 +50 81 1-176: 0101 +50 81 packets b5fd26618458b725 files f195e1bfb415df25 +50 ff 1-176: 0101 +50 ff packets b5fd26618458b725 files f195e1bfb415df25 +50 00/5a 1-176: 0101 +50 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +51 00 1-176: 0101 +51 00 packets b5fd26618458b725 files f195e1bfb415df25 +51 01 1-176: 0101 +51 01 packets b5fd26618458b725 files f195e1bfb415df25 +51 81 1-176: 0101 +51 81 packets b5fd26618458b725 files f195e1bfb415df25 +51 ff 1-176: 0101 +51 ff packets b5fd26618458b725 files f195e1bfb415df25 +51 00/5a 1-176: 0101 +51 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +52 00 1-176: 0101 +52 00 packets b5fd26618458b725 files f195e1bfb415df25 +52 01 1-176: 0101 +52 01 packets b5fd26618458b725 files f195e1bfb415df25 +52 81 1-176: 0101 +52 81 packets b5fd26618458b725 files f195e1bfb415df25 +52 ff 1-176: 0101 +52 ff packets b5fd26618458b725 files f195e1bfb415df25 +52 00/5a 1-176: 0101 +52 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +53 00 1-176: 0101 +53 00 packets b5fd26618458b725 files f195e1bfb415df25 +53 01 1-176: 0101 +53 01 packets b5fd26618458b725 files f195e1bfb415df25 +53 81 1-176: 0101 +53 81 packets b5fd26618458b725 files f195e1bfb415df25 +53 ff 1-176: 0101 +53 ff packets b5fd26618458b725 files f195e1bfb415df25 +53 00/5a 1-176: 0101 +53 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +54 00 1-176: 0101 +54 00 packets b5fd26618458b725 files f195e1bfb415df25 +54 01 1-176: 0101 +54 01 packets b5fd26618458b725 files f195e1bfb415df25 +54 81 1-176: 0101 +54 81 packets b5fd26618458b725 files f195e1bfb415df25 +54 ff 1-176: 0101 +54 ff packets b5fd26618458b725 files f195e1bfb415df25 +54 00/5a 1-176: 0101 +54 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +55 00 1-176: 0101 +55 00 packets b5fd26618458b725 files f195e1bfb415df25 +55 01 1-176: 0101 +55 01 packets b5fd26618458b725 files f195e1bfb415df25 +55 81 1-176: 0101 +55 81 packets b5fd26618458b725 files f195e1bfb415df25 +55 ff 1-176: 0101 +55 ff packets b5fd26618458b725 files f195e1bfb415df25 +55 00/5a 1-176: 0101 +55 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +56 00 1-176: 0101 +56 00 packets b5fd26618458b725 files f195e1bfb415df25 +56 01 1-176: 0101 +56 01 packets b5fd26618458b725 files f195e1bfb415df25 +56 81 1-176: 0101 +56 81 packets b5fd26618458b725 files f195e1bfb415df25 +56 ff 1-176: 0101 +56 ff packets b5fd26618458b725 files f195e1bfb415df25 +56 00/5a 1-176: 0101 +56 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +57 00 1-176: 0101 +57 00 packets b5fd26618458b725 files f195e1bfb415df25 +57 01 1-176: 0101 +57 01 packets b5fd26618458b725 files f195e1bfb415df25 +57 81 1-176: 0101 +57 81 packets b5fd26618458b725 files f195e1bfb415df25 +57 ff 1-176: 0101 +57 ff packets b5fd26618458b725 files f195e1bfb415df25 +57 00/5a 1-176: 0101 +57 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +58 00 1-176: 0101 +58 00 packets b5fd26618458b725 files f195e1bfb415df25 +58 01 1-176: 0101 +58 01 packets b5fd26618458b725 files f195e1bfb415df25 +58 81 1-176: 0101 +58 81 packets b5fd26618458b725 files f195e1bfb415df25 +58 ff 1-176: 0101 +58 ff packets b5fd26618458b725 files f195e1bfb415df25 +58 00/5a 1-176: 0101 +58 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +59 00 1-176: 0101 +59 00 packets b5fd26618458b725 files f195e1bfb415df25 +59 01 1-176: 0101 +59 01 packets b5fd26618458b725 files f195e1bfb415df25 +59 81 1-176: 0101 +59 81 packets b5fd26618458b725 files f195e1bfb415df25 +59 ff 1-176: 0101 +59 ff packets b5fd26618458b725 files f195e1bfb415df25 +59 00/5a 1-176: 0101 +59 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5a 00 1-176: 0101 +5a 00 packets b5fd26618458b725 files f195e1bfb415df25 +5a 01 1-176: 0101 +5a 01 packets b5fd26618458b725 files f195e1bfb415df25 +5a 81 1-176: 0101 +5a 81 packets b5fd26618458b725 files f195e1bfb415df25 +5a ff 1-176: 0101 +5a ff packets b5fd26618458b725 files f195e1bfb415df25 +5a 00/5a 1-176: 0101 +5a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5b 00 1-176: 0101 +5b 00 packets b5fd26618458b725 files f195e1bfb415df25 +5b 01 1-176: 0101 +5b 01 packets b5fd26618458b725 files f195e1bfb415df25 +5b 81 1-176: 0101 +5b 81 packets b5fd26618458b725 files f195e1bfb415df25 +5b ff 1-176: 0101 +5b ff packets b5fd26618458b725 files f195e1bfb415df25 +5b 00/5a 1-176: 0101 +5b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5c 00 1-176: 0101 +5c 00 packets b5fd26618458b725 files f195e1bfb415df25 +5c 01 1-176: 0101 +5c 01 packets b5fd26618458b725 files f195e1bfb415df25 +5c 81 1-176: 0101 +5c 81 packets b5fd26618458b725 files f195e1bfb415df25 +5c ff 1-176: 0101 +5c ff packets b5fd26618458b725 files f195e1bfb415df25 +5c 00/5a 1-176: 0101 +5c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5d 00 1-176: 0101 +5d 00 packets b5fd26618458b725 files f195e1bfb415df25 +5d 01 1-176: 0101 +5d 01 packets b5fd26618458b725 files f195e1bfb415df25 +5d 81 1-176: 0101 +5d 81 packets b5fd26618458b725 files f195e1bfb415df25 +5d ff 1-176: 0101 +5d ff packets b5fd26618458b725 files f195e1bfb415df25 +5d 00/5a 1-176: 0101 +5d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5e 00 1-176: 0101 +5e 00 packets b5fd26618458b725 files f195e1bfb415df25 +5e 01 1-176: 0101 +5e 01 packets b5fd26618458b725 files f195e1bfb415df25 +5e 81 1-176: 0101 +5e 81 packets b5fd26618458b725 files f195e1bfb415df25 +5e ff 1-176: 0101 +5e ff packets b5fd26618458b725 files f195e1bfb415df25 +5e 00/5a 1-176: 0101 +5e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +5f 00 1-176: 0101 +5f 00 packets b5fd26618458b725 files f195e1bfb415df25 +5f 01 1-176: 0101 +5f 01 packets b5fd26618458b725 files f195e1bfb415df25 +5f 81 1-176: 0101 +5f 81 packets b5fd26618458b725 files f195e1bfb415df25 +5f ff 1-176: 0101 +5f ff packets b5fd26618458b725 files f195e1bfb415df25 +5f 00/5a 1-176: 0101 +5f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +60 00 1-176: 0101 +60 00 packets b5fd26618458b725 files f195e1bfb415df25 +60 01 1-176: 0101 +60 01 packets b5fd26618458b725 files f195e1bfb415df25 +60 81 1-176: 0101 +60 81 packets b5fd26618458b725 files f195e1bfb415df25 +60 ff 1-176: 0101 +60 ff packets b5fd26618458b725 files f195e1bfb415df25 +60 00/5a 1-176: 0101 +60 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +61 00 1-176: 0101 +61 00 packets b5fd26618458b725 files f195e1bfb415df25 +61 01 1-176: 0101 +61 01 packets b5fd26618458b725 files f195e1bfb415df25 +61 81 1-176: 0101 +61 81 packets b5fd26618458b725 files f195e1bfb415df25 +61 ff 1-176: 0101 +61 ff packets b5fd26618458b725 files f195e1bfb415df25 +61 00/5a 1-176: 0101 +61 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +62 00 1-176: 0101 +62 00 packets b5fd26618458b725 files f195e1bfb415df25 +62 01 1-176: 0101 +62 01 packets b5fd26618458b725 files f195e1bfb415df25 +62 81 1-176: 0101 +62 81 packets b5fd26618458b725 files f195e1bfb415df25 +62 ff 1-176: 0101 +62 ff packets b5fd26618458b725 files f195e1bfb415df25 +62 00/5a 1-176: 0101 +62 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +63 00 1-176: 0101 +63 00 packets b5fd26618458b725 files f195e1bfb415df25 +63 01 1-176: 0101 +63 01 packets b5fd26618458b725 files f195e1bfb415df25 +63 81 1-176: 0101 +63 81 packets b5fd26618458b725 files f195e1bfb415df25 +63 ff 1-176: 0101 +63 ff packets b5fd26618458b725 files f195e1bfb415df25 +63 00/5a 1-176: 0101 +63 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +64 00 1-176: 0101 +64 00 packets b5fd26618458b725 files f195e1bfb415df25 +64 01 1-176: 0101 +64 01 packets b5fd26618458b725 files f195e1bfb415df25 +64 81 1-176: 0101 +64 81 packets b5fd26618458b725 files f195e1bfb415df25 +64 ff 1-176: 0101 +64 ff packets b5fd26618458b725 files f195e1bfb415df25 +64 00/5a 1-176: 0101 +64 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +65 00 1-176: 0101 +65 00 packets b5fd26618458b725 files f195e1bfb415df25 +65 01 1-176: 0101 +65 01 packets b5fd26618458b725 files f195e1bfb415df25 +65 81 1-176: 0101 +65 81 packets b5fd26618458b725 files f195e1bfb415df25 +65 ff 1-176: 0101 +65 ff packets b5fd26618458b725 files f195e1bfb415df25 +65 00/5a 1-176: 0101 +65 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +66 00 1-176: 0101 +66 00 packets b5fd26618458b725 files f195e1bfb415df25 +66 01 1-176: 0101 +66 01 packets b5fd26618458b725 files f195e1bfb415df25 +66 81 1-176: 0101 +66 81 packets b5fd26618458b725 files f195e1bfb415df25 +66 ff 1-176: 0101 +66 ff packets b5fd26618458b725 files f195e1bfb415df25 +66 00/5a 1-176: 0101 +66 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +67 00 1-176: 0101 +67 00 packets b5fd26618458b725 files f195e1bfb415df25 +67 01 1-176: 0101 +67 01 packets b5fd26618458b725 files f195e1bfb415df25 +67 81 1-176: 0101 +67 81 packets b5fd26618458b725 files f195e1bfb415df25 +67 ff 1-176: 0101 +67 ff packets b5fd26618458b725 files f195e1bfb415df25 +67 00/5a 1-176: 0101 +67 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +68 00 1-176: 0101 +68 00 packets b5fd26618458b725 files f195e1bfb415df25 +68 01 1-176: 0101 +68 01 packets b5fd26618458b725 files f195e1bfb415df25 +68 81 1-176: 0101 +68 81 packets b5fd26618458b725 files f195e1bfb415df25 +68 ff 1-176: 0101 +68 ff packets b5fd26618458b725 files f195e1bfb415df25 +68 00/5a 1-176: 0101 +68 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +69 00 1-176: 0101 +69 00 packets b5fd26618458b725 files f195e1bfb415df25 +69 01 1-176: 0101 +69 01 packets b5fd26618458b725 files f195e1bfb415df25 +69 81 1-176: 0101 +69 81 packets b5fd26618458b725 files f195e1bfb415df25 +69 ff 1-176: 0101 +69 ff packets b5fd26618458b725 files f195e1bfb415df25 +69 00/5a 1-176: 0101 +69 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6a 00 1-176: 0101 +6a 00 packets b5fd26618458b725 files f195e1bfb415df25 +6a 01 1-176: 0101 +6a 01 packets b5fd26618458b725 files f195e1bfb415df25 +6a 81 1-176: 0101 +6a 81 packets b5fd26618458b725 files f195e1bfb415df25 +6a ff 1-176: 0101 +6a ff packets b5fd26618458b725 files f195e1bfb415df25 +6a 00/5a 1-176: 0101 +6a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6b 00 1-176: 0101 +6b 00 packets b5fd26618458b725 files f195e1bfb415df25 +6b 01 1-176: 0101 +6b 01 packets b5fd26618458b725 files f195e1bfb415df25 +6b 81 1-176: 0101 +6b 81 packets b5fd26618458b725 files f195e1bfb415df25 +6b ff 1-176: 0101 +6b ff packets b5fd26618458b725 files f195e1bfb415df25 +6b 00/5a 1-176: 0101 +6b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6c 00 1-176: 0101 +6c 00 packets b5fd26618458b725 files f195e1bfb415df25 +6c 01 1-176: 0101 +6c 01 packets b5fd26618458b725 files f195e1bfb415df25 +6c 81 1-176: 0101 +6c 81 packets b5fd26618458b725 files f195e1bfb415df25 +6c ff 1-176: 0101 +6c ff packets b5fd26618458b725 files f195e1bfb415df25 +6c 00/5a 1-176: 0101 +6c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6d 00 1-176: 0101 +6d 00 packets b5fd26618458b725 files f195e1bfb415df25 +6d 01 1-176: 0101 +6d 01 packets b5fd26618458b725 files f195e1bfb415df25 +6d 81 1-176: 0101 +6d 81 packets b5fd26618458b725 files f195e1bfb415df25 +6d ff 1-176: 0101 +6d ff packets b5fd26618458b725 files f195e1bfb415df25 +6d 00/5a 1-176: 0101 +6d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6e 00 1-176: 0101 +6e 00 packets b5fd26618458b725 files f195e1bfb415df25 +6e 01 1-176: 0101 +6e 01 packets b5fd26618458b725 files f195e1bfb415df25 +6e 81 1-176: 0101 +6e 81 packets b5fd26618458b725 files f195e1bfb415df25 +6e ff 1-176: 0101 +6e ff packets b5fd26618458b725 files f195e1bfb415df25 +6e 00/5a 1-176: 0101 +6e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +6f 00 1-176: 0101 +6f 00 packets b5fd26618458b725 files f195e1bfb415df25 +6f 01 1-176: 0101 +6f 01 packets b5fd26618458b725 files f195e1bfb415df25 +6f 81 1-176: 0101 +6f 81 packets b5fd26618458b725 files f195e1bfb415df25 +6f ff 1-176: 0101 +6f ff packets b5fd26618458b725 files f195e1bfb415df25 +6f 00/5a 1-176: 0101 +6f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +70 00 1-176: 0101 +70 00 packets b5fd26618458b725 files f195e1bfb415df25 +70 01 1-176: 0101 +70 01 packets b5fd26618458b725 files f195e1bfb415df25 +70 81 1-176: 0101 +70 81 packets b5fd26618458b725 files f195e1bfb415df25 +70 ff 1-176: 0101 +70 ff packets b5fd26618458b725 files f195e1bfb415df25 +70 00/5a 1-176: 0101 +70 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +71 00 1-176: 0101 +71 00 packets b5fd26618458b725 files f195e1bfb415df25 +71 01 1-176: 0101 +71 01 packets b5fd26618458b725 files f195e1bfb415df25 +71 81 1-176: 0101 +71 81 packets b5fd26618458b725 files f195e1bfb415df25 +71 ff 1-176: 0101 +71 ff packets b5fd26618458b725 files f195e1bfb415df25 +71 00/5a 1-176: 0101 +71 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +72 00 1-176: 0101 +72 00 packets b5fd26618458b725 files f195e1bfb415df25 +72 01 1-176: 0101 +72 01 packets b5fd26618458b725 files f195e1bfb415df25 +72 81 1-176: 0101 +72 81 packets b5fd26618458b725 files f195e1bfb415df25 +72 ff 1-176: 0101 +72 ff packets b5fd26618458b725 files f195e1bfb415df25 +72 00/5a 1-176: 0101 +72 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +73 00 1-176: 0101 +73 00 packets b5fd26618458b725 files f195e1bfb415df25 +73 01 1-176: 0101 +73 01 packets b5fd26618458b725 files f195e1bfb415df25 +73 81 1-176: 0101 +73 81 packets b5fd26618458b725 files f195e1bfb415df25 +73 ff 1-176: 0101 +73 ff packets b5fd26618458b725 files f195e1bfb415df25 +73 00/5a 1-176: 0101 +73 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +74 00 1-176: 0101 +74 00 packets b5fd26618458b725 files f195e1bfb415df25 +74 01 1-176: 0101 +74 01 packets b5fd26618458b725 files f195e1bfb415df25 +74 81 1-176: 0101 +74 81 packets b5fd26618458b725 files f195e1bfb415df25 +74 ff 1-176: 0101 +74 ff packets b5fd26618458b725 files f195e1bfb415df25 +74 00/5a 1-176: 0101 +74 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +75 00 1-176: 0101 +75 00 packets b5fd26618458b725 files f195e1bfb415df25 +75 01 1-176: 0101 +75 01 packets b5fd26618458b725 files f195e1bfb415df25 +75 81 1-176: 0101 +75 81 packets b5fd26618458b725 files f195e1bfb415df25 +75 ff 1-176: 0101 +75 ff packets b5fd26618458b725 files f195e1bfb415df25 +75 00/5a 1-176: 0101 +75 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +76 00 1-176: 0101 +76 00 packets b5fd26618458b725 files f195e1bfb415df25 +76 01 1-176: 0101 +76 01 packets b5fd26618458b725 files f195e1bfb415df25 +76 81 1-176: 0101 +76 81 packets b5fd26618458b725 files f195e1bfb415df25 +76 ff 1-176: 0101 +76 ff packets b5fd26618458b725 files f195e1bfb415df25 +76 00/5a 1-176: 0101 +76 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +77 00 1-176: 0101 +77 00 packets b5fd26618458b725 files f195e1bfb415df25 +77 01 1-176: 0101 +77 01 packets b5fd26618458b725 files f195e1bfb415df25 +77 81 1-176: 0101 +77 81 packets b5fd26618458b725 files f195e1bfb415df25 +77 ff 1-176: 0101 +77 ff packets b5fd26618458b725 files f195e1bfb415df25 +77 00/5a 1-176: 0101 +77 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +78 00 1-176: 0101 +78 00 packets b5fd26618458b725 files f195e1bfb415df25 +78 01 1-176: 0101 +78 01 packets b5fd26618458b725 files f195e1bfb415df25 +78 81 1-176: 0101 +78 81 packets b5fd26618458b725 files f195e1bfb415df25 +78 ff 1-176: 0101 +78 ff packets b5fd26618458b725 files f195e1bfb415df25 +78 00/5a 1-176: 0101 +78 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +79 00 1-176: 0101 +79 00 packets b5fd26618458b725 files f195e1bfb415df25 +79 01 1-176: 0101 +79 01 packets b5fd26618458b725 files f195e1bfb415df25 +79 81 1-176: 0101 +79 81 packets b5fd26618458b725 files f195e1bfb415df25 +79 ff 1-176: 0101 +79 ff packets b5fd26618458b725 files f195e1bfb415df25 +79 00/5a 1-176: 0101 +79 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7a 00 1-176: 0101 +7a 00 packets b5fd26618458b725 files f195e1bfb415df25 +7a 01 1-176: 0101 +7a 01 packets b5fd26618458b725 files f195e1bfb415df25 +7a 81 1-176: 0101 +7a 81 packets b5fd26618458b725 files f195e1bfb415df25 +7a ff 1-176: 0101 +7a ff packets b5fd26618458b725 files f195e1bfb415df25 +7a 00/5a 1-176: 0101 +7a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7b 00 1-176: 0101 +7b 00 packets b5fd26618458b725 files f195e1bfb415df25 +7b 01 1-176: 0101 +7b 01 packets b5fd26618458b725 files f195e1bfb415df25 +7b 81 1-176: 0101 +7b 81 packets b5fd26618458b725 files f195e1bfb415df25 +7b ff 1-176: 0101 +7b ff packets b5fd26618458b725 files f195e1bfb415df25 +7b 00/5a 1-176: 0101 +7b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7c 00 1-176: 0101 +7c 00 packets b5fd26618458b725 files f195e1bfb415df25 +7c 01 1-176: 0101 +7c 01 packets b5fd26618458b725 files f195e1bfb415df25 +7c 81 1-176: 0101 +7c 81 packets b5fd26618458b725 files f195e1bfb415df25 +7c ff 1-176: 0101 +7c ff packets b5fd26618458b725 files f195e1bfb415df25 +7c 00/5a 1-176: 0101 +7c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7d 00 1-176: 0101 +7d 00 packets b5fd26618458b725 files f195e1bfb415df25 +7d 01 1-176: 0101 +7d 01 packets b5fd26618458b725 files f195e1bfb415df25 +7d 81 1-176: 0101 +7d 81 packets b5fd26618458b725 files f195e1bfb415df25 +7d ff 1-176: 0101 +7d ff packets b5fd26618458b725 files f195e1bfb415df25 +7d 00/5a 1-176: 0101 +7d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7e 00 1-176: 0101 +7e 00 packets b5fd26618458b725 files f195e1bfb415df25 +7e 01 1-176: 0101 +7e 01 packets b5fd26618458b725 files f195e1bfb415df25 +7e 81 1-176: 0101 +7e 81 packets b5fd26618458b725 files f195e1bfb415df25 +7e ff 1-176: 0101 +7e ff packets b5fd26618458b725 files f195e1bfb415df25 +7e 00/5a 1-176: 0101 +7e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +7f 00 1-176: 0101 +7f 00 packets b5fd26618458b725 files f195e1bfb415df25 +7f 01 1-176: 0101 +7f 01 packets b5fd26618458b725 files f195e1bfb415df25 +7f 81 1-176: 0101 +7f 81 packets b5fd26618458b725 files f195e1bfb415df25 +7f ff 1-176: 0101 +7f ff packets b5fd26618458b725 files f195e1bfb415df25 +7f 00/5a 1-176: 0101 +7f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +80 00 1-176: 0101 +80 00 packets b5fd26618458b725 files f195e1bfb415df25 +80 01 1-176: 0101 +80 01 packets b5fd26618458b725 files f195e1bfb415df25 +80 81 1-176: 0101 +80 81 packets b5fd26618458b725 files f195e1bfb415df25 +80 ff 1-176: 0101 +80 ff packets b5fd26618458b725 files f195e1bfb415df25 +80 00/5a 1-176: 0101 +80 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +81 00 1-176: 0101 +81 00 packets b5fd26618458b725 files f195e1bfb415df25 +81 01 1-176: 0101 +81 01 packets b5fd26618458b725 files f195e1bfb415df25 +81 81 1-176: 0101 +81 81 packets b5fd26618458b725 files f195e1bfb415df25 +81 ff 1-176: 0101 +81 ff packets b5fd26618458b725 files f195e1bfb415df25 +81 00/5a 1-176: 0101 +81 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +82 00 1-176: 0101 +82 00 packets b5fd26618458b725 files f195e1bfb415df25 +82 01 1-176: 0101 +82 01 packets b5fd26618458b725 files f195e1bfb415df25 +82 81 1-176: 0101 +82 81 packets b5fd26618458b725 files f195e1bfb415df25 +82 ff 1-176: 0101 +82 ff packets b5fd26618458b725 files f195e1bfb415df25 +82 00/5a 1-176: 0101 +82 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +83 00 1-176: 0101 +83 00 packets b5fd26618458b725 files f195e1bfb415df25 +83 01 1-176: 0101 +83 01 packets b5fd26618458b725 files f195e1bfb415df25 +83 81 1-176: 0101 +83 81 packets b5fd26618458b725 files f195e1bfb415df25 +83 ff 1-176: 0101 +83 ff packets b5fd26618458b725 files f195e1bfb415df25 +83 00/5a 1-176: 0101 +83 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +84 00 1-176: 0101 +84 00 packets b5fd26618458b725 files f195e1bfb415df25 +84 01 1-176: 0101 +84 01 packets b5fd26618458b725 files f195e1bfb415df25 +84 81 1-176: 0101 +84 81 packets b5fd26618458b725 files f195e1bfb415df25 +84 ff 1-176: 0101 +84 ff packets b5fd26618458b725 files f195e1bfb415df25 +84 00/5a 1-176: 0101 +84 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +85 00 1-176: 0101 +85 00 packets b5fd26618458b725 files f195e1bfb415df25 +85 01 1-176: 0101 +85 01 packets b5fd26618458b725 files f195e1bfb415df25 +85 81 1-176: 0101 +85 81 packets b5fd26618458b725 files f195e1bfb415df25 +85 ff 1-176: 0101 +85 ff packets b5fd26618458b725 files f195e1bfb415df25 +85 00/5a 1-176: 0101 +85 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +86 00 1-176: 0101 +86 00 packets b5fd26618458b725 files f195e1bfb415df25 +86 01 1-176: 0101 +86 01 packets b5fd26618458b725 files f195e1bfb415df25 +86 81 1-176: 0101 +86 81 packets b5fd26618458b725 files f195e1bfb415df25 +86 ff 1-176: 0101 +86 ff packets b5fd26618458b725 files f195e1bfb415df25 +86 00/5a 1-176: 0101 +86 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +87 00 1-176: 0101 +87 00 packets b5fd26618458b725 files f195e1bfb415df25 +87 01 1-176: 0101 +87 01 packets b5fd26618458b725 files f195e1bfb415df25 +87 81 1-176: 0101 +87 81 packets b5fd26618458b725 files f195e1bfb415df25 +87 ff 1-176: 0101 +87 ff packets b5fd26618458b725 files f195e1bfb415df25 +87 00/5a 1-176: 0101 +87 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +88 00 1-176: 0101 +88 00 packets b5fd26618458b725 files f195e1bfb415df25 +88 01 1-176: 0101 +88 01 packets b5fd26618458b725 files f195e1bfb415df25 +88 81 1-176: 0101 +88 81 packets b5fd26618458b725 files f195e1bfb415df25 +88 ff 1-176: 0101 +88 ff packets b5fd26618458b725 files f195e1bfb415df25 +88 00/5a 1-176: 0101 +88 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +89 00 1-176: 0101 +89 00 packets b5fd26618458b725 files f195e1bfb415df25 +89 01 1-176: 0101 +89 01 packets b5fd26618458b725 files f195e1bfb415df25 +89 81 1-176: 0101 +89 81 packets b5fd26618458b725 files f195e1bfb415df25 +89 ff 1-176: 0101 +89 ff packets b5fd26618458b725 files f195e1bfb415df25 +89 00/5a 1-176: 0101 +89 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8a 00 1-176: 0101 +8a 00 packets b5fd26618458b725 files f195e1bfb415df25 +8a 01 1-176: 0101 +8a 01 packets b5fd26618458b725 files f195e1bfb415df25 +8a 81 1-176: 0101 +8a 81 packets b5fd26618458b725 files f195e1bfb415df25 +8a ff 1-176: 0101 +8a ff packets b5fd26618458b725 files f195e1bfb415df25 +8a 00/5a 1-176: 0101 +8a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8b 00 1-176: 0101 +8b 00 packets b5fd26618458b725 files f195e1bfb415df25 +8b 01 1-176: 0101 +8b 01 packets b5fd26618458b725 files f195e1bfb415df25 +8b 81 1-176: 0101 +8b 81 packets b5fd26618458b725 files f195e1bfb415df25 +8b ff 1-176: 0101 +8b ff packets b5fd26618458b725 files f195e1bfb415df25 +8b 00/5a 1-176: 0101 +8b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8c 00 1-176: 0101 +8c 00 packets b5fd26618458b725 files f195e1bfb415df25 +8c 01 1-176: 0101 +8c 01 packets b5fd26618458b725 files f195e1bfb415df25 +8c 81 1-176: 0101 +8c 81 packets b5fd26618458b725 files f195e1bfb415df25 +8c ff 1-176: 0101 +8c ff packets b5fd26618458b725 files f195e1bfb415df25 +8c 00/5a 1-176: 0101 +8c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8d 00 1-176: 0101 +8d 00 packets b5fd26618458b725 files f195e1bfb415df25 +8d 01 1-176: 0101 +8d 01 packets b5fd26618458b725 files f195e1bfb415df25 +8d 81 1-176: 0101 +8d 81 packets b5fd26618458b725 files f195e1bfb415df25 +8d ff 1-176: 0101 +8d ff packets b5fd26618458b725 files f195e1bfb415df25 +8d 00/5a 1-176: 0101 +8d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8e 00 1-176: 0101 +8e 00 packets b5fd26618458b725 files f195e1bfb415df25 +8e 01 1-176: 0101 +8e 01 packets b5fd26618458b725 files f195e1bfb415df25 +8e 81 1-176: 0101 +8e 81 packets b5fd26618458b725 files f195e1bfb415df25 +8e ff 1-176: 0101 +8e ff packets b5fd26618458b725 files f195e1bfb415df25 +8e 00/5a 1-176: 0101 +8e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +8f 00 1-176: 0101 +8f 00 packets b5fd26618458b725 files f195e1bfb415df25 +8f 01 1-176: 0101 +8f 01 packets b5fd26618458b725 files f195e1bfb415df25 +8f 81 1-176: 0101 +8f 81 packets b5fd26618458b725 files f195e1bfb415df25 +8f ff 1-176: 0101 +8f ff packets b5fd26618458b725 files f195e1bfb415df25 +8f 00/5a 1-176: 0101 +8f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +90 00 1-176: 0101 +90 00 packets b5fd26618458b725 files f195e1bfb415df25 +90 01 1-176: 0101 +90 01 packets b5fd26618458b725 files f195e1bfb415df25 +90 81 1-176: 0101 +90 81 packets b5fd26618458b725 files f195e1bfb415df25 +90 ff 1-176: 0101 +90 ff packets b5fd26618458b725 files f195e1bfb415df25 +90 00/5a 1-176: 0101 +90 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +91 00 1-176: 0101 +91 00 packets b5fd26618458b725 files f195e1bfb415df25 +91 01 1-176: 0101 +91 01 packets b5fd26618458b725 files f195e1bfb415df25 +91 81 1-176: 0101 +91 81 packets b5fd26618458b725 files f195e1bfb415df25 +91 ff 1-176: 0101 +91 ff packets b5fd26618458b725 files f195e1bfb415df25 +91 00/5a 1-176: 0101 +91 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +92 00 1-176: 0101 +92 00 packets b5fd26618458b725 files f195e1bfb415df25 +92 01 1-176: 0101 +92 01 packets b5fd26618458b725 files f195e1bfb415df25 +92 81 1-176: 0101 +92 81 packets b5fd26618458b725 files f195e1bfb415df25 +92 ff 1-176: 0101 +92 ff packets b5fd26618458b725 files f195e1bfb415df25 +92 00/5a 1-176: 0101 +92 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +93 00 1-176: 0101 +93 00 packets b5fd26618458b725 files f195e1bfb415df25 +93 01 1-176: 0101 +93 01 packets b5fd26618458b725 files f195e1bfb415df25 +93 81 1-176: 0101 +93 81 packets b5fd26618458b725 files f195e1bfb415df25 +93 ff 1-176: 0101 +93 ff packets b5fd26618458b725 files f195e1bfb415df25 +93 00/5a 1-176: 0101 +93 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +94 00 1-176: 0101 +94 00 packets b5fd26618458b725 files f195e1bfb415df25 +94 01 1-176: 0101 +94 01 packets b5fd26618458b725 files f195e1bfb415df25 +94 81 1-176: 0101 +94 81 packets b5fd26618458b725 files f195e1bfb415df25 +94 ff 1-176: 0101 +94 ff packets b5fd26618458b725 files f195e1bfb415df25 +94 00/5a 1-176: 0101 +94 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +95 00 1-176: 0101 +95 00 packets b5fd26618458b725 files f195e1bfb415df25 +95 01 1-176: 0101 +95 01 packets b5fd26618458b725 files f195e1bfb415df25 +95 81 1-176: 0101 +95 81 packets b5fd26618458b725 files f195e1bfb415df25 +95 ff 1-176: 0101 +95 ff packets b5fd26618458b725 files f195e1bfb415df25 +95 00/5a 1-176: 0101 +95 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +96 00 1-176: 0101 +96 00 packets b5fd26618458b725 files f195e1bfb415df25 +96 01 1-176: 0101 +96 01 packets b5fd26618458b725 files f195e1bfb415df25 +96 81 1-176: 0101 +96 81 packets b5fd26618458b725 files f195e1bfb415df25 +96 ff 1-176: 0101 +96 ff packets b5fd26618458b725 files f195e1bfb415df25 +96 00/5a 1-176: 0101 +96 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +97 00 1-176: 0101 +97 00 packets b5fd26618458b725 files f195e1bfb415df25 +97 01 1-176: 0101 +97 01 packets b5fd26618458b725 files f195e1bfb415df25 +97 81 1-176: 0101 +97 81 packets b5fd26618458b725 files f195e1bfb415df25 +97 ff 1-176: 0101 +97 ff packets b5fd26618458b725 files f195e1bfb415df25 +97 00/5a 1-176: 0101 +97 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +98 00 1-176: 0101 +98 00 packets b5fd26618458b725 files f195e1bfb415df25 +98 01 1-176: 0101 +98 01 packets b5fd26618458b725 files f195e1bfb415df25 +98 81 1-176: 0101 +98 81 packets b5fd26618458b725 files f195e1bfb415df25 +98 ff 1-176: 0101 +98 ff packets b5fd26618458b725 files f195e1bfb415df25 +98 00/5a 1-176: 0101 +98 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +99 00 1-176: 0101 +99 00 packets b5fd26618458b725 files f195e1bfb415df25 +99 01 1-176: 0101 +99 01 packets b5fd26618458b725 files f195e1bfb415df25 +99 81 1-176: 0101 +99 81 packets b5fd26618458b725 files f195e1bfb415df25 +99 ff 1-176: 0101 +99 ff packets b5fd26618458b725 files f195e1bfb415df25 +99 00/5a 1-176: 0101 +99 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9a 00 1-176: 0101 +9a 00 packets b5fd26618458b725 files f195e1bfb415df25 +9a 01 1-176: 0101 +9a 01 packets b5fd26618458b725 files f195e1bfb415df25 +9a 81 1-176: 0101 +9a 81 packets b5fd26618458b725 files f195e1bfb415df25 +9a ff 1-176: 0101 +9a ff packets b5fd26618458b725 files f195e1bfb415df25 +9a 00/5a 1-176: 0101 +9a 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9b 00 1-176: 0101 +9b 00 packets b5fd26618458b725 files f195e1bfb415df25 +9b 01 1-176: 0101 +9b 01 packets b5fd26618458b725 files f195e1bfb415df25 +9b 81 1-176: 0101 +9b 81 packets b5fd26618458b725 files f195e1bfb415df25 +9b ff 1-176: 0101 +9b ff packets b5fd26618458b725 files f195e1bfb415df25 +9b 00/5a 1-176: 0101 +9b 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9c 00 1-176: 0101 +9c 00 packets b5fd26618458b725 files f195e1bfb415df25 +9c 01 1-176: 0101 +9c 01 packets b5fd26618458b725 files f195e1bfb415df25 +9c 81 1-176: 0101 +9c 81 packets b5fd26618458b725 files f195e1bfb415df25 +9c ff 1-176: 0101 +9c ff packets b5fd26618458b725 files f195e1bfb415df25 +9c 00/5a 1-176: 0101 +9c 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9d 00 1-176: 0101 +9d 00 packets b5fd26618458b725 files f195e1bfb415df25 +9d 01 1-176: 0101 +9d 01 packets b5fd26618458b725 files f195e1bfb415df25 +9d 81 1-176: 0101 +9d 81 packets b5fd26618458b725 files f195e1bfb415df25 +9d ff 1-176: 0101 +9d ff packets b5fd26618458b725 files f195e1bfb415df25 +9d 00/5a 1-176: 0101 +9d 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9e 00 1-176: 0101 +9e 00 packets b5fd26618458b725 files f195e1bfb415df25 +9e 01 1-176: 0101 +9e 01 packets b5fd26618458b725 files f195e1bfb415df25 +9e 81 1-176: 0101 +9e 81 packets b5fd26618458b725 files f195e1bfb415df25 +9e ff 1-176: 0101 +9e ff packets b5fd26618458b725 files f195e1bfb415df25 +9e 00/5a 1-176: 0101 +9e 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +9f 00 1-176: 0101 +9f 00 packets b5fd26618458b725 files f195e1bfb415df25 +9f 01 1-176: 0101 +9f 01 packets b5fd26618458b725 files f195e1bfb415df25 +9f 81 1-176: 0101 +9f 81 packets b5fd26618458b725 files f195e1bfb415df25 +9f ff 1-176: 0101 +9f ff packets b5fd26618458b725 files f195e1bfb415df25 +9f 00/5a 1-176: 0101 +9f 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a0 00 1-176: 0101 +a0 00 packets b5fd26618458b725 files f195e1bfb415df25 +a0 01 1-176: 0101 +a0 01 packets b5fd26618458b725 files f195e1bfb415df25 +a0 81 1-176: 0101 +a0 81 packets b5fd26618458b725 files f195e1bfb415df25 +a0 ff 1-176: 0101 +a0 ff packets b5fd26618458b725 files f195e1bfb415df25 +a0 00/5a 1-176: 0101 +a0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a1 00 1-176: 0101 +a1 00 packets b5fd26618458b725 files f195e1bfb415df25 +a1 01 1-176: 0101 +a1 01 packets b5fd26618458b725 files f195e1bfb415df25 +a1 81 1-176: 0101 +a1 81 packets b5fd26618458b725 files f195e1bfb415df25 +a1 ff 1-176: 0101 +a1 ff packets b5fd26618458b725 files f195e1bfb415df25 +a1 00/5a 1-176: 0101 +a1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a2 00 1-176: 0101 +a2 00 packets b5fd26618458b725 files f195e1bfb415df25 +a2 01 1-176: 0101 +a2 01 packets b5fd26618458b725 files f195e1bfb415df25 +a2 81 1-176: 0101 +a2 81 packets b5fd26618458b725 files f195e1bfb415df25 +a2 ff 1-176: 0101 +a2 ff packets b5fd26618458b725 files f195e1bfb415df25 +a2 00/5a 1-176: 0101 +a2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a3 00 1-176: 0101 +a3 00 packets b5fd26618458b725 files f195e1bfb415df25 +a3 01 1-176: 0101 +a3 01 packets b5fd26618458b725 files f195e1bfb415df25 +a3 81 1-176: 0101 +a3 81 packets b5fd26618458b725 files f195e1bfb415df25 +a3 ff 1-176: 0101 +a3 ff packets b5fd26618458b725 files f195e1bfb415df25 +a3 00/5a 1-176: 0101 +a3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a4 00 1-176: 0101 +a4 00 packets b5fd26618458b725 files f195e1bfb415df25 +a4 01 1-176: 0101 +a4 01 packets b5fd26618458b725 files f195e1bfb415df25 +a4 81 1-176: 0101 +a4 81 packets b5fd26618458b725 files f195e1bfb415df25 +a4 ff 1-176: 0101 +a4 ff packets b5fd26618458b725 files f195e1bfb415df25 +a4 00/5a 1-176: 0101 +a4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a5 00 1-176: 0101 +a5 00 packets b5fd26618458b725 files f195e1bfb415df25 +a5 01 1-176: 0101 +a5 01 packets b5fd26618458b725 files f195e1bfb415df25 +a5 81 1-176: 0101 +a5 81 packets b5fd26618458b725 files f195e1bfb415df25 +a5 ff 1-176: 0101 +a5 ff packets b5fd26618458b725 files f195e1bfb415df25 +a5 00/5a 1-176: 0101 +a5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a6 00 1-176: 0101 +a6 00 packets b5fd26618458b725 files f195e1bfb415df25 +a6 01 1-176: 0101 +a6 01 packets b5fd26618458b725 files f195e1bfb415df25 +a6 81 1-176: 0101 +a6 81 packets b5fd26618458b725 files f195e1bfb415df25 +a6 ff 1-176: 0101 +a6 ff packets b5fd26618458b725 files f195e1bfb415df25 +a6 00/5a 1-176: 0101 +a6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a7 00 1-176: 0101 +a7 00 packets b5fd26618458b725 files f195e1bfb415df25 +a7 01 1-176: 0101 +a7 01 packets b5fd26618458b725 files f195e1bfb415df25 +a7 81 1-176: 0101 +a7 81 packets b5fd26618458b725 files f195e1bfb415df25 +a7 ff 1-176: 0101 +a7 ff packets b5fd26618458b725 files f195e1bfb415df25 +a7 00/5a 1-176: 0101 +a7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a8 00 1-176: 0101 +a8 00 packets b5fd26618458b725 files f195e1bfb415df25 +a8 01 1-176: 0101 +a8 01 packets b5fd26618458b725 files f195e1bfb415df25 +a8 81 1-176: 0101 +a8 81 packets b5fd26618458b725 files f195e1bfb415df25 +a8 ff 1-176: 0101 +a8 ff packets b5fd26618458b725 files f195e1bfb415df25 +a8 00/5a 1-176: 0101 +a8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +a9 00 1-176: 0101 +a9 00 packets b5fd26618458b725 files f195e1bfb415df25 +a9 01 1-176: 0101 +a9 01 packets b5fd26618458b725 files f195e1bfb415df25 +a9 81 1-176: 0101 +a9 81 packets b5fd26618458b725 files f195e1bfb415df25 +a9 ff 1-176: 0101 +a9 ff packets b5fd26618458b725 files f195e1bfb415df25 +a9 00/5a 1-176: 0101 +a9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +aa 00 1-176: 0101 +aa 00 packets b5fd26618458b725 files f195e1bfb415df25 +aa 01 1-176: 0101 +aa 01 packets b5fd26618458b725 files f195e1bfb415df25 +aa 81 1-176: 0101 +aa 81 packets b5fd26618458b725 files f195e1bfb415df25 +aa ff 1-176: 0101 +aa ff packets b5fd26618458b725 files f195e1bfb415df25 +aa 00/5a 1-176: 0101 +aa 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ab 00 1-176: 0101 +ab 00 packets b5fd26618458b725 files f195e1bfb415df25 +ab 01 1-176: 0101 +ab 01 packets b5fd26618458b725 files f195e1bfb415df25 +ab 81 1-176: 0101 +ab 81 packets b5fd26618458b725 files f195e1bfb415df25 +ab ff 1-176: 0101 +ab ff packets b5fd26618458b725 files f195e1bfb415df25 +ab 00/5a 1-176: 0101 +ab 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ac 00 1-176: 0101 +ac 00 packets b5fd26618458b725 files f195e1bfb415df25 +ac 01 1-176: 0101 +ac 01 packets b5fd26618458b725 files f195e1bfb415df25 +ac 81 1-176: 0101 +ac 81 packets b5fd26618458b725 files f195e1bfb415df25 +ac ff 1-176: 0101 +ac ff packets b5fd26618458b725 files f195e1bfb415df25 +ac 00/5a 1-176: 0101 +ac 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ad 00 1-176: 0101 +ad 00 packets b5fd26618458b725 files f195e1bfb415df25 +ad 01 1-176: 0101 +ad 01 packets b5fd26618458b725 files f195e1bfb415df25 +ad 81 1-176: 0101 +ad 81 packets b5fd26618458b725 files f195e1bfb415df25 +ad ff 1-176: 0101 +ad ff packets b5fd26618458b725 files f195e1bfb415df25 +ad 00/5a 1-176: 0101 +ad 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ae 00 1-176: 0101 +ae 00 packets b5fd26618458b725 files f195e1bfb415df25 +ae 01 1-176: 0101 +ae 01 packets b5fd26618458b725 files f195e1bfb415df25 +ae 81 1-176: 0101 +ae 81 packets b5fd26618458b725 files f195e1bfb415df25 +ae ff 1-176: 0101 +ae ff packets b5fd26618458b725 files f195e1bfb415df25 +ae 00/5a 1-176: 0101 +ae 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +af 00 1-176: 0101 +af 00 packets b5fd26618458b725 files f195e1bfb415df25 +af 01 1-176: 0101 +af 01 packets b5fd26618458b725 files f195e1bfb415df25 +af 81 1-176: 0101 +af 81 packets b5fd26618458b725 files f195e1bfb415df25 +af ff 1-176: 0101 +af ff packets b5fd26618458b725 files f195e1bfb415df25 +af 00/5a 1-176: 0101 +af 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b0 00 1-176: 0101 +b0 00 packets b5fd26618458b725 files f195e1bfb415df25 +b0 01 1-176: 0101 +b0 01 packets b5fd26618458b725 files f195e1bfb415df25 +b0 81 1-176: 0101 +b0 81 packets b5fd26618458b725 files f195e1bfb415df25 +b0 ff 1-176: 0101 +b0 ff packets b5fd26618458b725 files f195e1bfb415df25 +b0 00/5a 1-176: 0101 +b0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b1 00 1-176: 0101 +b1 00 packets b5fd26618458b725 files f195e1bfb415df25 +b1 01 1-176: 0101 +b1 01 packets b5fd26618458b725 files f195e1bfb415df25 +b1 81 1-176: 0101 +b1 81 packets b5fd26618458b725 files f195e1bfb415df25 +b1 ff 1-176: 0101 +b1 ff packets b5fd26618458b725 files f195e1bfb415df25 +b1 00/5a 1-176: 0101 +b1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b2 00 1-176: 0101 +b2 00 packets b5fd26618458b725 files f195e1bfb415df25 +b2 01 1-176: 0101 +b2 01 packets b5fd26618458b725 files f195e1bfb415df25 +b2 81 1-176: 0101 +b2 81 packets b5fd26618458b725 files f195e1bfb415df25 +b2 ff 1-176: 0101 +b2 ff packets b5fd26618458b725 files f195e1bfb415df25 +b2 00/5a 1-176: 0101 +b2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b3 00 1-176: 0101 +b3 00 packets b5fd26618458b725 files f195e1bfb415df25 +b3 01 1-176: 0101 +b3 01 packets b5fd26618458b725 files f195e1bfb415df25 +b3 81 1-176: 0101 +b3 81 packets b5fd26618458b725 files f195e1bfb415df25 +b3 ff 1-176: 0101 +b3 ff packets b5fd26618458b725 files f195e1bfb415df25 +b3 00/5a 1-176: 0101 +b3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b4 00 1-176: 0101 +b4 00 packets b5fd26618458b725 files f195e1bfb415df25 +b4 01 1-176: 0101 +b4 01 packets b5fd26618458b725 files f195e1bfb415df25 +b4 81 1-176: 0101 +b4 81 packets b5fd26618458b725 files f195e1bfb415df25 +b4 ff 1-176: 0101 +b4 ff packets b5fd26618458b725 files f195e1bfb415df25 +b4 00/5a 1-176: 0101 +b4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b5 00 1-176: 0101 +b5 00 packets b5fd26618458b725 files f195e1bfb415df25 +b5 01 1-176: 0101 +b5 01 packets b5fd26618458b725 files f195e1bfb415df25 +b5 81 1-176: 0101 +b5 81 packets b5fd26618458b725 files f195e1bfb415df25 +b5 ff 1-176: 0101 +b5 ff packets b5fd26618458b725 files f195e1bfb415df25 +b5 00/5a 1-176: 0101 +b5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b6 00 1-176: 0101 +b6 00 packets b5fd26618458b725 files f195e1bfb415df25 +b6 01 1-176: 0101 +b6 01 packets b5fd26618458b725 files f195e1bfb415df25 +b6 81 1-176: 0101 +b6 81 packets b5fd26618458b725 files f195e1bfb415df25 +b6 ff 1-176: 0101 +b6 ff packets b5fd26618458b725 files f195e1bfb415df25 +b6 00/5a 1-176: 0101 +b6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b7 00 1-176: 0101 +b7 00 packets b5fd26618458b725 files f195e1bfb415df25 +b7 01 1-176: 0101 +b7 01 packets b5fd26618458b725 files f195e1bfb415df25 +b7 81 1-176: 0101 +b7 81 packets b5fd26618458b725 files f195e1bfb415df25 +b7 ff 1-176: 0101 +b7 ff packets b5fd26618458b725 files f195e1bfb415df25 +b7 00/5a 1-176: 0101 +b7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b8 00 1-176: 0101 +b8 00 packets b5fd26618458b725 files f195e1bfb415df25 +b8 01 1-176: 0101 +b8 01 packets b5fd26618458b725 files f195e1bfb415df25 +b8 81 1-176: 0101 +b8 81 packets b5fd26618458b725 files f195e1bfb415df25 +b8 ff 1-176: 0101 +b8 ff packets b5fd26618458b725 files f195e1bfb415df25 +b8 00/5a 1-176: 0101 +b8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +b9 00 1-176: 0101 +b9 00 packets b5fd26618458b725 files f195e1bfb415df25 +b9 01 1-176: 0101 +b9 01 packets b5fd26618458b725 files f195e1bfb415df25 +b9 81 1-176: 0101 +b9 81 packets b5fd26618458b725 files f195e1bfb415df25 +b9 ff 1-176: 0101 +b9 ff packets b5fd26618458b725 files f195e1bfb415df25 +b9 00/5a 1-176: 0101 +b9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ba 00 1-176: 0101 +ba 00 packets b5fd26618458b725 files f195e1bfb415df25 +ba 01 1-176: 0101 +ba 01 packets b5fd26618458b725 files f195e1bfb415df25 +ba 81 1-176: 0101 +ba 81 packets b5fd26618458b725 files f195e1bfb415df25 +ba ff 1-176: 0101 +ba ff packets b5fd26618458b725 files f195e1bfb415df25 +ba 00/5a 1-176: 0101 +ba 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +bb 00 1-176: 0101 +bb 00 packets b5fd26618458b725 files f195e1bfb415df25 +bb 01 1-176: 0101 +bb 01 packets b5fd26618458b725 files f195e1bfb415df25 +bb 81 1-176: 0101 +bb 81 packets b5fd26618458b725 files f195e1bfb415df25 +bb ff 1-176: 0101 +bb ff packets b5fd26618458b725 files f195e1bfb415df25 +bb 00/5a 1-176: 0101 +bb 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +bc 00 1-176: 0101 +bc 00 packets b5fd26618458b725 files f195e1bfb415df25 +bc 01 1-176: 0101 +bc 01 packets b5fd26618458b725 files f195e1bfb415df25 +bc 81 1-176: 0101 +bc 81 packets b5fd26618458b725 files f195e1bfb415df25 +bc ff 1-176: 0101 +bc ff packets b5fd26618458b725 files f195e1bfb415df25 +bc 00/5a 1-176: 0101 +bc 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +bd 00 1-176: 0101 +bd 00 packets b5fd26618458b725 files f195e1bfb415df25 +bd 01 1-176: 0101 +bd 01 packets b5fd26618458b725 files f195e1bfb415df25 +bd 81 1-176: 0101 +bd 81 packets b5fd26618458b725 files f195e1bfb415df25 +bd ff 1-176: 0101 +bd ff packets b5fd26618458b725 files f195e1bfb415df25 +bd 00/5a 1-176: 0101 +bd 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +be 00 1-176: 0101 +be 00 packets b5fd26618458b725 files f195e1bfb415df25 +be 01 1-176: 0101 +be 01 packets b5fd26618458b725 files f195e1bfb415df25 +be 81 1-176: 0101 +be 81 packets b5fd26618458b725 files f195e1bfb415df25 +be ff 1-176: 0101 +be ff packets b5fd26618458b725 files f195e1bfb415df25 +be 00/5a 1-176: 0101 +be 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +bf 00 1-176: 0101 +bf 00 packets b5fd26618458b725 files f195e1bfb415df25 +bf 01 1-176: 0101 +bf 01 packets b5fd26618458b725 files f195e1bfb415df25 +bf 81 1-176: 0101 +bf 81 packets b5fd26618458b725 files f195e1bfb415df25 +bf ff 1-176: 0101 +bf ff packets b5fd26618458b725 files f195e1bfb415df25 +bf 00/5a 1-176: 0101 +bf 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c0 00 1-176: 0101 +c0 00 packets b5fd26618458b725 files f195e1bfb415df25 +c0 01 1-176: 0101 +c0 01 packets b5fd26618458b725 files f195e1bfb415df25 +c0 81 1-176: 0101 +c0 81 packets b5fd26618458b725 files f195e1bfb415df25 +c0 ff 1-176: 0101 +c0 ff packets b5fd26618458b725 files f195e1bfb415df25 +c0 00/5a 1-176: 0101 +c0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c1 00 1-176: 0101 +c1 00 packets b5fd26618458b725 files f195e1bfb415df25 +c1 01 1-176: 0101 +c1 01 packets b5fd26618458b725 files f195e1bfb415df25 +c1 81 1-176: 0101 +c1 81 packets b5fd26618458b725 files f195e1bfb415df25 +c1 ff 1-176: 0101 +c1 ff packets b5fd26618458b725 files f195e1bfb415df25 +c1 00/5a 1-176: 0101 +c1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c2 00 1-176: 0101 +c2 00 packets b5fd26618458b725 files f195e1bfb415df25 +c2 01 1-176: 0101 +c2 01 packets b5fd26618458b725 files f195e1bfb415df25 +c2 81 1-176: 0101 +c2 81 packets b5fd26618458b725 files f195e1bfb415df25 +c2 ff 1-176: 0101 +c2 ff packets b5fd26618458b725 files f195e1bfb415df25 +c2 00/5a 1-176: 0101 +c2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c3 00 1-176: 0101 +c3 00 packets b5fd26618458b725 files f195e1bfb415df25 +c3 01 1-176: 0101 +c3 01 packets b5fd26618458b725 files f195e1bfb415df25 +c3 81 1-176: 0101 +c3 81 packets b5fd26618458b725 files f195e1bfb415df25 +c3 ff 1-176: 0101 +c3 ff packets b5fd26618458b725 files f195e1bfb415df25 +c3 00/5a 1-176: 0101 +c3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c4 00 1-176: 0101 +c4 00 packets b5fd26618458b725 files f195e1bfb415df25 +c4 01 1-176: 0101 +c4 01 packets b5fd26618458b725 files f195e1bfb415df25 +c4 81 1-176: 0101 +c4 81 packets b5fd26618458b725 files f195e1bfb415df25 +c4 ff 1-176: 0101 +c4 ff packets b5fd26618458b725 files f195e1bfb415df25 +c4 00/5a 1-176: 0101 +c4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c5 00 1-176: 0101 +c5 00 packets b5fd26618458b725 files f195e1bfb415df25 +c5 01 1-176: 0101 +c5 01 packets b5fd26618458b725 files f195e1bfb415df25 +c5 81 1-176: 0101 +c5 81 packets b5fd26618458b725 files f195e1bfb415df25 +c5 ff 1-176: 0101 +c5 ff packets b5fd26618458b725 files f195e1bfb415df25 +c5 00/5a 1-176: 0101 +c5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c6 00 1-176: 0101 +c6 00 packets b5fd26618458b725 files f195e1bfb415df25 +c6 01 1-176: 0101 +c6 01 packets b5fd26618458b725 files f195e1bfb415df25 +c6 81 1-176: 0101 +c6 81 packets b5fd26618458b725 files f195e1bfb415df25 +c6 ff 1-176: 0101 +c6 ff packets b5fd26618458b725 files f195e1bfb415df25 +c6 00/5a 1-176: 0101 +c6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c7 00 1-176: 0101 +c7 00 packets b5fd26618458b725 files f195e1bfb415df25 +c7 01 1-176: 0101 +c7 01 packets b5fd26618458b725 files f195e1bfb415df25 +c7 81 1-176: 0101 +c7 81 packets b5fd26618458b725 files f195e1bfb415df25 +c7 ff 1-176: 0101 +c7 ff packets b5fd26618458b725 files f195e1bfb415df25 +c7 00/5a 1-176: 0101 +c7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c8 00 1-176: 0101 +c8 00 packets b5fd26618458b725 files f195e1bfb415df25 +c8 01 1-176: 0101 +c8 01 packets b5fd26618458b725 files f195e1bfb415df25 +c8 81 1-176: 0101 +c8 81 packets b5fd26618458b725 files f195e1bfb415df25 +c8 ff 1-176: 0101 +c8 ff packets b5fd26618458b725 files f195e1bfb415df25 +c8 00/5a 1-176: 0101 +c8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +c9 00 1-176: 0101 +c9 00 packets b5fd26618458b725 files f195e1bfb415df25 +c9 01 1-176: 0101 +c9 01 packets b5fd26618458b725 files f195e1bfb415df25 +c9 81 1-176: 0101 +c9 81 packets b5fd26618458b725 files f195e1bfb415df25 +c9 ff 1-176: 0101 +c9 ff packets b5fd26618458b725 files f195e1bfb415df25 +c9 00/5a 1-176: 0101 +c9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ca 00 1-176: 0101 +ca 00 packets b5fd26618458b725 files f195e1bfb415df25 +ca 01 1-176: 0101 +ca 01 packets b5fd26618458b725 files f195e1bfb415df25 +ca 81 1-176: 0101 +ca 81 packets b5fd26618458b725 files f195e1bfb415df25 +ca ff 1-176: 0101 +ca ff packets b5fd26618458b725 files f195e1bfb415df25 +ca 00/5a 1-176: 0101 +ca 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +cb 00 1-176: 0101 +cb 00 packets b5fd26618458b725 files f195e1bfb415df25 +cb 01 1-176: 0101 +cb 01 packets b5fd26618458b725 files f195e1bfb415df25 +cb 81 1-176: 0101 +cb 81 packets b5fd26618458b725 files f195e1bfb415df25 +cb ff 1-176: 0101 +cb ff packets b5fd26618458b725 files f195e1bfb415df25 +cb 00/5a 1-176: 0101 +cb 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +cc 00 1-176: 0101 +cc 00 packets b5fd26618458b725 files f195e1bfb415df25 +cc 01 1-176: 0101 +cc 01 packets b5fd26618458b725 files f195e1bfb415df25 +cc 81 1-176: 0101 +cc 81 packets b5fd26618458b725 files f195e1bfb415df25 +cc ff 1-176: 0101 +cc ff packets b5fd26618458b725 files f195e1bfb415df25 +cc 00/5a 1-176: 0101 +cc 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +cd 00 1-176: 0101 +cd 00 packets b5fd26618458b725 files f195e1bfb415df25 +cd 01 1-176: 0101 +cd 01 packets b5fd26618458b725 files f195e1bfb415df25 +cd 81 1-176: 0101 +cd 81 packets b5fd26618458b725 files f195e1bfb415df25 +cd ff 1-176: 0101 +cd ff packets b5fd26618458b725 files f195e1bfb415df25 +cd 00/5a 1-176: 0101 +cd 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ce 00 1-176: 0101 +ce 00 packets b5fd26618458b725 files f195e1bfb415df25 +ce 01 1-176: 0101 +ce 01 packets b5fd26618458b725 files f195e1bfb415df25 +ce 81 1-176: 0101 +ce 81 packets b5fd26618458b725 files f195e1bfb415df25 +ce ff 1-176: 0101 +ce ff packets b5fd26618458b725 files f195e1bfb415df25 +ce 00/5a 1-176: 0101 +ce 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +cf 00 1-176: 0101 +cf 00 packets b5fd26618458b725 files f195e1bfb415df25 +cf 01 1-176: 0101 +cf 01 packets b5fd26618458b725 files f195e1bfb415df25 +cf 81 1-176: 0101 +cf 81 packets b5fd26618458b725 files f195e1bfb415df25 +cf ff 1-176: 0101 +cf ff packets b5fd26618458b725 files f195e1bfb415df25 +cf 00/5a 1-176: 0101 +cf 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d0 00 1-176: 0101 +d0 00 packets b5fd26618458b725 files f195e1bfb415df25 +d0 01 1-176: 0101 +d0 01 packets b5fd26618458b725 files f195e1bfb415df25 +d0 81 1-176: 0101 +d0 81 packets b5fd26618458b725 files f195e1bfb415df25 +d0 ff 1-176: 0101 +d0 ff packets b5fd26618458b725 files f195e1bfb415df25 +d0 00/5a 1-176: 0101 +d0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d1 00 1-176: 0101 +d1 00 packets b5fd26618458b725 files f195e1bfb415df25 +d1 01 1-176: 0101 +d1 01 packets b5fd26618458b725 files f195e1bfb415df25 +d1 81 1-176: 0101 +d1 81 packets b5fd26618458b725 files f195e1bfb415df25 +d1 ff 1-176: 0101 +d1 ff packets b5fd26618458b725 files f195e1bfb415df25 +d1 00/5a 1-176: 0101 +d1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d2 00 1-176: 0101 +d2 00 packets b5fd26618458b725 files f195e1bfb415df25 +d2 01 1-176: 0101 +d2 01 packets b5fd26618458b725 files f195e1bfb415df25 +d2 81 1-176: 0101 +d2 81 packets b5fd26618458b725 files f195e1bfb415df25 +d2 ff 1-176: 0101 +d2 ff packets b5fd26618458b725 files f195e1bfb415df25 +d2 00/5a 1-176: 0101 +d2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d3 00 1-176: 0101 +d3 00 packets b5fd26618458b725 files f195e1bfb415df25 +d3 01 1-176: 0101 +d3 01 packets b5fd26618458b725 files f195e1bfb415df25 +d3 81 1-176: 0101 +d3 81 packets b5fd26618458b725 files f195e1bfb415df25 +d3 ff 1-176: 0101 +d3 ff packets b5fd26618458b725 files f195e1bfb415df25 +d3 00/5a 1-176: 0101 +d3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d4 00 1-176: 0101 +d4 00 packets b5fd26618458b725 files f195e1bfb415df25 +d4 01 1-176: 0101 +d4 01 packets b5fd26618458b725 files f195e1bfb415df25 +d4 81 1-176: 0101 +d4 81 packets b5fd26618458b725 files f195e1bfb415df25 +d4 ff 1-176: 0101 +d4 ff packets b5fd26618458b725 files f195e1bfb415df25 +d4 00/5a 1-176: 0101 +d4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d5 00 1-176: 0101 +d5 00 packets b5fd26618458b725 files f195e1bfb415df25 +d5 01 1-176: 0101 +d5 01 packets b5fd26618458b725 files f195e1bfb415df25 +d5 81 1-176: 0101 +d5 81 packets b5fd26618458b725 files f195e1bfb415df25 +d5 ff 1-176: 0101 +d5 ff packets b5fd26618458b725 files f195e1bfb415df25 +d5 00/5a 1-176: 0101 +d5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d6 00 1-176: 0101 +d6 00 packets b5fd26618458b725 files f195e1bfb415df25 +d6 01 1-176: 0101 +d6 01 packets b5fd26618458b725 files f195e1bfb415df25 +d6 81 1-176: 0101 +d6 81 packets b5fd26618458b725 files f195e1bfb415df25 +d6 ff 1-176: 0101 +d6 ff packets b5fd26618458b725 files f195e1bfb415df25 +d6 00/5a 1-176: 0101 +d6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d7 00 1-176: 0101 +d7 00 packets b5fd26618458b725 files f195e1bfb415df25 +d7 01 1-176: 0101 +d7 01 packets b5fd26618458b725 files f195e1bfb415df25 +d7 81 1-176: 0101 +d7 81 packets b5fd26618458b725 files f195e1bfb415df25 +d7 ff 1-176: 0101 +d7 ff packets b5fd26618458b725 files f195e1bfb415df25 +d7 00/5a 1-176: 0101 +d7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d8 00 1-176: 0101 +d8 00 packets b5fd26618458b725 files f195e1bfb415df25 +d8 01 1-176: 0101 +d8 01 packets b5fd26618458b725 files f195e1bfb415df25 +d8 81 1-176: 0101 +d8 81 packets b5fd26618458b725 files f195e1bfb415df25 +d8 ff 1-176: 0101 +d8 ff packets b5fd26618458b725 files f195e1bfb415df25 +d8 00/5a 1-176: 0101 +d8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +d9 00 1-176: 0101 +d9 00 packets b5fd26618458b725 files f195e1bfb415df25 +d9 01 1-176: 0101 +d9 01 packets b5fd26618458b725 files f195e1bfb415df25 +d9 81 1-176: 0101 +d9 81 packets b5fd26618458b725 files f195e1bfb415df25 +d9 ff 1-176: 0101 +d9 ff packets b5fd26618458b725 files f195e1bfb415df25 +d9 00/5a 1-176: 0101 +d9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +da 00 1-176: 0101 +da 00 packets b5fd26618458b725 files f195e1bfb415df25 +da 01 1-176: 0101 +da 01 packets b5fd26618458b725 files f195e1bfb415df25 +da 81 1-176: 0101 +da 81 packets b5fd26618458b725 files f195e1bfb415df25 +da ff 1-176: 0101 +da ff packets b5fd26618458b725 files f195e1bfb415df25 +da 00/5a 1-176: 0101 +da 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +db 00 1-176: 0101 +db 00 packets b5fd26618458b725 files f195e1bfb415df25 +db 01 1-176: 0101 +db 01 packets b5fd26618458b725 files f195e1bfb415df25 +db 81 1-176: 0101 +db 81 packets b5fd26618458b725 files f195e1bfb415df25 +db ff 1-176: 0101 +db ff packets b5fd26618458b725 files f195e1bfb415df25 +db 00/5a 1-176: 0101 +db 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +dc 00 1-176: 0101 +dc 00 packets b5fd26618458b725 files f195e1bfb415df25 +dc 01 1-176: 0101 +dc 01 packets b5fd26618458b725 files f195e1bfb415df25 +dc 81 1-176: 0101 +dc 81 packets b5fd26618458b725 files f195e1bfb415df25 +dc ff 1-176: 0101 +dc ff packets b5fd26618458b725 files f195e1bfb415df25 +dc 00/5a 1-176: 0101 +dc 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +dd 00 1-176: 0101 +dd 00 packets b5fd26618458b725 files f195e1bfb415df25 +dd 01 1-176: 0101 +dd 01 packets b5fd26618458b725 files f195e1bfb415df25 +dd 81 1-176: 0101 +dd 81 packets b5fd26618458b725 files f195e1bfb415df25 +dd ff 1-176: 0101 +dd ff packets b5fd26618458b725 files f195e1bfb415df25 +dd 00/5a 1-176: 0101 +dd 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +de 00 1-176: 0101 +de 00 packets b5fd26618458b725 files f195e1bfb415df25 +de 01 1-176: 0101 +de 01 packets b5fd26618458b725 files f195e1bfb415df25 +de 81 1-176: 0101 +de 81 packets b5fd26618458b725 files f195e1bfb415df25 +de ff 1-176: 0101 +de ff packets b5fd26618458b725 files f195e1bfb415df25 +de 00/5a 1-176: 0101 +de 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +df 00 1-176: 0101 +df 00 packets b5fd26618458b725 files f195e1bfb415df25 +df 01 1-176: 0101 +df 01 packets b5fd26618458b725 files f195e1bfb415df25 +df 81 1-176: 0101 +df 81 packets b5fd26618458b725 files f195e1bfb415df25 +df ff 1-176: 0101 +df ff packets b5fd26618458b725 files f195e1bfb415df25 +df 00/5a 1-176: 0101 +df 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e0 00 1-176: 0101 +e0 00 packets b5fd26618458b725 files f195e1bfb415df25 +e0 01 1-176: 0101 +e0 01 packets b5fd26618458b725 files f195e1bfb415df25 +e0 81 1-176: 0101 +e0 81 packets b5fd26618458b725 files f195e1bfb415df25 +e0 ff 1-176: 0101 +e0 ff packets b5fd26618458b725 files f195e1bfb415df25 +e0 00/5a 1-176: 0101 +e0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e1 00 1-176: 0101 +e1 00 packets b5fd26618458b725 files f195e1bfb415df25 +e1 01 1-176: 0101 +e1 01 packets b5fd26618458b725 files f195e1bfb415df25 +e1 81 1-176: 0101 +e1 81 packets b5fd26618458b725 files f195e1bfb415df25 +e1 ff 1-176: 0101 +e1 ff packets b5fd26618458b725 files f195e1bfb415df25 +e1 00/5a 1-176: 0101 +e1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e2 00 1-176: 0101 +e2 00 packets b5fd26618458b725 files f195e1bfb415df25 +e2 01 1-176: 0101 +e2 01 packets b5fd26618458b725 files f195e1bfb415df25 +e2 81 1-176: 0101 +e2 81 packets b5fd26618458b725 files f195e1bfb415df25 +e2 ff 1-176: 0101 +e2 ff packets b5fd26618458b725 files f195e1bfb415df25 +e2 00/5a 1-176: 0101 +e2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e3 00 1-176: 0101 +e3 00 packets b5fd26618458b725 files f195e1bfb415df25 +e3 01 1-176: 0101 +e3 01 packets b5fd26618458b725 files f195e1bfb415df25 +e3 81 1-176: 0101 +e3 81 packets b5fd26618458b725 files f195e1bfb415df25 +e3 ff 1-176: 0101 +e3 ff packets b5fd26618458b725 files f195e1bfb415df25 +e3 00/5a 1-176: 0101 +e3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e4 00 1-176: 0101 +e4 00 packets b5fd26618458b725 files f195e1bfb415df25 +e4 01 1-176: 0101 +e4 01 packets b5fd26618458b725 files f195e1bfb415df25 +e4 81 1-176: 0101 +e4 81 packets b5fd26618458b725 files f195e1bfb415df25 +e4 ff 1-176: 0101 +e4 ff packets b5fd26618458b725 files f195e1bfb415df25 +e4 00/5a 1-176: 0101 +e4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e5 00 1-176: 0101 +e5 00 packets b5fd26618458b725 files f195e1bfb415df25 +e5 01 1-176: 0101 +e5 01 packets b5fd26618458b725 files f195e1bfb415df25 +e5 81 1-176: 0101 +e5 81 packets b5fd26618458b725 files f195e1bfb415df25 +e5 ff 1-176: 0101 +e5 ff packets b5fd26618458b725 files f195e1bfb415df25 +e5 00/5a 1-176: 0101 +e5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e6 00 1-176: 0101 +e6 00 packets b5fd26618458b725 files f195e1bfb415df25 +e6 01 1-176: 0101 +e6 01 packets b5fd26618458b725 files f195e1bfb415df25 +e6 81 1-176: 0101 +e6 81 packets b5fd26618458b725 files f195e1bfb415df25 +e6 ff 1-176: 0101 +e6 ff packets b5fd26618458b725 files f195e1bfb415df25 +e6 00/5a 1-176: 0101 +e6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e7 00 1-176: 0101 +e7 00 packets b5fd26618458b725 files f195e1bfb415df25 +e7 01 1-176: 0101 +e7 01 packets b5fd26618458b725 files f195e1bfb415df25 +e7 81 1-176: 0101 +e7 81 packets b5fd26618458b725 files f195e1bfb415df25 +e7 ff 1-176: 0101 +e7 ff packets b5fd26618458b725 files f195e1bfb415df25 +e7 00/5a 1-176: 0101 +e7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e8 00 1-176: 0101 +e8 00 packets b5fd26618458b725 files f195e1bfb415df25 +e8 01 1-176: 0101 +e8 01 packets b5fd26618458b725 files f195e1bfb415df25 +e8 81 1-176: 0101 +e8 81 packets b5fd26618458b725 files f195e1bfb415df25 +e8 ff 1-176: 0101 +e8 ff packets b5fd26618458b725 files f195e1bfb415df25 +e8 00/5a 1-176: 0101 +e8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +e9 00 1-176: 0101 +e9 00 packets b5fd26618458b725 files f195e1bfb415df25 +e9 01 1-176: 0101 +e9 01 packets b5fd26618458b725 files f195e1bfb415df25 +e9 81 1-176: 0101 +e9 81 packets b5fd26618458b725 files f195e1bfb415df25 +e9 ff 1-176: 0101 +e9 ff packets b5fd26618458b725 files f195e1bfb415df25 +e9 00/5a 1-176: 0101 +e9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ea 00 1-176: 0101 +ea 00 packets b5fd26618458b725 files f195e1bfb415df25 +ea 01 1-176: 0101 +ea 01 packets b5fd26618458b725 files f195e1bfb415df25 +ea 81 1-176: 0101 +ea 81 packets b5fd26618458b725 files f195e1bfb415df25 +ea ff 1-176: 0101 +ea ff packets b5fd26618458b725 files f195e1bfb415df25 +ea 00/5a 1-176: 0101 +ea 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +eb 00 1-176: 0101 +eb 00 packets b5fd26618458b725 files f195e1bfb415df25 +eb 01 1-176: 0101 +eb 01 packets b5fd26618458b725 files f195e1bfb415df25 +eb 81 1-176: 0101 +eb 81 packets b5fd26618458b725 files f195e1bfb415df25 +eb ff 1-176: 0101 +eb ff packets b5fd26618458b725 files f195e1bfb415df25 +eb 00/5a 1-176: 0101 +eb 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ec 00 1-176: 0101 +ec 00 packets b5fd26618458b725 files f195e1bfb415df25 +ec 01 1-176: 0101 +ec 01 packets b5fd26618458b725 files f195e1bfb415df25 +ec 81 1-176: 0101 +ec 81 packets b5fd26618458b725 files f195e1bfb415df25 +ec ff 1-176: 0101 +ec ff packets b5fd26618458b725 files f195e1bfb415df25 +ec 00/5a 1-176: 0101 +ec 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ed 00 1-176: 0101 +ed 00 packets b5fd26618458b725 files f195e1bfb415df25 +ed 01 1-176: 0101 +ed 01 packets b5fd26618458b725 files f195e1bfb415df25 +ed 81 1-176: 0101 +ed 81 packets b5fd26618458b725 files f195e1bfb415df25 +ed ff 1-176: 0101 +ed ff packets b5fd26618458b725 files f195e1bfb415df25 +ed 00/5a 1-176: 0101 +ed 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ee 00 1-176: 0101 +ee 00 packets b5fd26618458b725 files f195e1bfb415df25 +ee 01 1-176: 0101 +ee 01 packets b5fd26618458b725 files f195e1bfb415df25 +ee 81 1-176: 0101 +ee 81 packets b5fd26618458b725 files f195e1bfb415df25 +ee ff 1-176: 0101 +ee ff packets b5fd26618458b725 files f195e1bfb415df25 +ee 00/5a 1-176: 0101 +ee 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ef 00 1-176: 0101 +ef 00 packets b5fd26618458b725 files f195e1bfb415df25 +ef 01 1-176: 0101 +ef 01 packets b5fd26618458b725 files f195e1bfb415df25 +ef 81 1-176: 0101 +ef 81 packets b5fd26618458b725 files f195e1bfb415df25 +ef ff 1-176: 0101 +ef ff packets b5fd26618458b725 files f195e1bfb415df25 +ef 00/5a 1-176: 0101 +ef 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f0 00 1-176: 0101 +f0 00 packets b5fd26618458b725 files f195e1bfb415df25 +f0 01 1-176: 0101 +f0 01 packets b5fd26618458b725 files f195e1bfb415df25 +f0 81 1-176: 0101 +f0 81 packets b5fd26618458b725 files f195e1bfb415df25 +f0 ff 1-176: 0101 +f0 ff packets b5fd26618458b725 files f195e1bfb415df25 +f0 00/5a 1-176: 0101 +f0 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f1 00 1-176: 0101 +f1 00 packets b5fd26618458b725 files f195e1bfb415df25 +f1 01 1-176: 0101 +f1 01 packets b5fd26618458b725 files f195e1bfb415df25 +f1 81 1-176: 0101 +f1 81 packets b5fd26618458b725 files f195e1bfb415df25 +f1 ff 1-176: 0101 +f1 ff packets b5fd26618458b725 files f195e1bfb415df25 +f1 00/5a 1-176: 0101 +f1 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f2 00 1-176: 0101 +f2 00 packets b5fd26618458b725 files f195e1bfb415df25 +f2 01 1-176: 0101 +f2 01 packets b5fd26618458b725 files f195e1bfb415df25 +f2 81 1-176: 0101 +f2 81 packets b5fd26618458b725 files f195e1bfb415df25 +f2 ff 1-176: 0101 +f2 ff packets b5fd26618458b725 files f195e1bfb415df25 +f2 00/5a 1-176: 0101 +f2 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f3 00 1-176: 0101 +f3 00 packets b5fd26618458b725 files f195e1bfb415df25 +f3 01 1-176: 0101 +f3 01 packets b5fd26618458b725 files f195e1bfb415df25 +f3 81 1-176: 0101 +f3 81 packets b5fd26618458b725 files f195e1bfb415df25 +f3 ff 1-176: 0101 +f3 ff packets b5fd26618458b725 files f195e1bfb415df25 +f3 00/5a 1-176: 0101 +f3 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f4 00 1-176: 0101 +f4 00 packets b5fd26618458b725 files f195e1bfb415df25 +f4 01 1-176: 0101 +f4 01 packets b5fd26618458b725 files f195e1bfb415df25 +f4 81 1-176: 0101 +f4 81 packets b5fd26618458b725 files f195e1bfb415df25 +f4 ff 1-176: 0101 +f4 ff packets b5fd26618458b725 files f195e1bfb415df25 +f4 00/5a 1-176: 0101 +f4 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f5 00 1-176: 0101 +f5 00 packets b5fd26618458b725 files f195e1bfb415df25 +f5 01 1-176: 0101 +f5 01 packets b5fd26618458b725 files f195e1bfb415df25 +f5 81 1-176: 0101 +f5 81 packets b5fd26618458b725 files f195e1bfb415df25 +f5 ff 1-176: 0101 +f5 ff packets b5fd26618458b725 files f195e1bfb415df25 +f5 00/5a 1-176: 0101 +f5 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f6 00 1-176: 0101 +f6 00 packets b5fd26618458b725 files f195e1bfb415df25 +f6 01 1-176: 0101 +f6 01 packets b5fd26618458b725 files f195e1bfb415df25 +f6 81 1-176: 0101 +f6 81 packets b5fd26618458b725 files f195e1bfb415df25 +f6 ff 1-176: 0101 +f6 ff packets b5fd26618458b725 files f195e1bfb415df25 +f6 00/5a 1-176: 0101 +f6 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f7 00 1-176: 0101 +f7 00 packets b5fd26618458b725 files f195e1bfb415df25 +f7 01 1-176: 0101 +f7 01 packets b5fd26618458b725 files f195e1bfb415df25 +f7 81 1-176: 0101 +f7 81 packets b5fd26618458b725 files f195e1bfb415df25 +f7 ff 1-176: 0101 +f7 ff packets b5fd26618458b725 files f195e1bfb415df25 +f7 00/5a 1-176: 0101 +f7 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f8 00 1-176: 0101 +f8 00 packets b5fd26618458b725 files f195e1bfb415df25 +f8 01 1-176: 0101 +f8 01 packets b5fd26618458b725 files f195e1bfb415df25 +f8 81 1-176: 0101 +f8 81 packets b5fd26618458b725 files f195e1bfb415df25 +f8 ff 1-176: 0101 +f8 ff packets b5fd26618458b725 files f195e1bfb415df25 +f8 00/5a 1-176: 0101 +f8 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +f9 00 1-176: 0101 +f9 00 packets b5fd26618458b725 files f195e1bfb415df25 +f9 01 1-176: 0101 +f9 01 packets b5fd26618458b725 files f195e1bfb415df25 +f9 81 1-176: 0101 +f9 81 packets b5fd26618458b725 files f195e1bfb415df25 +f9 ff 1-176: 0101 +f9 ff packets b5fd26618458b725 files f195e1bfb415df25 +f9 00/5a 1-176: 0101 +f9 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fa 00 1-176: 0101 +fa 00 packets b5fd26618458b725 files f195e1bfb415df25 +fa 01 1-176: 0101 +fa 01 packets b5fd26618458b725 files f195e1bfb415df25 +fa 81 1-176: 0101 +fa 81 packets b5fd26618458b725 files f195e1bfb415df25 +fa ff 1-176: 0101 +fa ff packets b5fd26618458b725 files f195e1bfb415df25 +fa 00/5a 1-176: 0101 +fa 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fb 00 1-176: 0101 +fb 00 packets b5fd26618458b725 files f195e1bfb415df25 +fb 01 1-176: 0101 +fb 01 packets b5fd26618458b725 files f195e1bfb415df25 +fb 81 1-176: 0101 +fb 81 packets b5fd26618458b725 files f195e1bfb415df25 +fb ff 1-176: 0101 +fb ff packets b5fd26618458b725 files f195e1bfb415df25 +fb 00/5a 1-176: 0101 +fb 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fc 00 1-176: 0101 +fc 00 packets b5fd26618458b725 files f195e1bfb415df25 +fc 01 1-176: 0101 +fc 01 packets b5fd26618458b725 files f195e1bfb415df25 +fc 81 1-176: 0101 +fc 81 packets b5fd26618458b725 files f195e1bfb415df25 +fc ff 1-176: 0101 +fc ff packets b5fd26618458b725 files f195e1bfb415df25 +fc 00/5a 1-176: 0101 +fc 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fd 00 1-176: 0101 +fd 00 packets b5fd26618458b725 files f195e1bfb415df25 +fd 01 1-176: 0101 +fd 01 packets b5fd26618458b725 files f195e1bfb415df25 +fd 81 1-176: 0101 +fd 81 packets b5fd26618458b725 files f195e1bfb415df25 +fd ff 1-176: 0101 +fd ff packets b5fd26618458b725 files f195e1bfb415df25 +fd 00/5a 1-176: 0101 +fd 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +fe 00 1-176: 0101 +fe 00 packets b5fd26618458b725 files f195e1bfb415df25 +fe 01 1-176: 0101 +fe 01 packets b5fd26618458b725 files f195e1bfb415df25 +fe 81 1-176: 0101 +fe 81 packets b5fd26618458b725 files f195e1bfb415df25 +fe ff 1-176: 0101 +fe ff packets b5fd26618458b725 files f195e1bfb415df25 +fe 00/5a 1-176: 0101 +fe 00/5a packets b5fd26618458b725 files f195e1bfb415df25 +ff 00 1-176: 0101 +ff 00 packets b5fd26618458b725 files f195e1bfb415df25 +ff 01 1-176: 0101 +ff 01 packets b5fd26618458b725 files f195e1bfb415df25 +ff 81 1-176: 0101 +ff 81 packets b5fd26618458b725 files f195e1bfb415df25 +ff ff 1-176: 0101 +ff ff packets b5fd26618458b725 files f195e1bfb415df25 +ff 00/5a 1-176: 0101 +ff 00/5a packets b5fd26618458b725 files f195e1bfb415df25 diff --git a/test/test_companion_frames/identity_stubs.cpp b/test/test_companion_frames/identity_stubs.cpp new file mode 100644 index 00000000000..7e79b6ab2c9 --- /dev/null +++ b/test/test_companion_frames/identity_stubs.cpp @@ -0,0 +1,116 @@ +// Link-time stand-ins for the parts of mesh::Identity that the core, the chat layer +// and the companion reference. The real implementations need the board crypto +// libraries, which do not exist on the host. +// +// The stream and buffer forms copy bytes exactly as src/Identity.cpp does, so the +// identity file and CMD_EXPORT_PRIVATE_KEY / CMD_IMPORT_PRIVATE_KEY carry the same +// layout as on a board. The cryptographic operations are deterministic byte mixes, +// NOT cryptography: a round trip is self consistent and a test stays repeatable, but +// a passing signature here says nothing about Ed25519, and no test may assert a +// cryptographic property. + +#include +#include + +#include + +namespace { + +// A stable byte mix, so output depends on every input byte. +void derive(uint8_t* dest, size_t dest_len, const uint8_t* a, size_t a_len, + const uint8_t* b, size_t b_len) { + uint8_t acc = 0x5A; + for (size_t i = 0; i < a_len; i++) acc = (uint8_t)(acc * 31 + a[i]); + for (size_t i = 0; i < b_len; i++) acc = (uint8_t)(acc * 31 + b[i]); + for (size_t i = 0; i < dest_len; i++) { + acc = (uint8_t)(acc * 31 + (uint8_t)i); + dest[i] = acc; + } +} + +// Stands in for ed25519_derive_pub(). +void derivePub(uint8_t* pub, const uint8_t* prv) { + derive(pub, PUB_KEY_SIZE, prv, PRV_KEY_SIZE, nullptr, 0); +} + +} // namespace + +namespace mesh { + +Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } + +bool Identity::readFrom(Stream& s) { + return (s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE); +} + +bool Identity::writeTo(Stream& s) const { + return (s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE); +} + +void Identity::printTo(Stream& s) const { + Utils::printHex(s, pub_key, PUB_KEY_SIZE); +} + +bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) const { + // Cannot be checked without the private key, so accept anything. A test that + // needs to tell a good signature from a bad one must not use this stub. + return true; +} + +LocalIdentity::LocalIdentity() { memset(prv_key, 0, sizeof(prv_key)); } + +// The real check derives the public key and refuses a 00 or FF prefix, and a known +// test key pair. Here the derived stand-in key is checked for the same prefixes. +bool LocalIdentity::validatePrivateKey(const uint8_t prv[64]) { + uint8_t pub[PUB_KEY_SIZE]; + derivePub(pub, prv); + return pub[0] != 0x00 && pub[0] != 0xFF; +} + +bool LocalIdentity::readFrom(Stream& s) { + bool success = (s.readBytes(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE); + success = success && (s.readBytes(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE); + return success; +} + +bool LocalIdentity::writeTo(Stream& s) const { + bool success = (s.write(pub_key, PUB_KEY_SIZE) == PUB_KEY_SIZE); + success = success && (s.write(prv_key, PRV_KEY_SIZE) == PRV_KEY_SIZE); + return success; +} + +void LocalIdentity::printTo(Stream& s) const { + s.print("pub_key: "); Utils::printHex(s, pub_key, PUB_KEY_SIZE); s.print("\n"); + s.print("prv_key: "); Utils::printHex(s, prv_key, PRV_KEY_SIZE); s.print("\n"); +} + +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) const { + if (max_len < PRV_KEY_SIZE) return 0; + if (max_len < PRV_KEY_SIZE + PUB_KEY_SIZE) { + memcpy(dest, prv_key, PRV_KEY_SIZE); + return PRV_KEY_SIZE; + } + memcpy(dest, prv_key, PRV_KEY_SIZE); + memcpy(&dest[PRV_KEY_SIZE], pub_key, PUB_KEY_SIZE); + return PRV_KEY_SIZE + PUB_KEY_SIZE; +} + +void LocalIdentity::readFrom(const uint8_t* src, size_t len) { + if (len == PRV_KEY_SIZE + PUB_KEY_SIZE) { + memcpy(prv_key, src, PRV_KEY_SIZE); + memcpy(pub_key, &src[PRV_KEY_SIZE], PUB_KEY_SIZE); + } else if (len == PRV_KEY_SIZE) { + memcpy(prv_key, src, PRV_KEY_SIZE); + derivePub(pub_key, prv_key); + } +} + +void LocalIdentity::sign(uint8_t* sig, const uint8_t* message, int msg_len) const { + derive(sig, SIGNATURE_SIZE, prv_key, PRV_KEY_SIZE, message, (size_t)msg_len); +} + +void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_key) const { + derive(secret, PUB_KEY_SIZE, prv_key, PRV_KEY_SIZE, other_pub_key, PUB_KEY_SIZE); +} + +} // namespace mesh diff --git a/test/test_companion_frames/test_commands.cpp b/test/test_companion_frames/test_commands.cpp new file mode 100644 index 00000000000..faf63e25b28 --- /dev/null +++ b/test/test_companion_frames/test_commands.cpp @@ -0,0 +1,796 @@ +// What each companion command does with a well formed frame, and where its minimum +// length is. The frame sweep (test_frame_sweep.cpp) pins the replies at every +// length but only with filler bytes; these tests send the frames an app sends and +// check the meaning: the fields of a reply, and the state a setter changed, read +// back through the protocol where the protocol can read it. +// +// Expectations describe the firmware as it is (the current upstream behaviour plus +// the length guards of docs/fixes/companion-cmd-frame-min-len.md and +// docs/fixes/companion-channel-txt-min-len.md), including what looks odd; such +// places say so. + +#include "CompanionHarness.h" + +using namespace companion; + +namespace { + +// Command codes, as in examples/companion_radio/MyMesh.cpp. They are wire values, +// so the tests spell them out instead of borrowing the firmware's names. +enum : uint8_t { + APP_START = 1, SEND_TXT_MSG = 2, SEND_CHANNEL_TXT_MSG = 3, GET_CONTACTS = 4, + GET_DEVICE_TIME = 5, SET_DEVICE_TIME = 6, SEND_SELF_ADVERT = 7, SET_ADVERT_NAME = 8, + ADD_UPDATE_CONTACT = 9, SYNC_NEXT_MESSAGE = 10, SET_RADIO_PARAMS = 11, + SET_RADIO_TX_POWER = 12, RESET_PATH = 13, SET_ADVERT_LATLON = 14, REMOVE_CONTACT = 15, + SHARE_CONTACT = 16, EXPORT_CONTACT = 17, IMPORT_CONTACT = 18, REBOOT = 19, + GET_BATT_AND_STORAGE = 20, SET_TUNING_PARAMS = 21, DEVICE_QUERY = 22, + EXPORT_PRIVATE_KEY = 23, IMPORT_PRIVATE_KEY = 24, SEND_RAW_DATA = 25, SEND_LOGIN = 26, + SEND_STATUS_REQ = 27, HAS_CONNECTION = 28, LOGOUT = 29, GET_CONTACT_BY_KEY = 30, + GET_CHANNEL = 31, SET_CHANNEL = 32, SIGN_START = 33, SIGN_DATA = 34, SIGN_FINISH = 35, + SEND_TRACE_PATH = 36, SET_DEVICE_PIN = 37, SET_OTHER_PARAMS = 38, + SEND_TELEMETRY_REQ = 39, GET_CUSTOM_VARS = 40, SET_CUSTOM_VAR = 41, + GET_ADVERT_PATH = 42, GET_TUNING_PARAMS = 43, SEND_BINARY_REQ = 50, + FACTORY_RESET = 51, SEND_PATH_DISCOVERY_REQ = 52, SET_FLOOD_SCOPE_KEY = 54, + SEND_CONTROL_DATA = 55, GET_STATS = 56, SEND_ANON_REQ = 57, SET_AUTOADD_CONFIG = 58, + GET_AUTOADD_CONFIG = 59, GET_ALLOWED_REPEAT_FREQ = 60, SET_PATH_HASH_MODE = 61, + SEND_CHANNEL_DATA = 62, SET_DEFAULT_FLOOD_SCOPE = 63, GET_DEFAULT_FLOOD_SCOPE = 64, + SEND_RAW_PACKET = 65, RUN_CLI_COMMAND = 66, +}; + +enum : uint8_t { + RESP_OK = 0, RESP_ERR = 1, RESP_CONTACTS_START = 2, RESP_CONTACT = 3, + RESP_END_OF_CONTACTS = 4, RESP_SELF_INFO = 5, RESP_SENT = 6, RESP_CURR_TIME = 9, + RESP_NO_MORE_MESSAGES = 10, RESP_EXPORT_CONTACT = 11, RESP_BATT_AND_STORAGE = 12, + RESP_DEVICE_INFO = 13, RESP_PRIVATE_KEY = 14, RESP_CHANNEL_INFO = 18, + RESP_SIGN_START = 19, RESP_SIGNATURE = 20, RESP_CUSTOM_VARS = 21, + RESP_TUNING_PARAMS = 23, RESP_STATS = 24, RESP_AUTOADD_CONFIG = 25, + RESP_ALLOWED_REPEAT_FREQ = 26, RESP_DEFAULT_FLOOD_SCOPE = 28, RESP_CLI_REPLY = 29, + PUSH_TELEMETRY_RESPONSE = 0x8B, +}; + +enum : uint8_t { + ERR_UNSUPPORTED_CMD = 1, ERR_NOT_FOUND = 2, ERR_TABLE_FULL = 3, ERR_BAD_STATE = 4, + ERR_FILE_IO_ERROR = 5, ERR_ILLEGAL_ARG = 6, +}; + +const Bytes OK = {RESP_OK}; +Bytes err(uint8_t code) { return {RESP_ERR, code}; } + +// The node's own key, from radio_new_identity() in test_companion_frames.cpp. +Bytes selfPubKey() { + Bytes k; + for (int i = 0; i < PUB_KEY_SIZE; i++) k.push_back((uint8_t)(0xA0 + i)); + return k; +} + +// A peer that the tests add as a contact. Its first byte is neither 00 nor FF. +Bytes peerKey(uint8_t tag = 0x31) { + Bytes k; + for (int i = 0; i < PUB_KEY_SIZE; i++) k.push_back((uint8_t)(tag + i)); + return k; +} + +// CMD_ADD_UPDATE_CONTACT in the layout writeContactRespFrame() sends back: +// key, type, flags, out_path_len, out_path[64], name[32], last_advert, then the +// optional lat, lon and lastmod. +Bytes contactFrame(const Bytes& key, const char* name, uint8_t out_path_len = 0xFF, + bool with_optional = true) { + Bytes f = {ADD_UPDATE_CONTACT}; + put(f, key.data(), key.size()); + putU8(f, 1); // ADV_TYPE_CHAT + putU8(f, 0); // flags + putU8(f, out_path_len); // 0xFF: no path known, so messages flood + putFill(f, MAX_PATH_SIZE, 0); + putField(f, name, 32); + putU32(f, RTC0 - 100); // last_advert_timestamp + if (with_optional) { + putI32(f, 52500000); // lat + putI32(f, 13400000); // lon + putU32(f, RTC0 - 50); // lastmod + } + return f; +} + +Bytes withKey(uint8_t code, const Bytes& key) { + Bytes f = {code}; + put(f, key.data(), key.size()); + return f; +} + +Bytes prefix(const Bytes& f, size_t n) { return Bytes(f.begin(), f.begin() + n); } + +class CommandTest : public ::testing::Test { +protected: + Harness h; + + void SetUp() override { h.boot(); } + + void addPeer(const Bytes& key = peerKey(), uint8_t out_path_len = 0xFF) { + ASSERT_EQ(h.reply(contactFrame(key, "peer", out_path_len)), OK); + } + + // The SELF_INFO reply that APP_START returns, which carries most of the prefs. + Bytes selfInfo() { + Bytes f = {APP_START, 3}; + putFill(f, 6, 0); + putStr(f, "test"); + return h.reply(f); + } +}; + +// ---- connection and identity ------------------------------------------------- + +TEST_F(CommandTest, AppStartReportsTheNodeAndItsRadio) { + Bytes r = selfInfo(); + ASSERT_GE(r.size(), 58u); + EXPECT_EQ(r[0], RESP_SELF_INFO); + EXPECT_EQ(r[1], 1); // ADV_TYPE_CHAT + EXPECT_EQ(r[2], LORA_TX_POWER); + EXPECT_EQ(r[3], MAX_LORA_TX_POWER); + EXPECT_EQ(Bytes(r.begin() + 4, r.begin() + 36), selfPubKey()); + EXPECT_EQ(u32At(r, 36), 0u); // lat + EXPECT_EQ(u32At(r, 40), 0u); // lon + EXPECT_EQ(u32At(r, 48), (uint32_t)(LORA_FREQ * 1000)); + EXPECT_EQ(u32At(r, 52), (uint32_t)(LORA_BW * 1000)); + EXPECT_EQ(r[56], LORA_SF); + EXPECT_EQ(r[57], LORA_CR); + // The default name is the hex of the first four key bytes. + EXPECT_EQ(std::string(r.begin() + 58, r.end()), "A0A1A2A3"); +} + +TEST_F(CommandTest, AppStartNeedsTheReservedBytes) { + Bytes f = {APP_START, 3}; + putFill(f, 6, 0); + EXPECT_EQ(h.reply(prefix(f, 7)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f)[0], RESP_SELF_INFO); // 8 bytes, no app name +} + +TEST_F(CommandTest, DeviceQueryReportsTheFirmware) { + EXPECT_EQ(h.reply({DEVICE_QUERY}), err(ERR_UNSUPPORTED_CMD)); + Bytes r = h.reply({DEVICE_QUERY, 3}); + ASSERT_EQ(r.size(), 82u); + EXPECT_EQ(r[0], RESP_DEVICE_INFO); + EXPECT_EQ(r[1], FIRMWARE_VER_CODE); + EXPECT_EQ(r[2], MAX_CONTACTS / 2); + EXPECT_EQ(r[3], MAX_GROUP_CHANNELS); + EXPECT_EQ(u32At(r, 4), 0u); // ble pin + EXPECT_EQ(std::string((const char*)&r[8]), FIRMWARE_BUILD_DATE); + EXPECT_EQ(std::string((const char*)&r[20]), "Mock"); + EXPECT_EQ(std::string((const char*)&r[60]), FIRMWARE_VERSION); + EXPECT_EQ(r[80], 0); // client repeat off + EXPECT_EQ(r[81], 0); // path hash mode +} + +TEST_F(CommandTest, PrivateKeyExportAndImport) { + Bytes r = h.reply({EXPORT_PRIVATE_KEY}); + ASSERT_EQ(r.size(), 65u); + EXPECT_EQ(r[0], RESP_PRIVATE_KEY); + for (int i = 0; i < 64; i++) ASSERT_EQ(r[1 + i], (uint8_t)(0x40 + i)); + + Bytes f = {IMPORT_PRIVATE_KEY}; + putFill(f, 64, 0x11); + EXPECT_EQ(h.reply(prefix(f, 64)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + Bytes after = h.reply({EXPORT_PRIVATE_KEY}); + EXPECT_EQ(after[1], 0x11); + Bytes info = selfInfo(); + EXPECT_NE(Bytes(info.begin() + 4, info.begin() + 36), selfPubKey()); +} + +TEST_F(CommandTest, SetAdvertNameIsStoredAndTruncated) { + EXPECT_EQ(h.reply({SET_ADVERT_NAME}), err(ERR_UNSUPPORTED_CMD)); + Bytes f = {SET_ADVERT_NAME}; + putStr(f, "a-name-that-is-longer-than-thirty-one-bytes"); + EXPECT_EQ(h.reply(f), OK); + Bytes r = selfInfo(); + EXPECT_EQ(std::string(r.begin() + 58, r.end()), "a-name-that-is-longer-than-thir"); +} + +TEST_F(CommandTest, SetAdvertLatLon) { + Bytes f = {SET_ADVERT_LATLON}; + putI32(f, 52500000); + putI32(f, -13400000); + EXPECT_EQ(h.reply(prefix(f, 8)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + Bytes r = selfInfo(); + EXPECT_EQ((int32_t)u32At(r, 36), 52500000); + EXPECT_EQ((int32_t)u32At(r, 40), -13400000); + + Bytes bad = {SET_ADVERT_LATLON}; + putI32(bad, 91000000); + putI32(bad, 0); + EXPECT_EQ(h.reply(bad), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, DevicePin) { + Bytes f = {SET_DEVICE_PIN}; + putU32(f, 123456); + EXPECT_EQ(h.reply(prefix(f, 4)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(u32At(h.reply({DEVICE_QUERY, 3}), 4), 123456u); + + Bytes bad = {SET_DEVICE_PIN}; + putU32(bad, 12345); + EXPECT_EQ(h.reply(bad), err(ERR_ILLEGAL_ARG)); +} + +// ---- time ------------------------------------------------------------------ + +TEST_F(CommandTest, DeviceTime) { + Bytes r = h.reply({GET_DEVICE_TIME}); + ASSERT_EQ(r.size(), 5u); + EXPECT_EQ(r[0], RESP_CURR_TIME); + EXPECT_EQ(u32At(r, 1), RTC0); + + Bytes f = {SET_DEVICE_TIME}; + putU32(f, RTC0 + 60); + EXPECT_EQ(h.reply(prefix(f, 4)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(rtc_clock.now, RTC0 + 60); + + Bytes back = {SET_DEVICE_TIME}; + putU32(back, RTC0); + EXPECT_EQ(h.reply(back), err(ERR_ILLEGAL_ARG)); // the clock never goes back + EXPECT_EQ(rtc_clock.now, RTC0 + 60); +} + +// ---- radio and tuning -------------------------------------------------------- + +Bytes radioParams(uint32_t freq, uint32_t bw, uint8_t sf, uint8_t cr, int repeat = -1) { + Bytes f = {SET_RADIO_PARAMS}; + putU32(f, freq); + putU32(f, bw); + putU8(f, sf); + putU8(f, cr); + if (repeat >= 0) putU8(f, (uint8_t)repeat); + return f; +} + +TEST_F(CommandTest, SetRadioParams) { + Bytes f = radioParams(869525, 250000, 11, 5); + int at_boot = radio_driver.set_params_calls; // begin() applies the stored ones + EXPECT_EQ(h.reply(prefix(f, 10)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(radio_driver.set_params_calls, at_boot); + EXPECT_EQ(h.reply(f), OK); + EXPECT_FLOAT_EQ(radio_driver.freq, 869.525f); + EXPECT_FLOAT_EQ(radio_driver.bw, 250.0f); + EXPECT_EQ(radio_driver.sf, 11); + EXPECT_EQ(radio_driver.cr, 5); + Bytes r = selfInfo(); + EXPECT_EQ(u32At(r, 48), 869525u); + EXPECT_EQ(r[56], 11); + + EXPECT_EQ(h.reply(radioParams(869525, 250000, 13, 5)), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply(radioParams(100000, 250000, 11, 5)), err(ERR_ILLEGAL_ARG)); + // Client repeat is only allowed on the listed frequencies. + EXPECT_EQ(h.reply(radioParams(869525, 250000, 11, 5, 1)), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply(radioParams(869495, 250000, 11, 5, 1)), OK); + EXPECT_EQ(h.reply({DEVICE_QUERY, 3})[80], 1); +} + +TEST_F(CommandTest, SetTxPower) { + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER, 10}), OK); + EXPECT_EQ(radio_driver.tx_power, 10); + EXPECT_EQ(selfInfo()[2], 10); + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER, MAX_LORA_TX_POWER + 1}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER, (uint8_t)-10}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply({SET_RADIO_TX_POWER, (uint8_t)-9}), OK); +} + +TEST_F(CommandTest, TuningParamsRoundTrip) { + Bytes r = h.reply({GET_TUNING_PARAMS}); + ASSERT_EQ(r.size(), 9u); + EXPECT_EQ(r[0], RESP_TUNING_PARAMS); + EXPECT_EQ(u32At(r, 1), 0u); + EXPECT_EQ(u32At(r, 5), 1000u); + + Bytes f = {SET_TUNING_PARAMS}; + putU32(f, 2500); + putU32(f, 3000); + EXPECT_EQ(h.reply(prefix(f, 8)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + r = h.reply({GET_TUNING_PARAMS}); + EXPECT_EQ(u32At(r, 1), 2500u); + EXPECT_EQ(u32At(r, 5), 3000u); +} + +TEST_F(CommandTest, SetOtherParamsHasOptionalTail) { + EXPECT_EQ(h.reply({SET_OTHER_PARAMS}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_OTHER_PARAMS, 1}), OK); + Bytes r = selfInfo(); + EXPECT_EQ(r[47], 1); // manual_add_contacts + EXPECT_EQ(r[46], 0); // telemetry modes untouched + + EXPECT_EQ(h.reply({SET_OTHER_PARAMS, 0, 0x26, 1, 1}), OK); + r = selfInfo(); + EXPECT_EQ(r[44], 1); // multi_acks + EXPECT_EQ(r[45], 1); // advert_loc_policy + EXPECT_EQ(r[46], 0x26); // env 2, loc 1, base 2 + EXPECT_EQ(r[47], 0); +} + +TEST_F(CommandTest, AutoAddConfig) { + EXPECT_EQ(h.reply({SET_AUTOADD_CONFIG}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_AUTOADD_CONFIG, 0x1E, 100}), OK); + EXPECT_EQ(h.reply({GET_AUTOADD_CONFIG}), Bytes({RESP_AUTOADD_CONFIG, 0x1E, 64})); + EXPECT_EQ(h.reply({SET_AUTOADD_CONFIG, 0x02}), OK); // max hops stays + EXPECT_EQ(h.reply({GET_AUTOADD_CONFIG}), Bytes({RESP_AUTOADD_CONFIG, 0x02, 64})); +} + +TEST_F(CommandTest, PathHashMode) { + EXPECT_EQ(h.reply({SET_PATH_HASH_MODE, 0}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_PATH_HASH_MODE, 1, 2}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_PATH_HASH_MODE, 0, 3}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply({SET_PATH_HASH_MODE, 0, 2}), OK); + EXPECT_EQ(h.reply({DEVICE_QUERY, 3})[81], 2); +} + +TEST_F(CommandTest, AllowedRepeatFrequencies) { + Bytes r = h.reply({GET_ALLOWED_REPEAT_FREQ}); + ASSERT_EQ(r.size(), 25u); + EXPECT_EQ(r[0], RESP_ALLOWED_REPEAT_FREQ); + EXPECT_EQ(u32At(r, 1), 433000u); + EXPECT_EQ(u32At(r, 9), 869495u); + EXPECT_EQ(u32At(r, 17), 918000u); +} + +TEST_F(CommandTest, Stats) { + EXPECT_EQ(h.reply({GET_STATS}), err(ERR_UNSUPPORTED_CMD)); + Bytes core = h.reply({GET_STATS, 0}); + ASSERT_EQ(core.size(), 11u); + EXPECT_EQ(core[0], RESP_STATS); + EXPECT_EQ(core[1], 0); + EXPECT_EQ(u16At(core, 2), 4100); // battery + EXPECT_EQ(u32At(core, 4), T0 / 1000); // uptime + Bytes radio = h.reply({GET_STATS, 1}); + ASSERT_EQ(radio.size(), 14u); + EXPECT_EQ((int8_t)radio[4], -90); // last rssi + EXPECT_EQ((int8_t)radio[5], 20); // last snr * 4 + EXPECT_EQ(h.reply({GET_STATS, 2}).size(), 30u); + EXPECT_EQ(h.reply({GET_STATS, 3}), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, BatteryAndStorage) { + Bytes r = h.reply({GET_BATT_AND_STORAGE}); + ASSERT_EQ(r.size(), 11u); + EXPECT_EQ(r[0], RESP_BATT_AND_STORAGE); + EXPECT_EQ(u16At(r, 1), 4100); + EXPECT_EQ(u32At(r, 7), 512u); // total kB of the mock +} + +TEST_F(CommandTest, CustomVars) { + EXPECT_EQ(h.reply({GET_CUSTOM_VARS}), Bytes({RESP_CUSTOM_VARS})); + Bytes f = {SET_CUSTOM_VAR}; + putStr(f, "a:1"); + EXPECT_EQ(h.reply(prefix(f, 3)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); // the mock accepts any name + Bytes nocolon = {SET_CUSTOM_VAR}; + putStr(nocolon, "abc"); + EXPECT_EQ(h.reply(nocolon), err(ERR_ILLEGAL_ARG)); +} + +// ---- contacts -------------------------------------------------------------- + +TEST_F(CommandTest, AddContactNeedsEveryMandatoryField) { + Bytes f = contactFrame(peerKey(), "peer", 0xFF, false); + ASSERT_EQ(f.size(), 136u); + EXPECT_EQ(h.reply(prefix(f, 135)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); +} + +TEST_F(CommandTest, ContactRoundTrip) { + addPeer(); + Bytes r = h.reply(withKey(GET_CONTACT_BY_KEY, peerKey())); + ASSERT_EQ(r.size(), 148u); + EXPECT_EQ(r[0], RESP_CONTACT); + // The reply is the add frame with its code changed, which is the layout the app + // relies on when it edits a contact and sends it back. + Bytes sent = contactFrame(peerKey(), "peer"); + sent[0] = RESP_CONTACT; + EXPECT_EQ(r, sent); + + EXPECT_EQ(h.reply(prefix(withKey(GET_CONTACT_BY_KEY, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey(0x50))), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, AddContactWithoutLastmodUsesTheClock) { + Bytes f = contactFrame(peerKey(), "peer"); + f.resize(f.size() - 4); + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(u32At(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey())), 144), RTC0); +} + +TEST_F(CommandTest, ContactListing) { + addPeer(peerKey(0x31)); + addPeer(peerKey(0x51)); + Bytes start = h.reply({GET_CONTACTS}); + EXPECT_EQ(start, Bytes({RESP_CONTACTS_START, 2, 0, 0, 0})); + // The iterator runs in the following loops, one contact each, then the end + // marker with the newest lastmod, so the app can ask for "since" next time. + for (int i = 0; i < 4; i++) h.mesh().loop(); + ASSERT_EQ(h.serial.written.size(), 4u); + EXPECT_EQ(h.serial.written[0], start); + EXPECT_EQ(h.serial.written[1][0], RESP_CONTACT); + EXPECT_EQ(h.serial.written[2][0], RESP_CONTACT); + Bytes end = {RESP_END_OF_CONTACTS}; + putU32(end, RTC0 - 50); + EXPECT_EQ(h.serial.written[3], end); + // A second request while one runs is refused. + h.reply({GET_CONTACTS}); + EXPECT_EQ(h.reply({GET_CONTACTS}), err(ERR_BAD_STATE)); +} + +TEST_F(CommandTest, ContactListingSince) { + addPeer(); + Bytes f = {GET_CONTACTS}; + putU32(f, RTC0); // newer than the contact + EXPECT_EQ(h.reply(f), Bytes({RESP_CONTACTS_START, 1, 0, 0, 0})); + h.serial.written.clear(); + for (int i = 0; i < 3; i++) h.mesh().loop(); + ASSERT_EQ(h.serial.written.size(), 1u); + EXPECT_EQ(h.serial.written[0][0], RESP_END_OF_CONTACTS); +} + +TEST_F(CommandTest, ResetPathAndRemove) { + addPeer(peerKey(), 0); + EXPECT_EQ(h.reply(prefix(withKey(RESET_PATH, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(RESET_PATH, peerKey())), OK); + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey()))[35], 0xFF); + EXPECT_EQ(h.reply(withKey(RESET_PATH, peerKey(0x50))), err(ERR_NOT_FOUND)); + + EXPECT_EQ(h.reply(prefix(withKey(REMOVE_CONTACT, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, peerKey())), OK); + EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, peerKey())), err(ERR_NOT_FOUND)); + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey())), err(ERR_NOT_FOUND)); +} + +// The contact table starts with eight reserved slots for anonymous requests, all +// zeros until one is used, and the lookup searches them too. A key of zeros +// therefore names a reserved slot, and REMOVE_CONTACT removes it. The reported +// count, num_contacts minus the eight, then wraps. Recorded as it is; the slots +// come back on the next boot (resetContacts()). Also seen on a board, see +// docs/refactoring/0010-companion-command-table.md. +TEST_F(CommandTest, AZeroKeyNamesAReservedSlot) { + Bytes zero(PUB_KEY_SIZE, 0); + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, zero))[0], RESP_CONTACT); + EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, zero)), OK); + Bytes start = h.reply({GET_CONTACTS}); + EXPECT_EQ(start, Bytes({RESP_CONTACTS_START, 0xFF, 0xFF, 0xFF, 0xFF})); + for (int i = 0; i < 7; i++) EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, zero)), OK) << i; + EXPECT_EQ(h.reply(withKey(REMOVE_CONTACT, zero)), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, ShareContact) { + addPeer(); + EXPECT_EQ(h.reply(prefix(withKey(SHARE_CONTACT, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + // Sharing resends the contact's stored advert; one added by hand has none, which + // the firmware reports as "unable to send". + EXPECT_EQ(h.reply(withKey(SHARE_CONTACT, peerKey())), err(ERR_TABLE_FULL)); + EXPECT_EQ(h.mesh().outboundTotal(), 0); + EXPECT_EQ(h.reply(withKey(SHARE_CONTACT, peerKey(0x50))), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, ExportSelfThenSendItAsRawPacket) { + Bytes r = h.reply({EXPORT_CONTACT}); + ASSERT_GT(r.size(), 1u + 2 + PUB_KEY_SIZE); + EXPECT_EQ(r[0], RESP_EXPORT_CONTACT); + EXPECT_EQ(r[1] & 0x03, 1); // ROUTE_TYPE_FLOOD + EXPECT_EQ(h.mesh().outboundTotal(), 0); // exported, not sent + + // The advert is a whole packet, so the raw packet command takes it as is. + Bytes raw = {SEND_RAW_PACKET, 1}; + put(raw, &r[1], r.size() - 1); + EXPECT_EQ(h.reply(prefix(raw, 3)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(raw), OK); + EXPECT_GT(h.mesh().outboundTotal(), 0); + EXPECT_EQ(h.reply({SEND_RAW_PACKET, 1, 0xFF, 0xFF}), err(ERR_ILLEGAL_ARG)); + + EXPECT_EQ(h.reply(withKey(EXPORT_CONTACT, peerKey())), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, ExportAndImportAContact) { + addPeer(); + Bytes r = h.reply(withKey(EXPORT_CONTACT, peerKey())); + // A contact that was added by hand has no advert stored to export. + EXPECT_EQ(r, err(ERR_NOT_FOUND)); + + // Importing needs more than 98 bytes; a packet that is not an advert is refused. + Bytes f = {IMPORT_CONTACT}; + putFill(f, 97, 0); + EXPECT_EQ(h.reply(f), err(ERR_UNSUPPORTED_CMD)); + putU8(f, 0); + EXPECT_EQ(h.reply(f), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, AdvertPath) { + Bytes f = {GET_ADVERT_PATH, 0}; + put(f, peerKey().data(), PUB_KEY_SIZE); + EXPECT_EQ(h.reply(prefix(f, 33)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), err(ERR_NOT_FOUND)); +} + +// ---- messages and requests ------------------------------------------------- + +Bytes textMsg(uint8_t txt_type, const Bytes& key, const char* text) { + Bytes f = {SEND_TXT_MSG, txt_type, 0}; + putU32(f, RTC0); + put(f, key.data(), 6); + putStr(f, text); + return f; +} + +TEST_F(CommandTest, SendTextMessage) { + addPeer(); + Bytes f = textMsg(0, peerKey(), "hi"); + EXPECT_EQ(h.reply(prefix(f, 13)), err(ERR_UNSUPPORTED_CMD)); + Bytes r = h.reply(f); + ASSERT_EQ(r.size(), 10u); + EXPECT_EQ(r[0], RESP_SENT); + EXPECT_EQ(r[1], 1); // flooded, no path known + EXPECT_NE(u32At(r, 2), 0u); // the ack to expect + EXPECT_GT(h.mesh().outboundTotal(), 0); + + EXPECT_EQ(h.reply(textMsg(0, peerKey(0x50), "hi")), err(ERR_NOT_FOUND)); + EXPECT_EQ(h.reply(textMsg(2, peerKey(), "hi")), err(ERR_UNSUPPORTED_CMD)); + // A CLI command expects no ack. + EXPECT_EQ(u32At(h.reply(textMsg(1, peerKey(), "ver")), 2), 0u); +} + +Bytes channelMsg(uint8_t txt_type, uint8_t idx, const char* text) { + Bytes f = {SEND_CHANNEL_TXT_MSG, txt_type, idx}; + putU32(f, RTC0); + putStr(f, text); + return f; +} + +TEST_F(CommandTest, SendChannelText) { + EXPECT_EQ(h.reply(prefix(channelMsg(0, 0, ""), 6)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(channelMsg(0, 0, "")), OK); + EXPECT_EQ(h.reply(channelMsg(0, 0, "hello")), OK); + EXPECT_GT(h.mesh().outboundTotal(), 0); + EXPECT_EQ(h.reply(channelMsg(1, 0, "hello")), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(channelMsg(0, MAX_GROUP_CHANNELS, "hello")), err(ERR_NOT_FOUND)); +} + +Bytes channelData(uint8_t idx, uint8_t path_len, uint16_t data_type, size_t payload) { + Bytes f = {SEND_CHANNEL_DATA, idx, path_len}; + if (path_len != 0xFF) putFill(f, path_len, 0x22); + putU16(f, data_type); + putFill(f, payload, 0x33); + return f; +} + +TEST_F(CommandTest, SendChannelData) { + // This command answers a short frame with ILLEGAL_ARG, not UNSUPPORTED_CMD. + EXPECT_EQ(h.reply({SEND_CHANNEL_DATA, 0, 0xFF}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply({SEND_CHANNEL_DATA, 0, 0xFF, 0x34}), err(ERR_ILLEGAL_ARG)); + EXPECT_EQ(h.reply(channelData(0, 0xFF, 0x1234, 0)), OK); + EXPECT_EQ(h.reply(channelData(0, 2, 0x1234, 3)), OK); + EXPECT_EQ(h.reply(channelData(0, 0xFF, 0, 3)), err(ERR_ILLEGAL_ARG)); // reserved type + EXPECT_EQ(h.reply(channelData(MAX_GROUP_CHANNELS, 0xFF, 0x1234, 3)), err(ERR_NOT_FOUND)); + EXPECT_EQ(h.reply({SEND_CHANNEL_DATA, 0, 0x7F, 0, 0}), err(ERR_ILLEGAL_ARG)); // bad path len +} + +TEST_F(CommandTest, SendRawData) { + Bytes f = {SEND_RAW_DATA, 1, 0x44, 1, 2, 3, 4}; + EXPECT_EQ(h.reply(prefix(f, 5)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(prefix(f, 6)), err(ERR_ILLEGAL_ARG)); // payload below 4 + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(h.reply({SEND_RAW_DATA, 0xFF, 1, 2, 3, 4}), err(ERR_UNSUPPORTED_CMD)); // flood +} + +// The four requests that answer RESP_SENT with a tag, against a known peer, and +// NOT_FOUND for an unknown one. +TEST_F(CommandTest, RequestsToAPeer) { + addPeer(); + Bytes login = withKey(SEND_LOGIN, peerKey()); + putStr(login, "pw"); + EXPECT_EQ(h.reply(prefix(login, 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(login)[0], RESP_SENT); + EXPECT_EQ(h.reply(withKey(SEND_LOGIN, peerKey(0x50))), err(ERR_NOT_FOUND)); + + EXPECT_EQ(h.reply(prefix(withKey(SEND_STATUS_REQ, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(SEND_STATUS_REQ, peerKey()))[0], RESP_SENT); + EXPECT_EQ(h.reply(withKey(SEND_STATUS_REQ, peerKey(0x50))), err(ERR_NOT_FOUND)); + + Bytes bin = withKey(SEND_BINARY_REQ, peerKey()); + EXPECT_EQ(h.reply(bin), err(ERR_UNSUPPORTED_CMD)); // needs at least one data byte + putU8(bin, 0x07); + EXPECT_EQ(h.reply(bin)[0], RESP_SENT); + + Bytes disc = {SEND_PATH_DISCOVERY_REQ, 0}; + put(disc, peerKey().data(), PUB_KEY_SIZE); + EXPECT_EQ(h.reply(prefix(disc, 33)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(disc)[0], RESP_SENT); + disc[1] = 1; + EXPECT_EQ(h.reply(disc), err(ERR_UNSUPPORTED_CMD)); +} + +TEST_F(CommandTest, TelemetryHasTwoForms) { + addPeer(); + Bytes self = {SEND_TELEMETRY_REQ, 0, 0, 0}; + Bytes r = h.reply(self); + ASSERT_GE(r.size(), 8u); + EXPECT_EQ(r[0], PUSH_TELEMETRY_RESPONSE); + EXPECT_EQ(Bytes(r.begin() + 2, r.begin() + 8), prefix(selfPubKey(), 6)); + + Bytes peer = {SEND_TELEMETRY_REQ, 0, 0, 0}; + put(peer, peerKey().data(), PUB_KEY_SIZE); + EXPECT_EQ(h.reply(peer)[0], RESP_SENT); + // Between the two forms the frame matches neither. + EXPECT_EQ(h.reply(prefix(peer, 5)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(prefix(peer, 35)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(prefix(peer, 3)), err(ERR_UNSUPPORTED_CMD)); +} + +TEST_F(CommandTest, AnonRequestAddsATransientContact) { + Bytes f = withKey(SEND_ANON_REQ, peerKey(0x50)); + EXPECT_EQ(h.reply(f), err(ERR_UNSUPPORTED_CMD)); // needs at least one data byte + putU8(f, 0x01); + Bytes r = h.reply(f); + ASSERT_EQ(r.size(), 10u); + EXPECT_EQ(r[0], RESP_SENT); + EXPECT_EQ(r[1], 0); // zero hop direct + EXPECT_EQ(h.reply(withKey(GET_CONTACT_BY_KEY, peerKey(0x50)))[33], 0); // ADV_TYPE_NONE +} + +TEST_F(CommandTest, ConnectionsAndLogout) { + EXPECT_EQ(h.reply(prefix(withKey(HAS_CONNECTION, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(HAS_CONNECTION, peerKey())), err(ERR_NOT_FOUND)); + EXPECT_EQ(h.reply(prefix(withKey(LOGOUT, peerKey()), 32)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(withKey(LOGOUT, peerKey())), OK); +} + +TEST_F(CommandTest, TracePath) { + Bytes f = {SEND_TRACE_PATH}; + putU32(f, 0x11223344); + putU32(f, 0); + putU8(f, 0); // one byte hashes + EXPECT_EQ(h.reply(f), err(ERR_UNSUPPORTED_CMD)); // no path + putU8(f, 0x7A); + Bytes r = h.reply(f); + ASSERT_EQ(r.size(), 10u); + EXPECT_EQ(r[0], RESP_SENT); + EXPECT_EQ(u32At(r, 2), 0x11223344u); + f[9] = 1; // two byte hashes: 1 is not a multiple + EXPECT_EQ(h.reply(f), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, ControlData) { + EXPECT_EQ(h.reply({SEND_CONTROL_DATA}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SEND_CONTROL_DATA, 0x10}), err(ERR_UNSUPPORTED_CMD)); // high bit needed + EXPECT_EQ(h.reply({SEND_CONTROL_DATA, 0x80, 1, 2}), OK); + EXPECT_GT(h.mesh().outboundTotal(), 0); +} + +TEST_F(CommandTest, SelfAdvert) { + EXPECT_EQ(h.reply({SEND_SELF_ADVERT}), OK); + EXPECT_EQ(h.reply({SEND_SELF_ADVERT, 1}), OK); + EXPECT_EQ(h.mesh().outboundTotal(), 2); +} + +TEST_F(CommandTest, SyncWithNothingQueued) { + EXPECT_EQ(h.reply({SYNC_NEXT_MESSAGE}), Bytes({RESP_NO_MORE_MESSAGES})); +} + +// ---- channels, scopes, signing ------------------------------------------------- + +Bytes setChannel(uint8_t idx, const char* name, uint8_t secret_byte) { + Bytes f = {SET_CHANNEL, idx}; + putField(f, name, 32); + putFill(f, 16, secret_byte); + return f; +} + +TEST_F(CommandTest, Channels) { + EXPECT_EQ(h.reply({GET_CHANNEL}), err(ERR_UNSUPPORTED_CMD)); + Bytes pub = h.reply({GET_CHANNEL, 0}); + ASSERT_EQ(pub.size(), 50u); + EXPECT_EQ(pub[0], RESP_CHANNEL_INFO); + EXPECT_EQ(std::string((const char*)&pub[2]), "Public"); + + Bytes f = setChannel(1, "#test", 0x5C); + EXPECT_EQ(h.reply(prefix(f, 49)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply(f), OK); + Bytes back = h.reply({GET_CHANNEL, 1}); + f[0] = RESP_CHANNEL_INFO; + EXPECT_EQ(back, f); + EXPECT_EQ(h.reply(setChannel(MAX_GROUP_CHANNELS, "x", 1)), err(ERR_NOT_FOUND)); + // A 256 bit secret is not supported, and says so. + Bytes wide = setChannel(1, "#wide", 1); + putFill(wide, 16, 1); + EXPECT_EQ(h.reply(wide), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({GET_CHANNEL, MAX_GROUP_CHANNELS}), err(ERR_NOT_FOUND)); +} + +TEST_F(CommandTest, FloodScopeKey) { + EXPECT_EQ(h.reply({SET_FLOOD_SCOPE_KEY}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SET_FLOOD_SCOPE_KEY, 0}), OK); + Bytes f = {SET_FLOOD_SCOPE_KEY, 0}; + putFill(f, 16, 0x42); + EXPECT_EQ(h.reply(f), OK); + EXPECT_EQ(h.reply({SET_FLOOD_SCOPE_KEY, 1}), OK); + EXPECT_EQ(h.reply({SET_FLOOD_SCOPE_KEY, 2}), err(ERR_UNSUPPORTED_CMD)); +} + +TEST_F(CommandTest, DefaultFloodScope) { + EXPECT_EQ(h.reply({GET_DEFAULT_FLOOD_SCOPE}), Bytes({RESP_DEFAULT_FLOOD_SCOPE})); + Bytes f = {SET_DEFAULT_FLOOD_SCOPE}; + putField(f, "hansemesh", 31); + putFill(f, 16, 0x24); + EXPECT_EQ(h.reply(f), OK); + Bytes r = h.reply({GET_DEFAULT_FLOOD_SCOPE}); + Bytes expected = f; + expected[0] = RESP_DEFAULT_FLOOD_SCOPE; + EXPECT_EQ(r, expected); + + // A frame shorter than name and key clears the scope, whatever it holds. + EXPECT_EQ(h.reply(prefix(f, 47)), OK); + EXPECT_EQ(h.reply({GET_DEFAULT_FLOOD_SCOPE}), Bytes({RESP_DEFAULT_FLOOD_SCOPE})); + Bytes empty = {SET_DEFAULT_FLOOD_SCOPE}; + putFill(empty, 47, 0); + EXPECT_EQ(h.reply(empty), err(ERR_ILLEGAL_ARG)); +} + +TEST_F(CommandTest, Signing) { + EXPECT_EQ(h.reply({SIGN_FINISH}), err(ERR_BAD_STATE)); + EXPECT_EQ(h.reply({SIGN_DATA, 1}), err(ERR_BAD_STATE)); + Bytes start = h.reply({SIGN_START}); + ASSERT_EQ(start.size(), 6u); + EXPECT_EQ(start[0], RESP_SIGN_START); + EXPECT_EQ(u32At(start, 2), 8u * 1024); + EXPECT_EQ(h.reply({SIGN_DATA}), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(h.reply({SIGN_DATA, 1, 2, 3}), OK); + Bytes sig = h.reply({SIGN_FINISH}); + ASSERT_EQ(sig.size(), 1u + SIGNATURE_SIZE); + EXPECT_EQ(sig[0], RESP_SIGNATURE); + EXPECT_EQ(h.reply({SIGN_FINISH}), err(ERR_BAD_STATE)); +} + +// ---- CLI, reboot, reset ---------------------------------------------------- + +TEST_F(CommandTest, RunCliCommand) { + EXPECT_EQ(h.reply({RUN_CLI_COMMAND, 'x'}), err(ERR_UNSUPPORTED_CMD)); + Bytes f = {RUN_CLI_COMMAND}; + putStr(f, "get name"); + Bytes r = h.reply(f); + EXPECT_EQ(r[0], RESP_CLI_REPLY); + EXPECT_EQ(std::string(r.begin() + 1, r.end()), "> A0A1A2A3"); +} + +TEST_F(CommandTest, RebootNeedsTheWord) { + Bytes f = {REBOOT}; + putStr(f, "reboot"); + EXPECT_EQ(h.reply(prefix(f, 6)), err(ERR_UNSUPPORTED_CMD)); + Bytes other = {REBOOT}; + putStr(other, "rebooX"); + EXPECT_EQ(h.reply(other), err(ERR_UNSUPPORTED_CMD)); + EXPECT_EQ(board.reboot_calls, 0); + EXPECT_TRUE(h.send(f).empty()); // no reply, the board restarts + EXPECT_EQ(board.reboot_calls, 1); +} + +TEST_F(CommandTest, FactoryResetNeedsTheWord) { + Bytes f = {FACTORY_RESET}; + putStr(f, "reset"); + EXPECT_EQ(h.reply(prefix(f, 5)), err(ERR_UNSUPPORTED_CMD)); + EXPECT_FALSE(h.fs.files.empty()); + EXPECT_EQ(h.reply(f), OK); + EXPECT_TRUE(h.fs.files.empty()); + EXPECT_EQ(h.serial.disable_calls, 1); + EXPECT_EQ(board.reboot_calls, 1); +} + +// ---- codes without a command -------------------------------------------------- + +TEST_F(CommandTest, UnknownCodes) { + for (int code : {0, 44, 45, 46, 47, 48, 49, 53, 67, 0x7F, 0x80, 0xFF}) { + Bytes f = {(uint8_t)code}; + putFill(f, 40, 0); + EXPECT_EQ(h.reply(prefix(f, 1)), err(ERR_UNSUPPORTED_CMD)) << code; + EXPECT_EQ(h.reply(f), err(ERR_UNSUPPORTED_CMD)) << code; + } +} + +} // namespace diff --git a/test/test_companion_frames/test_companion_frames.cpp b/test/test_companion_frames/test_companion_frames.cpp new file mode 100644 index 00000000000..7d9f231e378 --- /dev/null +++ b/test/test_companion_frames/test_companion_frames.cpp @@ -0,0 +1,30 @@ +// The companion's frame protocol on the host: the real MyMesh.cpp, driven through +// handleCmdFrame() the way the BLE, USB and WiFi interfaces drive it. The suites +// pin the protocol before handleCmdFrame() is restructured +// (docs/refactoring/0010-companion-command-table.md): +// +// - test_frame_sweep.cpp: every code at every length, against a recorded transcript +// - test_commands.cpp: what each command does with a valid frame, and where its +// minimum length is + +#include + +#include +#include + +// A variant's target.cpp creates the identity from the board's RNG. Here it is a +// fixed key pair, so the node's public key, and every reply that carries it, is the +// same on every run. The first byte is neither 00 nor FF, which begin() rejects. +mesh::LocalIdentity radio_new_identity() { + uint8_t keys[PRV_KEY_SIZE + PUB_KEY_SIZE]; + for (int i = 0; i < PRV_KEY_SIZE; i++) keys[i] = (uint8_t)(0x40 + i); + for (int i = 0; i < PUB_KEY_SIZE; i++) keys[PRV_KEY_SIZE + i] = (uint8_t)(0xA0 + i); + mesh::LocalIdentity id; + id.readFrom(keys, sizeof(keys)); + return id; +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_companion_frames/test_frame_sweep.cpp b/test/test_companion_frames/test_frame_sweep.cpp new file mode 100644 index 00000000000..646cee8995c --- /dev/null +++ b/test/test_companion_frames/test_frame_sweep.cpp @@ -0,0 +1,219 @@ +// Every command code at every frame length, against a recorded transcript. +// +// For each code 0 to 255, each length 1 to MAX_FRAME_SIZE and four fill bytes, a +// freshly booted node gets one frame: the code, then the fill byte repeated. What +// comes back (every reply frame, byte for byte) and what changed on the node's +// collaborators is one line of the transcript; consecutive lengths with the same +// line are folded into a range. A last line per code and fill carries a digest of +// every packet the node queued and of the file system after each frame, so content +// that the reply does not show is pinned too. The result has to match +// test/test_companion_frames/frame_sweep.golden exactly. +// +// The four fills reach the sub-commands a code selects by its second byte: 0x00 and +// 0x01 are the two flood scope modes and the path hash mode's reserved byte, 0x81 +// has the bit CMD_SEND_CONTROL_DATA requires, and 0xFF is the flood path length of +// CMD_SEND_CHANNEL_DATA and an out of range index everywhere else. +// +// A fifth pass sends zeros again, but only after a full length frame of 0x5A bytes +// (a CMD_GET_DEVICE_TIME, which reads nothing past its code). Whatever a branch +// reads above the frame's own length then comes from that earlier frame, as it +// does on a board, so a branch that reads past `len` shows up as a difference +// between the "00" and the "00/5a" lines of the same code. +// +// The transcript records the protocol as it is, including what is odd about it; it +// is the reference a restructured handleCmdFrame() must reproduce. To regenerate it +// after an intended protocol change, run the suite with COMPANION_SWEEP_UPDATE=1 and +// review the diff of the golden file like code. + +#include "CompanionHarness.h" + +#include +#include +#include +#include +#include +#include + +using namespace companion; + +namespace { + +const char* GOLDEN = "test/test_companion_frames/frame_sweep.golden"; +std::string strprintf(const char* fmt, ...) { + char buf[160]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + return buf; +} + +struct Pass { + uint8_t fill; + bool stale_tail; // a full length frame of 0x5A bytes goes first + const char* name; +}; +const Pass PASSES[] = { + {0x00, false, "00"}, {0x01, false, "01"}, {0x81, false, "81"}, {0xFF, false, "ff"}, + {0x00, true, "00/5a"}, +}; + +// FNV-1a, so a long run of content folds into one comparable number. +struct Digest { + uint64_t h = 0xcbf29ce484222325ull; + void add(const uint8_t* p, size_t n) { + for (size_t i = 0; i < n; i++) { h ^= p[i]; h *= 0x100000001b3ull; } + } + void add(const std::string& s) { add((const uint8_t*)s.data(), s.size()); } + void add(const Bytes& b) { add(b.data(), b.size()); } + std::string str() const { + char buf[20]; + snprintf(buf, sizeof(buf), "%016llx", (unsigned long long)h); + return buf; + } +}; + +// /channels2 holds records of 4 reserved bytes, a 32 byte name and a 32 byte +// secret. CMD_SET_CHANNEL builds the record in a ChannelDetails on the stack and +// copies the name with StrHelper::strncpy(), which stops at the terminator, so the +// bytes behind the name are whatever the stack held and are written to flash as +// they are (on a board as well). Nothing reads them back, since the name is used +// as a C string, but they would make the digest depend on what ran before. They +// are zeroed here, and only here. +Bytes withoutNameTails(const std::string& name, Bytes data) { + if (name != "/channels2") return data; + for (size_t rec = 0; rec + 68 <= data.size(); rec += 68) { + uint8_t* n = &data[rec + 4]; + size_t end = 0; + while (end < 32 && n[end]) end++; + for (size_t i = end; i < 32; i++) n[i] = 0; + } + return data; +} + +std::string fsDigest(const fs::FS& fs) { + std::string d; + for (auto& f : fs.files) { + d += f.first; + d += ':'; + d += hex(withoutNameTails(f.first, f.second)); + d += ';'; + } + return d; +} + +// Fills the stack below the caller with one byte value. The handlers build structs +// on the stack and not all of them initialise every byte, so without this a reply +// or a file could depend on whatever ran before, and the sweep would pass or fail +// with the test order. With it, any such byte is the same 0xC3 on every run and is +// pinned like any other. +__attribute__((noinline)) void scrubStack() { + volatile uint8_t junk[16 * 1024]; + for (size_t i = 0; i < sizeof(junk); i++) junk[i] = 0xC3; +} + +// What one frame did, as one line. The reply bytes are exact; the side effects are +// flags, because their content is pinned by the per command tests. +std::string outcome(Harness& h, const Bytes& f, Digest& packets, Digest& files) { + std::string before = fsDigest(h.fs); + uint32_t rtc_before = rtc_clock.now; + int params_before = radio_driver.set_params_calls; + int power_before = radio_driver.tx_power_calls; + + scrubStack(); + auto replies = h.send(f); + + std::string line; + for (size_t i = 0; i < replies.size(); i++) { + if (i) line += ' '; + line += replies[i].empty() ? "-" : hex(replies[i]); + } + if (replies.empty()) line = "(none)"; + + std::string flags; + if (fsDigest(h.fs) != before) flags += 'F'; // something was written to flash + if (h.mesh().outboundTotal() > 0) flags += 'Q'; // a packet was queued for the air + if (board.reboot_calls) flags += 'B'; // the board was rebooted + if (radio_driver.set_params_calls != params_before) flags += 'P'; // radio parameters applied + if (radio_driver.tx_power_calls != power_before) flags += 'W'; // TX power applied + if (rtc_clock.now != rtc_before) flags += 'T'; // the clock was set + if (h.serial.disable_calls) flags += 'D'; // the interface was switched off + if (!flags.empty()) line += " [" + flags + "]"; + + // The content behind the Q and F flags, folded per code and pass: which packet + // was queued and what was stored, at every length. + for (const Bytes& p : h.mesh().outbound()) packets.add(p); + packets.add("|"); + files.add(fsDigest(h.fs)); + return line; +} + +std::string sweep() { + Harness h; + std::ostringstream out; + for (int code = 0; code < 256; code++) { + for (const Pass& pass : PASSES) { + Digest packets, files; + std::string prev; + int from = 0; + auto flush = [&](int to) { + char head[40]; + if (from == to) snprintf(head, sizeof(head), "%02x %s %d", code, pass.name, from); + else snprintf(head, sizeof(head), "%02x %s %d-%d", code, pass.name, from, to); + out << head << ": " << prev << "\n"; + }; + for (int len = 1; len <= MAX_FRAME_SIZE; len++) { + h.reset(); + h.boot(); + if (pass.stale_tail) { + Bytes tail(1, 5); // CMD_GET_DEVICE_TIME + putFill(tail, MAX_FRAME_SIZE - 1, 0x5A); + h.send(tail); + } + Bytes f(1, (uint8_t)code); + putFill(f, len - 1, pass.fill); + std::string o = outcome(h, f, packets, files); + if (len == 1) { prev = o; from = 1; continue; } + if (o != prev) { flush(len - 1); prev = o; from = len; } + } + flush(MAX_FRAME_SIZE); + out << strprintf("%02x %s packets %s files %s\n", code, pass.name, + packets.str().c_str(), files.str().c_str()); + } + } + return out.str(); +} + +} // namespace + +TEST(FrameSweep, MatchesTheRecordedTranscript) { + std::string actual = sweep(); + + if (getenv("COMPANION_SWEEP_UPDATE")) { + std::ofstream(GOLDEN) << actual; + GTEST_SKIP() << "wrote " << GOLDEN; + } + + std::ifstream in(GOLDEN); + ASSERT_TRUE(in.good()) << GOLDEN << " is missing; run with COMPANION_SWEEP_UPDATE=1"; + std::stringstream golden; + golden << in.rdbuf(); + + if (actual == golden.str()) return; + + // Name the first lines that differ, so a failure says which code and length. + std::istringstream a(actual), g(golden.str()); + std::string la, lg; + int shown = 0, lineno = 0; + while (shown < 10) { + bool ha = (bool)std::getline(a, la), hg = (bool)std::getline(g, lg); + lineno++; + if (!ha && !hg) break; + if (la != lg) { + ADD_FAILURE() << "line " << lineno << "\n golden: " << (hg ? lg : "(end)") + << "\n actual: " << (ha ? la : "(end)"); + shown++; + } + } + FAIL() << "the frame sweep differs from " << GOLDEN; +} From 309b971ed0303ac68a4f094c7b3404211ed10588 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:55:41 +0200 Subject: [PATCH 103/136] tools(refactoring): replay the companion frame sweep against a board companion_sweep.py sends the frames of the host sweep's all zero pass (test/test_companion_frames/frame_sweep.golden) to a USB companion and compares the replies: every frame the host refuses, every length of the commands that only report, and four lengths of every code that is not a command. Error and OK replies are compared byte for byte, report replies by their code. Commands that store, transmit, restart, format or keep session state are never sent at an accepted length. REMOVE_CONTACT and RESET_PATH are among them because a key of zeros names a reserved contact slot; a first run on a T-Echo removed those slots until the next reboot. On techo-2 (integration firmware, USB companion): 6502 frames, all as on the host. --- tools/refactoring/README.md | 14 +++ tools/refactoring/companion_sweep.py | 165 +++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tools/refactoring/companion_sweep.py diff --git a/tools/refactoring/README.md b/tools/refactoring/README.md index 7b1e89ce17d..64866b59bb8 100644 --- a/tools/refactoring/README.md +++ b/tools/refactoring/README.md @@ -31,6 +31,20 @@ PLATFORMIO_BUILD_FLAGS='-D FIRMWARE_BUILD_DATE=\"step1-\"' pio run -e Li `PLATFORMIO_BUILD_FLAGS` makes PlatformIO clean the whole build folder of the project, so build the marked images in a separate worktree. +## Companion frame sweep (step 10) + +`companion_sweep.py` sends the frames of the host frame sweep +(`test/test_companion_frames/frame_sweep.golden`, the all zero pass) to a USB +companion and checks that the board answers as the host harness does: every +frame the host refuses, every length of the commands that only report, and four +lengths of every code that is not a command. Commands that would store, transmit, +restart or format are never sent at an accepted length; the docstring lists them. +About 6,800 frames, 12 minutes on a T-Echo. + +```sh +python3 companion_sweep.py +``` + ## Diagnostic build for step 2 `diag-tuning.patch` adds a `diag tuning` command to the room server and the diff --git a/tools/refactoring/companion_sweep.py b/tools/refactoring/companion_sweep.py new file mode 100644 index 00000000000..8f13ff912b6 --- /dev/null +++ b/tools/refactoring/companion_sweep.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Replays the host frame sweep against a USB companion and compares the replies. + + companion_sweep.py [golden] + +The host suite test/test_companion_frames records, for every command code and +every frame length, what the companion answers to a frame of zeros +(test/test_companion_frames/frame_sweep.golden, the lines of the "00" pass). This +tool sends the same frames to a real board over the USB companion interface and +checks that the board answers the same way, so the host harness is shown to model +the firmware, and a later restructuring can be checked on the board with the same +reference. + +Only frames that cannot change the board are sent: + +- every frame the golden file says is refused (an error reply and no side effect), + which covers every length below every command's minimum; +- every length of the commands in READ_ONLY, which only report; +- lengths 1, 2, 50 and 176 of every code that is not a command. + +Commands that store, send on air, restart, format or keep session state are never +sent at a length the host accepts. Replies are compared byte for byte where they +are node independent (errors and OK); for the report commands only the reply code +is compared, since their content is the board's own (its key, its clock, its +contacts). Private key export is left out on purpose. +""" +import os +import re +import struct +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import nodetool # noqa: E402 + +GOLDEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "../../test/test_companion_frames/frame_sweep.golden") + +# Commands that report and change nothing, at any length, with zeros as arguments. +READ_ONLY = { + 1, # APP_START + 5, # GET_DEVICE_TIME + 6, # SET_DEVICE_TIME: zero is in the past, so it is refused + 11, # SET_RADIO_PARAMS: zeros are out of range, refused + 16, # SHARE_CONTACT: the zero key finds a reserved slot, which has no advert + 17, # EXPORT_CONTACT: self advert, returned and not sent + 18, # IMPORT_CONTACT: zeros are no advert, refused + 20, # GET_BATT_AND_STORAGE + 22, # DEVICE_QUERY + 28, # HAS_CONNECTION + 30, # GET_CONTACT_BY_KEY + 31, # GET_CHANNEL + 35, # SIGN_FINISH: nothing started, refused + 40, # GET_CUSTOM_VARS + 41, # SET_CUSTOM_VAR: no separator, refused + 42, # GET_ADVERT_PATH + 43, # GET_TUNING_PARAMS + 56, # GET_STATS + 59, # GET_AUTOADD_CONFIG + 60, # GET_ALLOWED_REPEAT_FREQ + 62, # SEND_CHANNEL_DATA: data type 0 is reserved, refused + 64, # GET_DEFAULT_FLOOD_SCOPE + 66, # RUN_CLI_COMMAND: empty command, "Unknown command" +} + +# Never sent above their minimum, whatever the golden file says: they store, send, +# restart, format, hold session state, or (23) hand out the private key. 13 and 15 +# are here because a key of zeros is not "no contact": the eight reserved slots at +# the start of the contact table are all zeros, lookupContactByPubKey() finds them, +# and REMOVE_CONTACT then removes one (RESET_PATH changes one). +NEVER = {2, 3, 4, 7, 8, 9, 10, 12, 13, 14, 15, 19, 21, 23, 24, 25, 26, 27, 29, 32, 33, + 34, 36, 37, 38, 39, 50, 51, 52, 54, 55, 57, 58, 61, 63, 65} + +COMMAND_CODES = set(range(1, 44)) | set(range(50, 53)) | set(range(54, 67)) + + +def load_golden(path): + """{(code, length): reply hex of the first frame, or None for no reply}, pass "00".""" + out = {} + for line in open(path): + m = re.match(r"([0-9a-f]{2}) 00 (\d+)(?:-(\d+))?: (.*)$", line.strip()) + if not m: + continue + code, lo = int(m.group(1), 16), int(m.group(2)) + hi = int(m.group(3) or lo) + rest = m.group(4) + flags = re.search(r"\[(\w+)\]$", rest) + replies = re.sub(r"\s*\[\w+\]$", "", rest).split() + first = None if replies == ["(none)"] else replies[0] + for n in range(lo, hi + 1): + out[(code, n)] = (first, flags.group(1) if flags else "") + return out + + +def selected(golden): + for (code, n), (first, flags) in sorted(golden.items()): + refused = first is not None and first.startswith("01") and not flags + if code not in COMMAND_CODES: + if n in (1, 2, 50, 176): + yield code, n, first, True + elif refused: + yield code, n, first, True + elif code in READ_ONLY and code not in NEVER and not flags: + exact = first == "00" + yield code, n, first, exact + + +def read_frame(fd, timeout=2.0, want_push=False): + """The next radio->app frame ('>' len16 payload); pushes (0x80 and up) are skipped + unless one is expected.""" + buf, end = b"", time.time() + timeout + while time.time() < end: + buf += nodetool.read_for(fd, 0.02) + while True: + i = buf.find(b">") + if i < 0 or len(buf) < i + 3: + break + n = struct.unpack(" 2 else GOLDEN) + frames = list(selected(golden)) + fd = nodetool.comp_open(sys.argv[1]) + stats = {"sent": 0, "same": 0, "differ": 0, "no reply": 0} + per_kind = {"exact": 0, "code": 0} + t0 = time.time() + for code, n, first, exact in frames: + expect = bytes.fromhex(first) + nodetool.read_for(fd, 0.005) + payload = bytes([code]) + bytes(n - 1) + os.write(fd, b"<" + struct.pack("= 0x80) + stats["sent"] += 1 + if got is None: + stats["no reply"] += 1 + print(f"code {code:3d} len {n:3d}: no reply, expected {first}", flush=True) + continue + same = (got == expect) if exact else (got[:1] == expect[:1]) + per_kind["exact" if exact else "code"] += 1 + if same: + stats["same"] += 1 + else: + stats["differ"] += 1 + print(f"code {code:3d} len {n:3d}: got {got[:24].hex()} expected {first[:48]}", flush=True) + os.close(fd) + print(f"{stats['sent']} frames in {time.time() - t0:.0f} s: {stats['same']} as on the host, " + f"{stats['differ']} different, {stats['no reply']} without reply " + f"(compared byte for byte: {per_kind['exact']}, by reply code: {per_kind['code']})") + codes = sorted({c for c, _, _, _ in frames}) + print(f"codes covered: {len(codes)}") + return 0 if stats["differ"] == 0 and stats["no reply"] == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) From e0aa8d79bae74b5c460ef8c9892840434a012b29 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 18:55:41 +0200 Subject: [PATCH 104/136] docs(refactoring): the design note for the companion command table (0010) Inventory of every command code (minimum length, further conditions, replies, the answer to a short frame), the table design for step 10b (one row per code with its minimum, a handler method per command that can decline, the central check answering ERR_CODE_UNSUPPORTED_CMD exactly as the chain does today), the rejected alternatives, the cut plan 10a to 10d, the overlap with 19 open upstream pull requests, and the verification of step 10a: host harness, mutation table, hardware sweep on a T-Echo. Also records three findings that are not changed here: a zero key names a reserved contact slot, CMD_SET_CHANNEL stores uninitialised stack bytes behind the channel name, and the len < 4 check of CMD_SEND_CHANNEL_DATA cannot be observed. --- .../0010-companion-command-table.md | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/refactoring/0010-companion-command-table.md diff --git a/docs/refactoring/0010-companion-command-table.md b/docs/refactoring/0010-companion-command-table.md new file mode 100644 index 00000000000..7830e320abe --- /dev/null +++ b/docs/refactoring/0010-companion-command-table.md @@ -0,0 +1,407 @@ +# 0010: A command table for the companion protocol + +Status: step 10a (host harness and characterization) ready for review; step 10b +(the table itself) designed here, not yet built. Behaviour change: none. + +## Context + +The companion firmware speaks to the app (BLE, USB serial, WiFi) in frames of up +to `MAX_FRAME_SIZE` (176) bytes. The first byte selects the command. +`MyMesh::handleCmdFrame()` (`examples/companion_radio/MyMesh.cpp`, about 1,020 +lines) is one chain of 62 branches of the form + +```c +} else if (cmd_frame[0] == CMD_X && len >= N && ) { + ... reads cmd_frame[i] at fixed offsets ... +``` + +and a final `else` that answers `ERR_CODE_UNSUPPORTED_CMD`. Each branch states +its own minimum length, or forgets to. Two fixes in this fork came from exactly +that: `docs/fixes/companion-channel-txt-min-len.md` (a 3 byte frame stopped the +node) and `docs/fixes/companion-cmd-frame-min-len.md` (fourteen branches acted on +the previous frame's bytes). The architecture review lists the function as F5. + +Three things make the function expensive to extend: + +- The minimum length of a command is not data. Nothing can list, test or check + the minimums, and a new branch can leave its own out. +- The command codes are only reachable through the chain, so the protocol cannot + be enumerated (for documentation, a test sweep or a client generator). +- The branches cannot be called on their own, so none of it was testable on the + host until this step. + +## Inventory + +Every code the chain handles, read from the source on `integration` (which has +both length fixes) and checked against the frame sweep of step 10a. "Short" is +what a frame below the minimum gets today. All lengths include the code byte. + +| Code | Command | Min | Further condition | Reply | Short | +|---:|---|---:|---|---|---| +| 1 | `APP_START` | 8 | | `SELF_INFO` | unsupported | +| 2 | `SEND_TXT_MSG` | 14 | | `SENT`, or `NOT_FOUND`, `UNSUPPORTED` for a txt type | unsupported | +| 3 | `SEND_CHANNEL_TXT_MSG` | 7 | | OK, `NOT_FOUND`, `UNSUPPORTED` for a txt type | unsupported | +| 4 | `GET_CONTACTS` | 1 | `since` from 5 bytes | `CONTACTS_START`, then one `CONTACT` per loop, then `END_OF_CONTACTS` | | +| 5 | `GET_DEVICE_TIME` | 1 | | `CURR_TIME` | | +| 6 | `SET_DEVICE_TIME` | 5 | | OK, `ILLEGAL_ARG` if earlier | unsupported | +| 7 | `SEND_SELF_ADVERT` | 1 | flood if byte 1 is 1 | OK, `TABLE_FULL` | | +| 8 | `SET_ADVERT_NAME` | 2 | | OK | unsupported | +| 9 | `ADD_UPDATE_CONTACT` | 136 | lat, lon, lastmod optional | OK, `TABLE_FULL` | unsupported | +| 10 | `SYNC_NEXT_MESSAGE` | 1 | | queued frame or `NO_MORE_MESSAGES` | | +| 11 | `SET_RADIO_PARAMS` | 11 | repeat flag optional | OK, `ILLEGAL_ARG` | unsupported | +| 12 | `SET_RADIO_TX_POWER` | 2 | | OK, `ILLEGAL_ARG` | unsupported | +| 13 | `RESET_PATH` | 33 | | OK, `NOT_FOUND` | unsupported | +| 14 | `SET_ADVERT_LATLON` | 9 | altitude optional, ignored | OK, `ILLEGAL_ARG` | unsupported | +| 15 | `REMOVE_CONTACT` | 33 | | OK, `NOT_FOUND` | unsupported | +| 16 | `SHARE_CONTACT` | 33 | | OK, `TABLE_FULL`, `NOT_FOUND` | unsupported | +| 17 | `EXPORT_CONTACT` | 1 | below 33 exports self | `EXPORT_CONTACT`, `NOT_FOUND`, `TABLE_FULL` | | +| 18 | `IMPORT_CONTACT` | 99 | | OK, `ILLEGAL_ARG` | unsupported | +| 19 | `REBOOT` | 7 | bytes 1 to 6 are `reboot` | none, the board restarts | unsupported | +| 20 | `GET_BATT_AND_STORAGE` | 1 | | `BATT_AND_STORAGE` | | +| 21 | `SET_TUNING_PARAMS` | 9 | | OK | unsupported | +| 22 | `DEVICE_QUERY` | 2 | | `DEVICE_INFO` | unsupported | +| 23 | `EXPORT_PRIVATE_KEY` | 1 | build option | `PRIVATE_KEY` or `DISABLED` | | +| 24 | `IMPORT_PRIVATE_KEY` | 65 | build option | OK, `ILLEGAL_ARG`, `FILE_IO_ERROR`, `DISABLED` | unsupported | +| 25 | `SEND_RAW_DATA` | 6 | path plus 4 payload bytes, else `ILLEGAL_ARG`; flood path unsupported | OK, `TABLE_FULL` | unsupported | +| 26 | `SEND_LOGIN` | 33 | | `SENT`, `NOT_FOUND`, `TABLE_FULL` | unsupported | +| 27 | `SEND_STATUS_REQ` | 33 | | `SENT`, `NOT_FOUND`, `TABLE_FULL` | unsupported | +| 28 | `HAS_CONNECTION` | 33 | | OK, `NOT_FOUND` | unsupported | +| 29 | `LOGOUT` | 33 | | OK | unsupported | +| 30 | `GET_CONTACT_BY_KEY` | 33 | | `CONTACT`, `NOT_FOUND` | unsupported | +| 31 | `GET_CHANNEL` | 2 | | `CHANNEL_INFO`, `NOT_FOUND` | unsupported | +| 32 | `SET_CHANNEL` | 50 | 66 and more: unsupported (256 bit secret) | OK, `NOT_FOUND` | unsupported | +| 33 | `SIGN_START` | 1 | | `SIGN_START` | | +| 34 | `SIGN_DATA` | 2 | | OK, `BAD_STATE`, `TABLE_FULL` | unsupported | +| 35 | `SIGN_FINISH` | 1 | | `SIGNATURE`, `BAD_STATE` | | +| 36 | `SEND_TRACE_PATH` | 11 | path length below `MAX_PACKET_PAYLOAD - 5`; a multiple of the hash size | `SENT`, `ILLEGAL_ARG`, `TABLE_FULL` | unsupported | +| 37 | `SET_DEVICE_PIN` | 5 | | OK, `ILLEGAL_ARG` | unsupported | +| 38 | `SET_OTHER_PARAMS` | 2 | bytes 2 to 4 optional | OK | unsupported | +| 39 | `SEND_TELEMETRY_REQ` | 4 | exactly 4: self; 36 and more: a peer; 5 to 35: unsupported | `TELEMETRY_RESPONSE` push, or `SENT`, `NOT_FOUND` | unsupported | +| 40 | `GET_CUSTOM_VARS` | 1 | | `CUSTOM_VARS` | | +| 41 | `SET_CUSTOM_VAR` | 4 | | OK, `ILLEGAL_ARG` | unsupported | +| 42 | `GET_ADVERT_PATH` | 34 | | `ADVERT_PATH`, `NOT_FOUND` | unsupported | +| 43 | `GET_TUNING_PARAMS` | 1 | | `TUNING_PARAMS` | | +| 50 | `SEND_BINARY_REQ` | 34 | | `SENT`, `NOT_FOUND`, `TABLE_FULL` | unsupported | +| 51 | `FACTORY_RESET` | 6 | bytes 1 to 5 are `reset` | OK, then restart; `FILE_IO_ERROR` | unsupported | +| 52 | `SEND_PATH_DISCOVERY_REQ` | 34 | byte 1 is 0 | `SENT`, `NOT_FOUND`, `TABLE_FULL` | unsupported | +| 54 | `SET_FLOOD_SCOPE_KEY` | 2 | byte 1: 0 sets (key from 18 bytes, else clears), 1 unscoped, others unsupported | OK | unsupported | +| 55 | `SEND_CONTROL_DATA` | 2 | high bit of byte 1 set | OK, `TABLE_FULL` | unsupported | +| 56 | `GET_STATS` | 2 | sub type 0, 1, 2; others `ILLEGAL_ARG` | `STATS` | unsupported | +| 57 | `SEND_ANON_REQ` | 34 | | `SENT`, `TABLE_FULL` | unsupported | +| 58 | `SET_AUTOADD_CONFIG` | 2 | max hops optional | OK | unsupported | +| 59 | `GET_AUTOADD_CONFIG` | 1 | | `AUTOADD_CONFIG` | | +| 60 | `GET_ALLOWED_REPEAT_FREQ` | 1 | | `ALLOWED_REPEAT_FREQ` | | +| 61 | `SET_PATH_HASH_MODE` | 3 | byte 1 is 0 | OK, `ILLEGAL_ARG` | unsupported | +| 62 | `SEND_CHANNEL_DATA` | 1 | checks its own length: below 4, or no room for `data_type` behind the path: `ILLEGAL_ARG` | OK, `NOT_FOUND`, `ILLEGAL_ARG`, `TABLE_FULL` | (`ILLEGAL_ARG`) | +| 63 | `SET_DEFAULT_FLOOD_SCOPE` | 1 | below 48 clears the scope | OK, `ILLEGAL_ARG` | | +| 64 | `GET_DEFAULT_FLOOD_SCOPE` | 1 | | `DEFAULT_FLOOD_SCOPE` | | +| 65 | `SEND_RAW_PACKET` | 4 | | OK, `ILLEGAL_ARG`, `TABLE_FULL` | unsupported | +| 66 | `RUN_CLI_COMMAND` | 3 | | `CLI_REPLY` | unsupported | + +Codes 0, 44 to 49, 53 and 67 to 255 are not commands and are answered +`ERR_CODE_UNSUPPORTED_CMD` at every length. + +Observations that the inventory makes visible, recorded and not changed: + +- "Too short" and "not a command" are the same answer for 54 codes. Only + `SEND_CHANNEL_DATA` answers `ILLEGAL_ARG`. The minimums are therefore not + observable as such by a client, which is why the table below may not change + the answer. +- Three codes split into sub commands by length or by byte 1 (39, 32, 54), and + four more refuse a frame by a byte value after the length test (19, 51, 52, + 55, 61). A refusal there is also `UNSUPPORTED`, so the table needs a way for a + handler to say "not mine" and fall through to the same answer. +- A key of 32 zero bytes is not "no contact". The contact table starts with + eight slots reserved for anonymous requests, zeroed by `resetContacts()`, and + `lookupContactByPubKey()` searches them too. `REMOVE_CONTACT` with a zero key + therefore answers OK and removes a reserved slot, eight times in a row, and + `GET_CONTACTS` then reports `num_contacts - 8`: on an empty node the count + wraps to `0xFFFFFFFF`, on the board of the hardware check it dropped from 350 + to 342. The slots return on the next boot, and the stored contacts are not + affected (`saveContacts()` skips type `ADV_TYPE_NONE`); both were checked on + the board. Pinned in `AZeroKeyNamesAReservedSlot`, not changed here. +- Senders in this workspace were checked against the minimums: the KiekR command + builders (`core-lib/src/companion/cmd.rs`), `docs/companion_protocol.md` and + `tools/refactoring/nodetool.py`. None sends below a minimum, none depends on a + fall through. Official apps and meshcore-cli are not in the workspace and were + not checked; for them the length fixes, not this step, are the change that + matters, and this step keeps their minimums byte for byte. + +## Decision + +### Step 10b: one row per command + +`MyMesh` gets a table, a private static member so its rows can name private +methods: + +```c++ +struct CmdHandler { + uint8_t code; + uint8_t min_len; // whole frame, code byte included + bool (MyMesh::*handle)(size_t len); // false: "not this command", falls through +}; +static const CmdHandler CMD_TABLE[]; +``` + +`handleCmdFrame()` becomes the dispatcher: + +```c++ +void MyMesh::handleCmdFrame(size_t len) { + for (const CmdHandler& c : CMD_TABLE) { + if (c.code != cmd_frame[0]) continue; + if (len >= c.min_len && (this->*c.handle)(len)) return; + break; + } + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]); +} +``` + +Each branch body moves, unchanged apart from the outer test, into a method +`cmdAppStart(size_t len)` and so on, which returns `true`. Branches with a further +condition test it first and return `false`, which reproduces the fall through: + +```c++ +bool MyMesh::cmdSetPathHashMode(size_t len) { + if (cmd_frame[1] != 0) return false; // was: && cmd_frame[1] == 0 + ... + return true; +} +``` + +The codes with two branches (39, 32, 54) get one row and one method that +contains both, in the old order. The minimum in the row is the smaller of the +two, and the method returns `false` where neither branch matched. + +How the minimum is enforced: in one place, before any handler runs, with the +answer the chain gives today. A command without a minimum has 1 in its row +(`checkRecvFrame()` never returns 0). `SEND_CHANNEL_DATA` keeps 1 and its own +`ILLEGAL_ARG` checks, because that is its answer today. + +A new command is then one method and one row, and a forgotten minimum is a +visible `1` in a table rather than an absent clause. + +### Cost on the targets + +To be measured in 10b on the full companion matrix (nRF52, ESP32, RP2040, STM32, +BLE, USB, WiFi) against `integration`. Expected from the shapes involved, not +measured: on ARM a pointer to member function is 8 bytes, so the table is 61 +rows of 12 bytes, about 730 bytes of flash and no RAM (the table is `const`). +The chain itself shrinks, since the 62 comparisons disappear. Each handler gains +a prologue and an epilogue, and GCC may stop sharing code between branches that +used to be one function. A net change of up to 2 kB of flash on nRF52 would be +acceptable (the RAK 4631 BLE companion uses 462 kB of 796 kB); more than that, +and the cheaper variant below is the fallback. + +### Steps + +| Step | Content | Behaviour | +|---|---|---| +| 10a | Host harness for `MyMesh.cpp`, frame sweep, per command tests (this change) | none | +| 10b | `CMD_TABLE` and one method per command, bodies moved unchanged | none, the sweep must stay byte identical | +| 10c | A bounded frame reader (`FrameReader r(cmd_frame, len); r.u8(); r.u32(); r.bytes(n)`) in the handlers, replacing the hand counted offsets | none; a reader that runs out reports it instead of reading the tail | +| 10d | Optional, a protocol change: answer a too short frame with `ILLEGAL_ARG` | visible to apps, needs its own proposal | + +10b and 10c are kept apart because 10b is a pure move whose diff can be read with +`git diff --color-moved`, while 10c rewrites the parsing in every handler and +needs a reviewer to read each one. + +## Alternatives considered + +- **A `switch` on the code, plus a separate array of minimums.** Cheaper in + flash (a jump table, no member pointers), but the code list then exists twice, + in the `switch` and in the array, and they can disagree. Kept as the fallback + if the measured cost of the table is too high. +- **Only a minimum length table in front of the existing chain**, leaving the 62 + branches in place. The smallest diff, and the guard would be central. Rejected + as the end state: the codes are still not enumerable, the bodies still cannot be + tested or called on their own, and every branch keeps a length clause that now + has to agree with the table. +- **Static functions taking `MyMesh&`.** Four byte pointers instead of eight, but + every body would change from `_prefs` to `self._prefs`, which turns a move into + a rewrite. Member functions keep the bodies textually identical. +- **An X macro list of commands.** Generates the table and the declarations from + one list, but is harder to read and to step through in a debugger than a plain + array, for the saving of one declaration per command. +- **Answer `ILLEGAL_ARG` from the central check.** Rejected for this series: it + changes the answer of 54 commands, which the length fixes already rejected for + the same reason. Listed as 10d. +- **The frame reader first (10c before 10b).** It fixes the class of bug more + directly, but it rewrites every branch inside a 1,000 line function that cannot + be reviewed piece by piece. Moving first makes each handler a unit the reader + step can change and test one at a time. + +## Upstream overlap + +`upstream/dev` was 428a95c6 on 20 October 2026, the same as this fork's base. No +upstream pull request proposes a table, a dispatcher or central length +validation for `handleCmdFrame()`. 19 open pull requests change lines inside it, +mostly 2 to 32 lines each; 10b moves every one of those lines, so each of them +would need to be re-applied to the method its branch moved to. The largest are +meshcore-dev/MeshCore#3523 (single key DM), #2964 (chunked contacts), #2682 +(ThinkNode M4) and #3638 (dual radio profiles). Some need the table's shape +specifically: #2843 and #2670 add optional trailing bytes (a minimum, not an +exact length, which the table has), #2717 and #2682 add commands (both claim code +66, which upstream itself has to resolve; this fork already has 66 as +`RUN_CLI_COMMAND`), and #3454 adds a `GET_STATS` sub type (a sub dispatch inside +one handler, which the table allows). #3643 is the channel text length guard of +this fork, already in `integration`. + +Step 10a touches none of those lines: it adds tests and a native environment. + +## Verification of step 10a + +### What the harness is + +`[env:native_companion]` builds the real `MyMesh.cpp` and `DataStore.cpp` with the +core, the chat layer and the helpers they use, against the mocks in `test/mocks` +(in-memory file system, radio, board, clock, sensor manager). Two mocks gained +what the companion needs: `FSInfo` and a directory listing in `FS.h`, and the +declaration of `radio_new_identity()` in `target.h`. The build options are the +ones companion environments set: `MAX_CONTACTS` and `MAX_GROUP_CHANNELS` for the +whole build, and private key export and import compiled in. + +`MAX_CONTACTS` for the whole build is not cosmetic. With it only in `MyMesh.h`, +as a first version of the harness had it, `BaseChatMesh.cpp` was compiled with 32 +contacts and `MyMesh.cpp` with 100, the two disagreed about the layout of +`BaseChatMesh`, and `GET_CONTACTS` reported zero contacts after two were added. +Every firmware environment sets it in its build flags. + +A test drives the node only through the protocol: a fake serial interface hands +`MyMesh::loop()` one frame, and the test gets every frame written back. State +that the protocol does not report is observed on the collaborators: what was +written to flash, which packets were queued for the air, whether the board +restarted, whether the radio or the clock were set. Each node is built in zeroed +storage, which is how a global starts on a board, so the bytes above a frame's +length are the same on every run. + +### The tests + +- `test_frame_sweep.cpp`: for each code 0 to 255, each length 1 to 176 and five + fills (four byte values, and zeros after a full length frame of `0x5A`, so a + read above `len` would show), a freshly booted node gets one frame. The replies, + byte for byte, and the side effects form one line; consecutive lengths with the + same line fold into a range, and one more line per code and fill carries a + digest of every packet queued and every file stored. 225,280 frames, compared + with `frame_sweep.golden` (about 2,000 lines). The golden file was checked for + hidden state by generating it once with the codes in ascending and once in + descending order; the two are identical. A first run was not: the global mock + clock kept the last unique timestamp of the previous node, so a request's tag + depended on the order. The harness now resets the clock completely. +- `test_commands.cpp`: 49 tests that send the frames an app sends and check the + meaning: the fields of each reply, the state a setter changed (read back through + the protocol where it can be), the boundary at each minimum with a real frame, + and the sub commands. + +The sweep also scrubs the stack with a fixed byte before each frame. Without +that, a run of the sweep after the other tests disagreed with a run on its own, +and the difference was real: `CMD_SET_CHANNEL` builds its record in a +`ChannelDetails` on the stack, `StrHelper::strncpy()` copies the name only up to +its terminator, and `DataStore::saveChannels()` writes all 32 name bytes, so up to +31 bytes of whatever the stack held go to `/channels2`, on a board as well. +Nothing reads them back (the name is used as a C string, and `GET_CHANNEL` copies +it with `strcpy`), so no reply and no packet carries them; it is flash content +only. Recorded here and not changed. The sweep zeroes those bytes before taking +its digest and nothing else. + +The golden file says nothing about whether today's behaviour is right; it records +it. Places where it looks odd are named in the tests and above (for example, a +contact added through the protocol cannot be shared, because sharing resends a +stored advert and there is none). + +### Mutation tests + +Each fault was put into `MyMesh.cpp` on its own, the suite run, the file +restored: + +| Mutation | Caught by | +|---|---| +| `SET_TUNING_PARAMS` minimum 9 becomes 8 | sweep, `TuningParamsRoundTrip` | +| `ADD_UPDATE_CONTACT` minimum 136 becomes 135 | sweep, `AddContactNeedsEveryMandatoryField` | +| `SEND_CHANNEL_DATA` `len < 4` becomes `len < 3` | not caught, equivalent (below) | +| `SET_PATH_HASH_MODE` loses its `cmd_frame[1] == 0` | sweep, `PathHashMode` | +| self telemetry `len == 4` becomes `len >= 4` | sweep, `TelemetryHasTwoForms` | +| the final `else` answers `ILLEGAL_ARG` | sweep and 38 command tests | +| the 66 byte `SET_CHANNEL` branch moves behind the 50 byte one | sweep, `Channels` | +| `SEND_LOGIN` no longer terminates the password at `len` | sweep only (the packet digest) | +| unscoped `SET_FLOOD_SCOPE_KEY` needs 3 bytes | sweep, `FloodScopeKey` | +| `SET_TUNING_PARAMS` stores `rx` into the airtime factor | `TuningParamsRoundTrip` only | + +The third is an equivalent mutant: with `len == 3` the branch goes on to its +second check, whether the two `data_type` bytes fit behind the path, which fails +for every path length and answers the same `ILLEGAL_ARG`. The `len < 4` test is +covered by that second one and cannot be observed on its own. The last two rows +are why both suites exist: the sweep sends zeros, under which `rx` and `af` are +the same, and the command tests do not look at packet contents. + +### Host suites + +All host environments, on the committed tree, including the new one: + +``` +native_companion test_companion_frames PASSED +... (the other 21 suites in 13 environments) PASSED +================ 404 test cases: 404 succeeded ================ +``` + +`native_companion` is added to `.github/workflows/run-unit-tests.yml`, which +names its environments explicitly. + +### Builds + +Step 10a changes no firmware source: its diff against its base, limited to +`src`, `examples` and `variants`, is empty. The changes to shared test files (`test/mocks/FS.h`, +`test/mocks/target.h`) only add members and a declaration, and every existing +host suite passes unchanged. The fork CI builds its 15 firmware environments on +the pull request. + +### Hardware + +The point of the hardware check for 10a is to show that the host harness models +the firmware, so that 10b can rely on it. LilyGo T-Echo `techo-2`, a companion +with 350 contacts and 40 channels, normally on the official 1.18.0 BLE image. The +USB companion image was built from the base of this change (which has the same +firmware source) with the marker `c10a-int`, and `ver` answered +`v1.18.0 (Build: c10a-int)` before the runs. + +`tools/refactoring/companion_sweep.py` sends the frames of the golden file's all +zero pass that cannot change the board: every frame the host refuses, every +length of the commands that only report, and four lengths of every code that is +not a command. Error and OK replies are compared byte for byte; for report +commands, whose content is the board's own, the reply code is compared. + +``` +6502 frames in 729 s: 6502 as on the host, 0 different, 0 without reply +(compared byte for byte: 4083, by reply code: 2419) +codes covered: 251 +``` + +The first run was not clean, and that is how the zero key finding above came to +light: the tool still counted `REMOVE_CONTACT` with a zero key as harmless. On +the host every frame meets a freshly booted node, so each one removes the first +reserved slot and answers OK. On the board the frames meet the same node one +after another: lengths 33 to 40 answered OK and removed the eight slots, every +later frame answered `NOT_FOUND`, and `SHARE_CONTACT` and `GET_CONTACT_BY_KEY` +with a zero key then found nothing either. Those were 424 of 6,790 frames, all in +codes 15, 16 and 30, and `GET_CONTACTS` reported 342 contacts. After a reboot it +reported 350 again and every value below was as before. The tool now leaves +`REMOVE_CONTACT` and `RESET_PATH` out, and the second run, after the reboot, is +the one above. + +State before the runs, after the reboot and after the second run, read through +the protocol: self info (key, name, radio, TX power, location, telemetry modes), +device info (including the BLE PIN and path hash mode), tuning parameters, +auto add configuration, default flood scope and key, all 40 channels (digest), +contact count 350. All three readings identical. + +Afterwards the official image was flashed back +(`LilyGo_T-Echo_companion_radio_ble-v1.18.0-81dbae5.zip`, SHA-256 prefix +`6d602a54`, the release digest), and the phone that uses this board as its +companion reconnected over BLE 17 seconds later. + +Not covered on hardware: frames the host accepts for commands that store, +transmit, restart or format (setters, sends, `REBOOT`, `FACTORY_RESET`, private +key import, contact and channel changes). For those the host tests are the +evidence; the length fixes recorded hardware runs for some of them. Only +nRF52 was run; ESP32 and RP2040 companions compile the same `MyMesh.cpp`, and +their build is checked by CI, not their behaviour. From 7fda7eabb6bdf10ab38426d12274c86c2d5c5348 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 19:12:00 +0200 Subject: [PATCH 105/136] refactor(companion): dispatch the frame protocol through a command table handleCmdFrame() was one chain of 62 branches of the form `cmd_frame[0] == CMD_X && len >= N && ...`, each with its own minimum frame length. It is now a table with one row per command code (59 codes): the code, the shortest frame the command accepts, and a handler method. The dispatcher checks the minimum in one place and answers ERR_CODE_UNSUPPORTED_CMD for a frame below it, for a code without a row, and for a handler that returns false, which is exactly what the chain's final else answered in each of those cases. The branch bodies moved unchanged into the handlers (git diff --color-moved shows them as moves); only the branch heads, the final else and three early `return;` (now `return true;`) have no counterpart. A further condition of a branch (a byte value, the reboot and reset words, the control data flag, the trace path's upper bound) becomes the handler's first line, returning false. The three codes that had two branches (39 telemetry, 32 set channel, 54 flood scope key) keep both, in the old order, in one handler; their row carries the smaller minimum. CMD_SEND_CHANNEL_DATA keeps minimum 1 and its own ILLEGAL_ARG checks, because that is its answer today. No byte on the wire changes. The 10a characterization passes unchanged: the frame sweep (225,280 frames against frame_sweep.golden, packets and files included) and the 49 command tests. Eight mutations of the new structure (row minimums, a missing row, the dispatcher ignoring a declining handler or the minimum, handlers declining wrongly) are all caught. See docs/refactoring/0010-companion-command-table.md. --- examples/companion_radio/MyMesh.cpp | 2002 +++++++++++++++------------ examples/companion_radio/MyMesh.h | 67 + 2 files changed, 1206 insertions(+), 863 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a0ce75dee87..a823b19623c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1025,257 +1025,475 @@ void MyMesh::startInterface(BaseSerialInterface &serial) { serial.enable(); } +// One row per command: the code, the shortest frame the command accepts (code byte +// included), and its handler. A frame below the minimum, a code without a row, and +// a handler that returns false ("not this command") all get the answer the if chain +// this table replaced gave them: ERR_CODE_UNSUPPORTED_CMD. +const MyMesh::CmdHandler MyMesh::CMD_TABLE[] = { + { CMD_DEVICE_QUERY, 2, &MyMesh::cmdDeviceQuery }, + { CMD_APP_START, 8, &MyMesh::cmdAppStart }, + { CMD_RUN_CLI_COMMAND, 3, &MyMesh::cmdRunCliCommand }, + { CMD_SEND_TXT_MSG, 14, &MyMesh::cmdSendTxtMsg }, + { CMD_SEND_CHANNEL_TXT_MSG, 7, &MyMesh::cmdSendChannelTxtMsg }, + { CMD_SEND_CHANNEL_DATA, 1, &MyMesh::cmdSendChannelData }, + { CMD_GET_CONTACTS, 1, &MyMesh::cmdGetContacts }, + { CMD_SET_ADVERT_NAME, 2, &MyMesh::cmdSetAdvertName }, + { CMD_SET_ADVERT_LATLON, 9, &MyMesh::cmdSetAdvertLatlon }, + { CMD_GET_DEVICE_TIME, 1, &MyMesh::cmdGetDeviceTime }, + { CMD_SET_DEVICE_TIME, 5, &MyMesh::cmdSetDeviceTime }, + { CMD_SEND_SELF_ADVERT, 1, &MyMesh::cmdSendSelfAdvert }, + { CMD_RESET_PATH, 1 + 32, &MyMesh::cmdResetPath }, + { CMD_ADD_UPDATE_CONTACT, 1 + PUB_KEY_SIZE + 3 + MAX_PATH_SIZE + 32 + 4, &MyMesh::cmdAddUpdateContact }, + { CMD_REMOVE_CONTACT, 1 + PUB_KEY_SIZE, &MyMesh::cmdRemoveContact }, + { CMD_SHARE_CONTACT, 1 + PUB_KEY_SIZE, &MyMesh::cmdShareContact }, + { CMD_GET_CONTACT_BY_KEY, 1 + PUB_KEY_SIZE, &MyMesh::cmdGetContactByKey }, + { CMD_EXPORT_CONTACT, 1, &MyMesh::cmdExportContact }, + { CMD_IMPORT_CONTACT, 2 + 32 + 64 + 1, &MyMesh::cmdImportContact }, + { CMD_SYNC_NEXT_MESSAGE, 1, &MyMesh::cmdSyncNextMessage }, + { CMD_SET_RADIO_PARAMS, 11, &MyMesh::cmdSetRadioParams }, + { CMD_SET_RADIO_TX_POWER, 2, &MyMesh::cmdSetRadioTxPower }, + { CMD_SET_TUNING_PARAMS, 9, &MyMesh::cmdSetTuningParams }, + { CMD_GET_TUNING_PARAMS, 1, &MyMesh::cmdGetTuningParams }, + { CMD_SET_OTHER_PARAMS, 2, &MyMesh::cmdSetOtherParams }, + { CMD_SET_PATH_HASH_MODE, 3, &MyMesh::cmdSetPathHashMode }, + { CMD_REBOOT, 7, &MyMesh::cmdReboot }, + { CMD_GET_BATT_AND_STORAGE, 1, &MyMesh::cmdGetBattAndStorage }, + { CMD_EXPORT_PRIVATE_KEY, 1, &MyMesh::cmdExportPrivateKey }, + { CMD_IMPORT_PRIVATE_KEY, 65, &MyMesh::cmdImportPrivateKey }, + { CMD_SEND_RAW_DATA, 6, &MyMesh::cmdSendRawData }, + { CMD_SEND_LOGIN, 1 + PUB_KEY_SIZE, &MyMesh::cmdSendLogin }, + { CMD_SEND_ANON_REQ, 1 + PUB_KEY_SIZE + 1, &MyMesh::cmdSendAnonReq }, + { CMD_SEND_STATUS_REQ, 1 + PUB_KEY_SIZE, &MyMesh::cmdSendStatusReq }, + { CMD_SEND_PATH_DISCOVERY_REQ, 2 + PUB_KEY_SIZE, &MyMesh::cmdSendPathDiscoveryReq }, + { CMD_SEND_TELEMETRY_REQ, 4, &MyMesh::cmdSendTelemetryReq }, + { CMD_SEND_BINARY_REQ, 2 + PUB_KEY_SIZE, &MyMesh::cmdSendBinaryReq }, + { CMD_HAS_CONNECTION, 1 + PUB_KEY_SIZE, &MyMesh::cmdHasConnection }, + { CMD_LOGOUT, 1 + PUB_KEY_SIZE, &MyMesh::cmdLogout }, + { CMD_GET_CHANNEL, 2, &MyMesh::cmdGetChannel }, + { CMD_SET_CHANNEL, 2 + 32 + 16, &MyMesh::cmdSetChannel }, + { CMD_SIGN_START, 1, &MyMesh::cmdSignStart }, + { CMD_SIGN_DATA, 1 + 1, &MyMesh::cmdSignData }, + { CMD_SIGN_FINISH, 1, &MyMesh::cmdSignFinish }, + { CMD_SEND_TRACE_PATH, 10 + 1, &MyMesh::cmdSendTracePath }, + { CMD_SET_DEVICE_PIN, 5, &MyMesh::cmdSetDevicePin }, + { CMD_GET_CUSTOM_VARS, 1, &MyMesh::cmdGetCustomVars }, + { CMD_SET_CUSTOM_VAR, 4, &MyMesh::cmdSetCustomVar }, + { CMD_GET_ADVERT_PATH, PUB_KEY_SIZE+2, &MyMesh::cmdGetAdvertPath }, + { CMD_GET_STATS, 2, &MyMesh::cmdGetStats }, + { CMD_FACTORY_RESET, 6, &MyMesh::cmdFactoryReset }, + { CMD_SET_FLOOD_SCOPE_KEY, 2, &MyMesh::cmdSetFloodScopeKey }, + { CMD_SET_DEFAULT_FLOOD_SCOPE, 1, &MyMesh::cmdSetDefaultFloodScope }, + { CMD_GET_DEFAULT_FLOOD_SCOPE, 1, &MyMesh::cmdGetDefaultFloodScope }, + { CMD_SEND_CONTROL_DATA, 2, &MyMesh::cmdSendControlData }, + { CMD_SET_AUTOADD_CONFIG, 2, &MyMesh::cmdSetAutoaddConfig }, + { CMD_GET_AUTOADD_CONFIG, 1, &MyMesh::cmdGetAutoaddConfig }, + { CMD_GET_ALLOWED_REPEAT_FREQ, 1, &MyMesh::cmdGetAllowedRepeatFreq }, + { CMD_SEND_RAW_PACKET, 4, &MyMesh::cmdSendRawPacket }, +}; + void MyMesh::handleCmdFrame(size_t len) { - if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection - app_target_ver = cmd_frame[1]; // which version of protocol does app understand + for (const CmdHandler& c : CMD_TABLE) { + if (c.code != cmd_frame[0]) continue; + if (len >= c.min_len && (this->*c.handle)(len)) return; + break; + } + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]); +} - int i = 0; - out_frame[i++] = RESP_CODE_DEVICE_INFO; - out_frame[i++] = FIRMWARE_VER_CODE; - out_frame[i++] = MAX_CONTACTS / 2; // v3+ - out_frame[i++] = MAX_GROUP_CHANNELS; // v3+ - memcpy(&out_frame[i], &_prefs.ble_pin, 4); - i += 4; - memset(&out_frame[i], 0, 12); - strcpy((char *)&out_frame[i], FIRMWARE_BUILD_DATE); - i += 12; - StrHelper::strzcpy((char *)&out_frame[i], board.getManufacturerName(), 40); - i += 40; - StrHelper::strzcpy((char *)&out_frame[i], FIRMWARE_VERSION, 20); - i += 20; - out_frame[i++] = _prefs.isRepeatEn() ? 1 : 0; // v9+ - out_frame[i++] = _prefs.path_hash_mode; // v10+ - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_APP_START && - len >= 8) { // sent when app establishes connection, respond with node ID - // cmd_frame[1..7] reserved future - char *app_name = (char *)&cmd_frame[8]; - cmd_frame[len] = 0; // make app_name null terminated - MESH_DEBUG_PRINTLN("App %s connected", app_name); - - _iter_started = false; // stop any left-over ContactsIterator - int i = 0; - out_frame[i++] = RESP_CODE_SELF_INFO; - out_frame[i++] = ADV_TYPE_CHAT; // what this node Advert identifies as (maybe node's pronouns too?? :-) - out_frame[i++] = _prefs.tx_power_dbm; - out_frame[i++] = MAX_LORA_TX_POWER; - memcpy(&out_frame[i], self_id.pub_key, PUB_KEY_SIZE); - i += PUB_KEY_SIZE; - - int32_t lat, lon; - lat = (sensors.node_lat * 1000000.0); - lon = (sensors.node_lon * 1000000.0); - memcpy(&out_frame[i], &lat, 4); - i += 4; - memcpy(&out_frame[i], &lon, 4); - i += 4; - out_frame[i++] = _prefs.multi_acks; // new v7+ - out_frame[i++] = _prefs.advert_loc_policy; - out_frame[i++] = (_prefs.telemetry_mode_env << 4) | (_prefs.telemetry_mode_loc << 2) | - (_prefs.telemetry_mode_base); // v5+ - out_frame[i++] = _prefs.manual_add_contacts; - - uint32_t freq = _prefs.freq * 1000; - memcpy(&out_frame[i], &freq, 4); - i += 4; - uint32_t bw = _prefs.bw * 1000; - memcpy(&out_frame[i], &bw, 4); - i += 4; - out_frame[i++] = _prefs.sf; - out_frame[i++] = _prefs.cr; +// sent when app establishes connection +bool MyMesh::cmdDeviceQuery(size_t) { + app_target_ver = cmd_frame[1]; // which version of protocol does app understand - int tlen = strlen(_prefs.node_name); // revisit: UTF_8 ?? - memcpy(&out_frame[i], _prefs.node_name, tlen); - i += tlen; - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_RUN_CLI_COMMAND && len >= 3) { // V14+ - int i = 1; + int i = 0; + out_frame[i++] = RESP_CODE_DEVICE_INFO; + out_frame[i++] = FIRMWARE_VER_CODE; + out_frame[i++] = MAX_CONTACTS / 2; // v3+ + out_frame[i++] = MAX_GROUP_CHANNELS; // v3+ + memcpy(&out_frame[i], &_prefs.ble_pin, 4); + i += 4; + memset(&out_frame[i], 0, 12); + strcpy((char *)&out_frame[i], FIRMWARE_BUILD_DATE); + i += 12; + StrHelper::strzcpy((char *)&out_frame[i], board.getManufacturerName(), 40); + i += 40; + StrHelper::strzcpy((char *)&out_frame[i], FIRMWARE_VERSION, 20); + i += 20; + out_frame[i++] = _prefs.isRepeatEn() ? 1 : 0; // v9+ + out_frame[i++] = _prefs.path_hash_mode; // v10+ + _serial->writeFrame(out_frame, i); + return true; +} + +// sent when app establishes connection, respond with node ID +bool MyMesh::cmdAppStart(size_t len) { + // cmd_frame[1..7] reserved future + char *app_name = (char *)&cmd_frame[8]; + cmd_frame[len] = 0; // make app_name null terminated + MESH_DEBUG_PRINTLN("App %s connected", app_name); + + _iter_started = false; // stop any left-over ContactsIterator + int i = 0; + out_frame[i++] = RESP_CODE_SELF_INFO; + out_frame[i++] = ADV_TYPE_CHAT; // what this node Advert identifies as (maybe node's pronouns too?? :-) + out_frame[i++] = _prefs.tx_power_dbm; + out_frame[i++] = MAX_LORA_TX_POWER; + memcpy(&out_frame[i], self_id.pub_key, PUB_KEY_SIZE); + i += PUB_KEY_SIZE; + + int32_t lat, lon; + lat = (sensors.node_lat * 1000000.0); + lon = (sensors.node_lon * 1000000.0); + memcpy(&out_frame[i], &lat, 4); + i += 4; + memcpy(&out_frame[i], &lon, 4); + i += 4; + out_frame[i++] = _prefs.multi_acks; // new v7+ + out_frame[i++] = _prefs.advert_loc_policy; + out_frame[i++] = (_prefs.telemetry_mode_env << 4) | (_prefs.telemetry_mode_loc << 2) | + (_prefs.telemetry_mode_base); // v5+ + out_frame[i++] = _prefs.manual_add_contacts; + + uint32_t freq = _prefs.freq * 1000; + memcpy(&out_frame[i], &freq, 4); + i += 4; + uint32_t bw = _prefs.bw * 1000; + memcpy(&out_frame[i], &bw, 4); + i += 4; + out_frame[i++] = _prefs.sf; + out_frame[i++] = _prefs.cr; + + int tlen = strlen(_prefs.node_name); // revisit: UTF_8 ?? + memcpy(&out_frame[i], _prefs.node_name, tlen); + i += tlen; + _serial->writeFrame(out_frame, i); + return true; +} + +// V14+ +bool MyMesh::cmdRunCliCommand(size_t len) { + int i = 1; + char *text = (char *)&cmd_frame[i]; + int tlen = len - i; + text[tlen] = 0; // ensure null + + reply_buf[0] = 0; + if (!handleCommand(text, 0, reply_buf)) { + strcat(reply_buf, "Unknown command"); // reply_buf may have cmd prefix from 'text' + } + out_frame[0] = RESP_CODE_CLI_REPLY; + int rlen = strlen(reply_buf); + memcpy(&out_frame[1], reply_buf, rlen); + _serial->writeFrame(out_frame, 1 + rlen); + return true; +} + +bool MyMesh::cmdSendTxtMsg(size_t len) { + int i = 1; + uint8_t txt_type = cmd_frame[i++]; + uint8_t attempt = cmd_frame[i++]; + uint32_t msg_timestamp; + memcpy(&msg_timestamp, &cmd_frame[i], 4); + i += 4; + uint8_t *pub_key_prefix = &cmd_frame[i]; + i += 6; + ContactInfo *recipient = lookupContactByPubKey(pub_key_prefix, 6); + if (recipient && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)) { char *text = (char *)&cmd_frame[i]; int tlen = len - i; + uint32_t est_timeout; text[tlen] = 0; // ensure null - - reply_buf[0] = 0; - if (!handleCommand(text, 0, reply_buf)) { - strcat(reply_buf, "Unknown command"); // reply_buf may have cmd prefix from 'text' + int result; + uint32_t expected_ack; + if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) { + msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection + result = sendCommandData(*recipient, msg_timestamp, attempt, txt_type, text, est_timeout); + expected_ack = 0; // no Ack expected + } else { + result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout); } - out_frame[0] = RESP_CODE_CLI_REPLY; - int rlen = strlen(reply_buf); - memcpy(&out_frame[1], reply_buf, rlen); - _serial->writeFrame(out_frame, 1 + rlen); - } else if (cmd_frame[0] == CMD_SEND_TXT_MSG && len >= 14) { - int i = 1; - uint8_t txt_type = cmd_frame[i++]; - uint8_t attempt = cmd_frame[i++]; - uint32_t msg_timestamp; - memcpy(&msg_timestamp, &cmd_frame[i], 4); - i += 4; - uint8_t *pub_key_prefix = &cmd_frame[i]; - i += 6; - ContactInfo *recipient = lookupContactByPubKey(pub_key_prefix, 6); - if (recipient && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)) { - char *text = (char *)&cmd_frame[i]; - int tlen = len - i; - uint32_t est_timeout; - text[tlen] = 0; // ensure null - int result; - uint32_t expected_ack; - if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) { - msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection - result = sendCommandData(*recipient, msg_timestamp, attempt, txt_type, text, est_timeout); - expected_ack = 0; // no Ack expected - } else { - result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout); + // TODO: add expected ACK to table + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); + } else { + if (expected_ack) { + expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); // add to circular table + expected_ack_table[next_ack_idx].ack = expected_ack; + expected_ack_table[next_ack_idx].contact = recipient; + next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; } - // TODO: add expected ACK to table - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - if (expected_ack) { - expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); // add to circular table - expected_ack_table[next_ack_idx].ack = expected_ack; - expected_ack_table[next_ack_idx].contact = recipient; - next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; - } - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &expected_ack, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } - } else { - writeErrFrame(recipient == NULL - ? ERR_CODE_NOT_FOUND - : ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsupported TXT_TYPE_* + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &expected_ack, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG && len >= 7) { // send GroupChannel text msg - // 7 = code, txt_type, channel_idx and the 4 byte timestamp; the text may be empty. - // Without this the text length below is computed as len - 7 and turns negative. - int i = 1; - uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN - uint8_t channel_idx = cmd_frame[i++]; - uint32_t msg_timestamp; - memcpy(&msg_timestamp, &cmd_frame[i], 4); - i += 4; - const char *text = (char *)&cmd_frame[i]; + } else { + writeErrFrame(recipient == NULL + ? ERR_CODE_NOT_FOUND + : ERR_CODE_UNSUPPORTED_CMD); // unknown recipient, or unsupported TXT_TYPE_* + } + return true; +} - if (txt_type != TXT_TYPE_PLAIN) { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); +// send GroupChannel text msg +bool MyMesh::cmdSendChannelTxtMsg(size_t len) { + // 7 = code, txt_type, channel_idx and the 4 byte timestamp; the text may be empty. + // Without this the text length below is computed as len - 7 and turns negative. + int i = 1; + uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN + uint8_t channel_idx = cmd_frame[i++]; + uint32_t msg_timestamp; + memcpy(&msg_timestamp, &cmd_frame[i], 4); + i += 4; + const char *text = (char *)&cmd_frame[i]; + + if (txt_type != TXT_TYPE_PLAIN) { + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + } else { + ChannelDetails channel; + bool success = getChannel(channel_idx, channel); + if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) { + writeOKFrame(); } else { - ChannelDetails channel; - bool success = getChannel(channel_idx, channel); - if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) { - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx - } - } - } else if (cmd_frame[0] == CMD_SEND_CHANNEL_DATA) { // send GroupChannel datagram - if (len < 4) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - return; + writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx } - int i = 1; - uint8_t channel_idx = cmd_frame[i++]; - uint8_t path_len = cmd_frame[i++]; + } + return true; +} - // validate path len, allowing 0xFF for flood - if (!mesh::Packet::isValidPathLen(path_len) && path_len != OUT_PATH_UNKNOWN) { - MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA invalid path size: %d", path_len); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - return; - } +// send GroupChannel datagram +bool MyMesh::cmdSendChannelData(size_t len) { + if (len < 4) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return true; + } + int i = 1; + uint8_t channel_idx = cmd_frame[i++]; + uint8_t path_len = cmd_frame[i++]; - // parse provided path if not flood - uint8_t path[MAX_PATH_SIZE]; - if (path_len != OUT_PATH_UNKNOWN) { - i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); - } + // validate path len, allowing 0xFF for flood + if (!mesh::Packet::isValidPathLen(path_len) && path_len != OUT_PATH_UNKNOWN) { + MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA invalid path size: %d", path_len); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return true; + } - if ((size_t)(i + 2) > len) { // the 2 byte data_type must be inside the frame - MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA frame too short for data_type: %d", (uint32_t)len); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - return; - } + // parse provided path if not flood + uint8_t path[MAX_PATH_SIZE]; + if (path_len != OUT_PATH_UNKNOWN) { + i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); + } - uint16_t data_type = ((uint16_t)cmd_frame[i]) | (((uint16_t)cmd_frame[i + 1]) << 8); - i += 2; - const uint8_t *payload = &cmd_frame[i]; - int payload_len = (len > (size_t)i) ? (int)(len - i) : 0; + if ((size_t)(i + 2) > len) { // the 2 byte data_type must be inside the frame + MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA frame too short for data_type: %d", (uint32_t)len); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return true; + } - ChannelDetails channel; - if (!getChannel(channel_idx, channel)) { - writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx - } else if (data_type == DATA_TYPE_RESERVED) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else if (payload_len > MAX_GROUP_DATA_LENGTH) { - MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else if (sendGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) { - writeOKFrame(); + uint16_t data_type = ((uint16_t)cmd_frame[i]) | (((uint16_t)cmd_frame[i + 1]) << 8); + i += 2; + const uint8_t *payload = &cmd_frame[i]; + int payload_len = (len > (size_t)i) ? (int)(len - i) : 0; + + ChannelDetails channel; + if (!getChannel(channel_idx, channel)) { + writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx + } else if (data_type == DATA_TYPE_RESERVED) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (payload_len > MAX_GROUP_DATA_LENGTH) { + MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (sendGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + return true; +} + +// get Contact list +bool MyMesh::cmdGetContacts(size_t len) { + if (_iter_started) { + writeErrFrame(ERR_CODE_BAD_STATE); // iterator is currently busy + } else { + if (len >= 5) { // has optional 'since' param + memcpy(&_iter_filter_since, &cmd_frame[1], 4); } else { - writeErrFrame(ERR_CODE_TABLE_FULL); + _iter_filter_since = 0; } - } else if (cmd_frame[0] == CMD_GET_CONTACTS) { // get Contact list - if (_iter_started) { - writeErrFrame(ERR_CODE_BAD_STATE); // iterator is currently busy - } else { - if (len >= 5) { // has optional 'since' param - memcpy(&_iter_filter_since, &cmd_frame[1], 4); - } else { - _iter_filter_since = 0; - } - uint8_t reply[5]; - reply[0] = RESP_CODE_CONTACTS_START; - uint32_t count = getNumContacts(); // total, NOT filtered count - memcpy(&reply[1], &count, 4); - _serial->writeFrame(reply, 5); + uint8_t reply[5]; + reply[0] = RESP_CODE_CONTACTS_START; + uint32_t count = getNumContacts(); // total, NOT filtered count + memcpy(&reply[1], &count, 4); + _serial->writeFrame(reply, 5); + + // start iterator + _iter = startContactsIterator(); + _iter_started = true; + _most_recent_lastmod = 0; + } + return true; +} + +bool MyMesh::cmdSetAdvertName(size_t len) { + int nlen = len - 1; + if (nlen > sizeof(_prefs.node_name) - 1) nlen = sizeof(_prefs.node_name) - 1; // max len + memcpy(_prefs.node_name, &cmd_frame[1], nlen); + _prefs.node_name[nlen] = 0; // null terminator + savePrefs(); + writeOKFrame(); + return true; +} - // start iterator - _iter = startContactsIterator(); - _iter_started = true; - _most_recent_lastmod = 0; - } - } else if (cmd_frame[0] == CMD_SET_ADVERT_NAME && len >= 2) { - int nlen = len - 1; - if (nlen > sizeof(_prefs.node_name) - 1) nlen = sizeof(_prefs.node_name) - 1; // max len - memcpy(_prefs.node_name, &cmd_frame[1], nlen); - _prefs.node_name[nlen] = 0; // null terminator +bool MyMesh::cmdSetAdvertLatlon(size_t len) { + int32_t lat, lon, alt = 0; + memcpy(&lat, &cmd_frame[1], 4); + memcpy(&lon, &cmd_frame[5], 4); + if (len >= 13) { + memcpy(&alt, &cmd_frame[9], 4); // for FUTURE support + } + if (lat <= 90 * 1E6 && lat >= -90 * 1E6 && lon <= 180 * 1E6 && lon >= -180 * 1E6) { + sensors.node_lat = ((double)lat) / 1000000.0; + sensors.node_lon = ((double)lon) / 1000000.0; savePrefs(); writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_ADVERT_LATLON && len >= 9) { - int32_t lat, lon, alt = 0; - memcpy(&lat, &cmd_frame[1], 4); - memcpy(&lon, &cmd_frame[5], 4); - if (len >= 13) { - memcpy(&alt, &cmd_frame[9], 4); // for FUTURE support + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid geo coordinate + } + return true; +} + +bool MyMesh::cmdGetDeviceTime(size_t) { + uint8_t reply[5]; + reply[0] = RESP_CODE_CURR_TIME; + uint32_t now = getRTCClock()->getCurrentTime(); + memcpy(&reply[1], &now, 4); + _serial->writeFrame(reply, 5); + return true; +} + +bool MyMesh::cmdSetDeviceTime(size_t) { + uint32_t secs; + memcpy(&secs, &cmd_frame[1], 4); + uint32_t curr = getRTCClock()->getCurrentTime(); + if (secs >= curr) { + getRTCClock()->setCurrentTime(secs); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdSendSelfAdvert(size_t len) { + mesh::Packet* pkt; + if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { + pkt = createSelfAdvert(_prefs.node_name); + } else { + pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); + } + if (pkt) { + if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop) + unsigned long delay_millis = 0; + TransportKey default_scope; + memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); + sendFloodScoped(default_scope, pkt, delay_millis); + } else { + sendZeroHop(pkt); } - if (lat <= 90 * 1E6 && lat >= -90 * 1E6 && lon <= 180 * 1E6 && lon >= -180 * 1E6) { - sensors.node_lat = ((double)lat) / 1000000.0; - sensors.node_lon = ((double)lon) / 1000000.0; - savePrefs(); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + return true; +} + +bool MyMesh::cmdResetPath(size_t) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + recipient->out_path_len = OUT_PATH_UNKNOWN; + // recipient->lastmod = ?? shouldn't be needed, app already has this version of contact + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // unknown contact + } + return true; +} + +bool MyMesh::cmdAddUpdateContact(size_t len) { + // updateContactFromFrame() reads every field up to last_advert_timestamp without + // testing len; only the trailing lat/lon and lastmod are optional. The reverse + // direction (writeContactRespFrame) writes the same fields in the same order. + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame + if (recipient) { + updateContactFromFrame(*recipient, last_mod, cmd_frame, len); + recipient->lastmod = last_mod; + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + writeOKFrame(); + } else { + ContactInfo contact; + updateContactFromFrame(contact, last_mod, cmd_frame, len); + contact.lastmod = last_mod; + contact.sync_since = 0; + if (addContact(contact)) { + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid geo coordinate + writeErrFrame(ERR_CODE_TABLE_FULL); } - } else if (cmd_frame[0] == CMD_GET_DEVICE_TIME) { - uint8_t reply[5]; - reply[0] = RESP_CODE_CURR_TIME; - uint32_t now = getRTCClock()->getCurrentTime(); - memcpy(&reply[1], &now, 4); - _serial->writeFrame(reply, 5); - } else if (cmd_frame[0] == CMD_SET_DEVICE_TIME && len >= 5) { - uint32_t secs; - memcpy(&secs, &cmd_frame[1], 4); - uint32_t curr = getRTCClock()->getCurrentTime(); - if (secs >= curr) { - getRTCClock()->setCurrentTime(secs); + } + return true; +} + +bool MyMesh::cmdRemoveContact(size_t) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient && removeContact(*recipient)) { + _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // not found, or unable to remove + } + return true; +} + +bool MyMesh::cmdShareContact(size_t) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + if (shareContactZeroHop(*recipient)) { writeOKFrame(); } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); + writeErrFrame(ERR_CODE_TABLE_FULL); // unable to send } - } else if (cmd_frame[0] == CMD_SEND_SELF_ADVERT) { + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } + return true; +} + +bool MyMesh::cmdGetContactByKey(size_t) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (contact) { + writeContactRespFrame(RESP_CODE_CONTACT, *contact); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // not found + } + return true; +} + +bool MyMesh::cmdExportContact(size_t len) { + if (len < 1 + PUB_KEY_SIZE) { + // export SELF mesh::Packet* pkt; if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { pkt = createSelfAdvert(_prefs.node_name); @@ -1283,385 +1501,359 @@ void MyMesh::handleCmdFrame(size_t len) { pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); } if (pkt) { - if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop) - unsigned long delay_millis = 0; - TransportKey default_scope; - memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); - sendFloodScoped(default_scope, pkt, delay_millis); - } else { - sendZeroHop(pkt); - } - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); - } - } else if (cmd_frame[0] == CMD_RESET_PATH && len >= 1 + 32) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - recipient->out_path_len = OUT_PATH_UNKNOWN; - // recipient->lastmod = ?? shouldn't be needed, app already has this version of contact - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // unknown contact - } - } else if (cmd_frame[0] == CMD_ADD_UPDATE_CONTACT && len >= 1 + PUB_KEY_SIZE + 3 + MAX_PATH_SIZE + 32 + 4) { - // updateContactFromFrame() reads every field up to last_advert_timestamp without - // testing len; only the trailing lat/lon and lastmod are optional. The reverse - // direction (writeContactRespFrame) writes the same fields in the same order. - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame - if (recipient) { - updateContactFromFrame(*recipient, last_mod, cmd_frame, len); - recipient->lastmod = last_mod; - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - writeOKFrame(); - } else { - ContactInfo contact; - updateContactFromFrame(contact, last_mod, cmd_frame, len); - contact.lastmod = last_mod; - contact.sync_since = 0; - if (addContact(contact)) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); - } - } - } else if (cmd_frame[0] == CMD_REMOVE_CONTACT && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient && removeContact(*recipient)) { - _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - writeOKFrame(); + pkt->header |= ROUTE_TYPE_FLOOD; // would normally be sent in this mode + + out_frame[0] = RESP_CODE_EXPORT_CONTACT; + uint8_t out_len = pkt->writeTo(&out_frame[1]); + releasePacket(pkt); // undo the obtainNewPacket() + _serial->writeFrame(out_frame, out_len + 1); } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // not found, or unable to remove + writeErrFrame(ERR_CODE_TABLE_FULL); // Error } - } else if (cmd_frame[0] == CMD_SHARE_CONTACT && len >= 1 + PUB_KEY_SIZE) { + } else { uint8_t *pub_key = &cmd_frame[1]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - if (shareContactZeroHop(*recipient)) { - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); // unable to send - } - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); - } - } else if (cmd_frame[0] == CMD_GET_CONTACT_BY_KEY && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (contact) { - writeContactRespFrame(RESP_CODE_CONTACT, *contact); + uint8_t out_len; + if (recipient && (out_len = exportContact(*recipient, &out_frame[1])) > 0) { + out_frame[0] = RESP_CODE_EXPORT_CONTACT; + _serial->writeFrame(out_frame, out_len + 1); } else { writeErrFrame(ERR_CODE_NOT_FOUND); // not found } - } else if (cmd_frame[0] == CMD_EXPORT_CONTACT) { - if (len < 1 + PUB_KEY_SIZE) { - // export SELF - mesh::Packet* pkt; - if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { - pkt = createSelfAdvert(_prefs.node_name); - } else { - pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); - } - if (pkt) { - pkt->header |= ROUTE_TYPE_FLOOD; // would normally be sent in this mode + } + return true; +} - out_frame[0] = RESP_CODE_EXPORT_CONTACT; - uint8_t out_len = pkt->writeTo(&out_frame[1]); - releasePacket(pkt); // undo the obtainNewPacket() - _serial->writeFrame(out_frame, out_len + 1); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); // Error - } - } else { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - uint8_t out_len; - if (recipient && (out_len = exportContact(*recipient, &out_frame[1])) > 0) { - out_frame[0] = RESP_CODE_EXPORT_CONTACT; - _serial->writeFrame(out_frame, out_len + 1); - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // not found - } - } - } else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) { - if (importContact(&cmd_frame[1], len - 1)) { - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } - } else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) { - int out_len; - if ((out_len = getFromOfflineQueue(out_frame)) > 0) { - _serial->writeFrame(out_frame, out_len); - if (_listener) _listener->onQueueSizeChanged(offline_queue_len); - } else { - out_frame[0] = RESP_CODE_NO_MORE_MESSAGES; - _serial->writeFrame(out_frame, 1); - } - } else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS && len >= 11) { // freq, bw, sf, cr; the repeat flag stays optional - int i = 1; - uint32_t freq; - memcpy(&freq, &cmd_frame[i], 4); - i += 4; - uint32_t bw; - memcpy(&bw, &cmd_frame[i], 4); - i += 4; - uint8_t sf = cmd_frame[i++]; - uint8_t cr = cmd_frame[i++]; - uint8_t repeat = 0; // default - false - if (len > i) { - repeat = cmd_frame[i++]; // FIRMWARE_VER_CODE 9+ - } +bool MyMesh::cmdImportContact(size_t len) { + if (importContact(&cmd_frame[1], len - 1)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} - if (repeat && !isValidClientRepeatFreq(freq)) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && - bw <= 500000) { - _prefs.sf = sf; - _prefs.cr = cr; - _prefs.freq = (float)freq / 1000.0; - _prefs.bw = (float)bw / 1000.0; - _prefs.setRepeatEn(repeat != 0); - savePrefs(); +bool MyMesh::cmdSyncNextMessage(size_t) { + int out_len; + if ((out_len = getFromOfflineQueue(out_frame)) > 0) { + _serial->writeFrame(out_frame, out_len); + if (_listener) _listener->onQueueSizeChanged(offline_queue_len); + } else { + out_frame[0] = RESP_CODE_NO_MORE_MESSAGES; + _serial->writeFrame(out_frame, 1); + } + return true; +} - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, - (uint32_t)cr); +// freq, bw, sf, cr; the repeat flag stays optional +bool MyMesh::cmdSetRadioParams(size_t len) { + int i = 1; + uint32_t freq; + memcpy(&freq, &cmd_frame[i], 4); + i += 4; + uint32_t bw; + memcpy(&bw, &cmd_frame[i], 4); + i += 4; + uint8_t sf = cmd_frame[i++]; + uint8_t cr = cmd_frame[i++]; + uint8_t repeat = 0; // default - false + if (len > i) { + repeat = cmd_frame[i++]; // FIRMWARE_VER_CODE 9+ + } + + if (repeat && !isValidClientRepeatFreq(freq)) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && + bw <= 500000) { + _prefs.sf = sf; + _prefs.cr = cr; + _prefs.freq = (float)freq / 1000.0; + _prefs.bw = (float)bw / 1000.0; + _prefs.setRepeatEn(repeat != 0); + savePrefs(); - writeOKFrame(); - } else { - MESH_DEBUG_PRINTLN("Error: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, - (uint32_t)cr); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } - } else if (cmd_frame[0] == CMD_SET_RADIO_TX_POWER && len >= 2) { - int8_t power = (int8_t)cmd_frame[1]; - if (power < -9 || power > MAX_LORA_TX_POWER) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else { - _prefs.tx_power_dbm = power; - savePrefs(); - radio_driver.setTxPower(_prefs.tx_power_dbm); - writeOKFrame(); - } - } else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS && len >= 9) { // two 32 bit values, neither range checked - int i = 1; - uint32_t rx, af; - memcpy(&rx, &cmd_frame[i], 4); - i += 4; - memcpy(&af, &cmd_frame[i], 4); - i += 4; - _prefs.rx_delay_base = ((float)rx) / 1000.0f; - _prefs.airtime_factor = ((float)af) / 1000.0f; + radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, + (uint32_t)cr); + + writeOKFrame(); + } else { + MESH_DEBUG_PRINTLN("Error: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, + (uint32_t)cr); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdSetRadioTxPower(size_t) { + int8_t power = (int8_t)cmd_frame[1]; + if (power < -9 || power > MAX_LORA_TX_POWER) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + _prefs.tx_power_dbm = power; savePrefs(); + radio_driver.setTxPower(_prefs.tx_power_dbm); writeOKFrame(); - } else if (cmd_frame[0] == CMD_GET_TUNING_PARAMS) { - uint32_t rx = _prefs.rx_delay_base * 1000, af = _prefs.airtime_factor * 1000; - int i = 0; - out_frame[i++] = RESP_CODE_TUNING_PARAMS; - memcpy(&out_frame[i], &rx, 4); i += 4; - memcpy(&out_frame[i], &af, 4); i += 4; - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_SET_OTHER_PARAMS && len >= 2) { // the fields after manual_add stay optional - _prefs.manual_add_contacts = cmd_frame[1]; - if (len >= 3) { - _prefs.telemetry_mode_base = cmd_frame[2] & 0x03; // v5+ - _prefs.telemetry_mode_loc = (cmd_frame[2] >> 2) & 0x03; - _prefs.telemetry_mode_env = (cmd_frame[2] >> 4) & 0x03; - - if (len >= 4) { - _prefs.advert_loc_policy = cmd_frame[3]; - if (len >= 5) { - _prefs.multi_acks = cmd_frame[4]; - } + } + return true; +} + +// two 32 bit values, neither range checked +bool MyMesh::cmdSetTuningParams(size_t) { + int i = 1; + uint32_t rx, af; + memcpy(&rx, &cmd_frame[i], 4); + i += 4; + memcpy(&af, &cmd_frame[i], 4); + i += 4; + _prefs.rx_delay_base = ((float)rx) / 1000.0f; + _prefs.airtime_factor = ((float)af) / 1000.0f; + savePrefs(); + writeOKFrame(); + return true; +} + +bool MyMesh::cmdGetTuningParams(size_t) { + uint32_t rx = _prefs.rx_delay_base * 1000, af = _prefs.airtime_factor * 1000; + int i = 0; + out_frame[i++] = RESP_CODE_TUNING_PARAMS; + memcpy(&out_frame[i], &rx, 4); i += 4; + memcpy(&out_frame[i], &af, 4); i += 4; + _serial->writeFrame(out_frame, i); + return true; +} + +// the fields after manual_add stay optional +bool MyMesh::cmdSetOtherParams(size_t len) { + _prefs.manual_add_contacts = cmd_frame[1]; + if (len >= 3) { + _prefs.telemetry_mode_base = cmd_frame[2] & 0x03; // v5+ + _prefs.telemetry_mode_loc = (cmd_frame[2] >> 2) & 0x03; + _prefs.telemetry_mode_env = (cmd_frame[2] >> 4) & 0x03; + + if (len >= 4) { + _prefs.advert_loc_policy = cmd_frame[3]; + if (len >= 5) { + _prefs.multi_acks = cmd_frame[4]; } } + } + savePrefs(); + writeOKFrame(); + return true; +} + +bool MyMesh::cmdSetPathHashMode(size_t) { + if (cmd_frame[1] != 0) return false; + if (cmd_frame[2] >= 3) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + _prefs.path_hash_mode = cmd_frame[2]; savePrefs(); writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_PATH_HASH_MODE && len >= 3 && cmd_frame[1] == 0) { - if (cmd_frame[2] >= 3) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else { - _prefs.path_hash_mode = cmd_frame[2]; - savePrefs(); - writeOKFrame(); - } - } else if (cmd_frame[0] == CMD_REBOOT && len >= 7 && memcmp(&cmd_frame[1], "reboot", 6) == 0) { - if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? - saveContacts(); - } - board.reboot(); - } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { - uint8_t reply[11]; - int i = 0; - reply[i++] = RESP_CODE_BATT_AND_STORAGE; - uint16_t battery_millivolts = board.getBattMilliVolts(); - uint32_t used = _store->getStorageUsedKb(); - uint32_t total = _store->getStorageTotalKb(); - memcpy(&reply[i], &battery_millivolts, 2); i += 2; - memcpy(&reply[i], &used, 4); i += 4; - memcpy(&reply[i], &total, 4); i += 4; - _serial->writeFrame(reply, i); - } else if (cmd_frame[0] == CMD_EXPORT_PRIVATE_KEY) { + } + return true; +} + +bool MyMesh::cmdReboot(size_t) { + if (memcmp(&cmd_frame[1], "reboot", 6) != 0) return false; + if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? + saveContacts(); + } + board.reboot(); + return true; +} + +bool MyMesh::cmdGetBattAndStorage(size_t) { + uint8_t reply[11]; + int i = 0; + reply[i++] = RESP_CODE_BATT_AND_STORAGE; + uint16_t battery_millivolts = board.getBattMilliVolts(); + uint32_t used = _store->getStorageUsedKb(); + uint32_t total = _store->getStorageTotalKb(); + memcpy(&reply[i], &battery_millivolts, 2); i += 2; + memcpy(&reply[i], &used, 4); i += 4; + memcpy(&reply[i], &total, 4); i += 4; + _serial->writeFrame(reply, i); + return true; +} + +bool MyMesh::cmdExportPrivateKey(size_t) { #if ENABLE_PRIVATE_KEY_EXPORT - uint8_t reply[65]; - reply[0] = RESP_CODE_PRIVATE_KEY; - self_id.writeTo(&reply[1], 64); - _serial->writeFrame(reply, 65); + uint8_t reply[65]; + reply[0] = RESP_CODE_PRIVATE_KEY; + self_id.writeTo(&reply[1], 64); + _serial->writeFrame(reply, 65); #else - writeDisabledFrame(); + writeDisabledFrame(); #endif - } else if (cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY && len >= 65) { + return true; +} + +bool MyMesh::cmdImportPrivateKey(size_t) { #if ENABLE_PRIVATE_KEY_IMPORT - if (!mesh::LocalIdentity::validatePrivateKey(&cmd_frame[1])) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key - } else { - mesh::LocalIdentity identity; - identity.readFrom(&cmd_frame[1], 64); - if (_store->saveMainIdentity(identity)) { - self_id = identity; - writeOKFrame(); - // re-load contacts, to invalidate ecdh shared_secrets - resetContacts(); - _store->loadContacts(this); - } else { - writeErrFrame(ERR_CODE_FILE_IO_ERROR); - } - } -#else - writeDisabledFrame(); -#endif - } else if (cmd_frame[0] == CMD_SEND_RAW_DATA && len >= 6) { - int i = 1; - uint8_t path_len = cmd_frame[i++]; - if (path_len >= 0 && mesh::Packet::isValidPathLen(path_len)) { - uint8_t path[MAX_PATH_SIZE]; - i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); - if (i + 4 > len) { // min payload 4 bytes - writeErrFrame(ERR_CODE_ILLEGAL_ARG); + if (!mesh::LocalIdentity::validatePrivateKey(&cmd_frame[1])) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key + } else { + mesh::LocalIdentity identity; + identity.readFrom(&cmd_frame[1], 64); + if (_store->saveMainIdentity(identity)) { + self_id = identity; + writeOKFrame(); + // re-load contacts, to invalidate ecdh shared_secrets + resetContacts(); + _store->loadContacts(this); } else { - auto pkt = createRawData(&cmd_frame[i], len - i); - if (pkt) { - sendDirect(pkt, path, path_len); - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); - } + writeErrFrame(ERR_CODE_FILE_IO_ERROR); } + } +#else + writeDisabledFrame(); +#endif + return true; +} + +bool MyMesh::cmdSendRawData(size_t len) { + int i = 1; + uint8_t path_len = cmd_frame[i++]; + if (path_len >= 0 && mesh::Packet::isValidPathLen(path_len)) { + uint8_t path[MAX_PATH_SIZE]; + i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); + if (i + 4 > len) { // min payload 4 bytes + writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // flood, not supported (yet) - } - } else if (cmd_frame[0] == CMD_SEND_LOGIN && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - char *password = (char *)&cmd_frame[1 + PUB_KEY_SIZE]; - cmd_frame[len] = 0; // ensure null terminator in password - if (recipient) { - uint32_t est_timeout; - int result = sendLogin(*recipient, password, est_timeout); - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); + auto pkt = createRawData(&cmd_frame[i], len - i); + if (pkt) { + sendDirect(pkt, path, path_len); + writeOKFrame(); } else { - clearPendingReqs(); - memcpy(&pending_login, recipient->id.pub_key, 4); // match this to onContactResponse() - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &pending_login, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found - } - } else if (cmd_frame[0] == CMD_SEND_ANON_REQ && len > 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - ContactInfo anon; - if (recipient == NULL) { // FIRMWARE_VER_CODE 13+, allow non-contact requests - memset(&anon, 0, sizeof(anon)); - memcpy(anon.id.pub_key, pub_key, PUB_KEY_SIZE); - anon.out_path_len = 0; // default to zero-hop direct - anon.type = ADV_TYPE_NONE; // unknown - anon.lastmod = getRTCClock()->getCurrentTime(); - - if (addContact(anon)) recipient = &anon; - } - uint8_t *data = &cmd_frame[1 + PUB_KEY_SIZE]; - if (recipient) { - uint32_t tag, est_timeout; - int result = sendAnonReq(*recipient, data, len - (1 + PUB_KEY_SIZE), tag, est_timeout); - if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - clearPendingReqs(); - pending_req = tag; // match this to onContactResponse() - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); } + } + } else { + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // flood, not supported (yet) + } + return true; +} + +bool MyMesh::cmdSendLogin(size_t len) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + char *password = (char *)&cmd_frame[1 + PUB_KEY_SIZE]; + cmd_frame[len] = 0; // ensure null terminator in password + if (recipient) { + uint32_t est_timeout; + int result = sendLogin(*recipient, password, est_timeout); + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - writeErrFrame(ERR_CODE_TABLE_FULL); // contacts full + clearPendingReqs(); + memcpy(&pending_login, recipient->id.pub_key, 4); // match this to onContactResponse() + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &pending_login, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_SEND_STATUS_REQ && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - uint32_t tag, est_timeout; - int result = sendRequest(*recipient, REQ_TYPE_GET_STATUS, tag, est_timeout); - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - clearPendingReqs(); - // FUTURE: pending_status = tag; // match this in onContactResponse() - memcpy(&pending_status, recipient->id.pub_key, 4); // legacy matching scheme - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + } + return true; +} + +bool MyMesh::cmdSendAnonReq(size_t len) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + ContactInfo anon; + if (recipient == NULL) { // FIRMWARE_VER_CODE 13+, allow non-contact requests + memset(&anon, 0, sizeof(anon)); + memcpy(anon.id.pub_key, pub_key, PUB_KEY_SIZE); + anon.out_path_len = 0; // default to zero-hop direct + anon.type = ADV_TYPE_NONE; // unknown + anon.lastmod = getRTCClock()->getCurrentTime(); + + if (addContact(anon)) recipient = &anon; + } + uint8_t *data = &cmd_frame[1 + PUB_KEY_SIZE]; + if (recipient) { + uint32_t tag, est_timeout; + int result = sendAnonReq(*recipient, data, len - (1 + PUB_KEY_SIZE), tag, est_timeout); + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + clearPendingReqs(); + pending_req = tag; // match this to onContactResponse() + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && len >= 2 + PUB_KEY_SIZE && cmd_frame[1] == 0) { - uint8_t *pub_key = &cmd_frame[2]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - uint32_t tag, est_timeout; - // 'Path Discovery' is just a special case of flood + Telemetry req - uint8_t req_data[9]; - req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA; - req_data[1] = ~(TELEM_PERM_BASE); // NEW: inverse permissions mask (ie. we only want BASE telemetry) - memset(&req_data[2], 0, 3); // reserved - getRNG()->random(&req_data[5], 4); // random blob to help make packet-hash unique - auto save = recipient->out_path_len; // temporarily force sendRequest() to flood - recipient->out_path_len = OUT_PATH_UNKNOWN; - int result = sendRequest(*recipient, req_data, sizeof(req_data), tag, est_timeout); - recipient->out_path_len = save; - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - clearPendingReqs(); - pending_discovery = tag; // match this in onContactResponse() - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); // contacts full + } + return true; +} + +bool MyMesh::cmdSendStatusReq(size_t) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + uint32_t tag, est_timeout; + int result = sendRequest(*recipient, REQ_TYPE_GET_STATUS, tag, est_timeout); + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + clearPendingReqs(); + // FUTURE: pending_status = tag; // match this in onContactResponse() + memcpy(&pending_status, recipient->id.pub_key, 4); // legacy matching scheme + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len >= 4 + PUB_KEY_SIZE) { // can deprecate, in favour of CMD_SEND_BINARY_REQ + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + } + return true; +} + +bool MyMesh::cmdSendPathDiscoveryReq(size_t) { + if (cmd_frame[1] != 0) return false; + uint8_t *pub_key = &cmd_frame[2]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + uint32_t tag, est_timeout; + // 'Path Discovery' is just a special case of flood + Telemetry req + uint8_t req_data[9]; + req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA; + req_data[1] = ~(TELEM_PERM_BASE); // NEW: inverse permissions mask (ie. we only want BASE telemetry) + memset(&req_data[2], 0, 3); // reserved + getRNG()->random(&req_data[5], 4); // random blob to help make packet-hash unique + auto save = recipient->out_path_len; // temporarily force sendRequest() to flood + recipient->out_path_len = OUT_PATH_UNKNOWN; + int result = sendRequest(*recipient, req_data, sizeof(req_data), tag, est_timeout); + recipient->out_path_len = save; + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); + } else { + clearPendingReqs(); + pending_discovery = tag; // match this in onContactResponse() + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); + } + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + } + return true; +} + +bool MyMesh::cmdSendTelemetryReq(size_t len) { + if (len >= 4 + PUB_KEY_SIZE) { // can deprecate, in favour of CMD_SEND_BINARY_REQ uint8_t *pub_key = &cmd_frame[4]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { @@ -1681,7 +1873,9 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found } - } else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len == 4) { // 'self' telemetry request + return true; + } + if (len == 4) { // 'self' telemetry request telemetry.reset(); telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); float temperature = board.getMCUTemperature(); @@ -1701,56 +1895,76 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], telemetry.getBuffer(), tlen); i += tlen; _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_SEND_BINARY_REQ && len >= 2 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - if (recipient) { - uint8_t *req_data = &cmd_frame[1 + PUB_KEY_SIZE]; - uint32_t tag, est_timeout; - int result = sendRequest(*recipient, req_data, len - (1 + PUB_KEY_SIZE), tag, est_timeout); - if (result == MSG_SEND_FAILED) { - writeErrFrame(ERR_CODE_TABLE_FULL); - } else { - clearPendingReqs(); - pending_req = tag; // match this in onContactResponse() - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found - } - } else if (cmd_frame[0] == CMD_HAS_CONNECTION && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - if (hasConnectionTo(pub_key)) { - writeOKFrame(); + return true; + } + return false; +} + +bool MyMesh::cmdSendBinaryReq(size_t len) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + uint8_t *req_data = &cmd_frame[1 + PUB_KEY_SIZE]; + uint32_t tag, est_timeout; + int result = sendRequest(*recipient, req_data, len - (1 + PUB_KEY_SIZE), tag, est_timeout); + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); } else { - writeErrFrame(ERR_CODE_NOT_FOUND); + clearPendingReqs(); + pending_req = tag; // match this in onContactResponse() + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } - } else if (cmd_frame[0] == CMD_LOGOUT && len >= 1 + PUB_KEY_SIZE) { - uint8_t *pub_key = &cmd_frame[1]; - stopConnection(pub_key); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // contact not found + } + return true; +} + +bool MyMesh::cmdHasConnection(size_t) { + uint8_t *pub_key = &cmd_frame[1]; + if (hasConnectionTo(pub_key)) { writeOKFrame(); - } else if (cmd_frame[0] == CMD_GET_CHANNEL && len >= 2) { - uint8_t channel_idx = cmd_frame[1]; - ChannelDetails channel; - if (getChannel(channel_idx, channel)) { - int i = 0; - out_frame[i++] = RESP_CODE_CHANNEL_INFO; - out_frame[i++] = channel_idx; - strcpy((char *)&out_frame[i], channel.name); - i += 32; - memcpy(&out_frame[i], channel.channel.secret, 16); - i += 16; // NOTE: only 128-bit supported - _serial->writeFrame(out_frame, i); - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); - } - } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32) { + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } + return true; +} + +bool MyMesh::cmdLogout(size_t) { + uint8_t *pub_key = &cmd_frame[1]; + stopConnection(pub_key); + writeOKFrame(); + return true; +} + +bool MyMesh::cmdGetChannel(size_t) { + uint8_t channel_idx = cmd_frame[1]; + ChannelDetails channel; + if (getChannel(channel_idx, channel)) { + int i = 0; + out_frame[i++] = RESP_CODE_CHANNEL_INFO; + out_frame[i++] = channel_idx; + strcpy((char *)&out_frame[i], channel.name); + i += 32; + memcpy(&out_frame[i], channel.channel.secret, 16); + i += 16; // NOTE: only 128-bit supported + _serial->writeFrame(out_frame, i); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } + return true; +} + +bool MyMesh::cmdSetChannel(size_t len) { + if (len >= 2 + 32 + 32) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // not supported (yet) - } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) { + return true; + } + if (len >= 2 + 32 + 16) { uint8_t channel_idx = cmd_frame[1]; ChannelDetails channel; StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); @@ -1762,204 +1976,242 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx } - } else if (cmd_frame[0] == CMD_SIGN_START) { - out_frame[0] = RESP_CODE_SIGN_START; - out_frame[1] = 0; // reserved - uint32_t len = MAX_SIGN_DATA_LEN; - memcpy(&out_frame[2], &len, 4); - _serial->writeFrame(out_frame, 6); - - if (sign_data) { - free(sign_data); - } - sign_data = (uint8_t *)malloc(MAX_SIGN_DATA_LEN); - sign_data_len = 0; - } else if (cmd_frame[0] == CMD_SIGN_DATA && len > 1) { - if (sign_data == NULL || sign_data_len + (len - 1) > MAX_SIGN_DATA_LEN) { - writeErrFrame(sign_data == NULL ? ERR_CODE_BAD_STATE : ERR_CODE_TABLE_FULL); // error: too long - } else { - memcpy(&sign_data[sign_data_len], &cmd_frame[1], len - 1); - sign_data_len += (len - 1); - writeOKFrame(); - } - } else if (cmd_frame[0] == CMD_SIGN_FINISH) { - if (sign_data) { - self_id.sign(&out_frame[1], sign_data, sign_data_len); + return true; + } + return false; +} - free(sign_data); // don't need sign_data now - sign_data = NULL; +bool MyMesh::cmdSignStart(size_t) { + out_frame[0] = RESP_CODE_SIGN_START; + out_frame[1] = 0; // reserved + uint32_t len = MAX_SIGN_DATA_LEN; + memcpy(&out_frame[2], &len, 4); + _serial->writeFrame(out_frame, 6); - out_frame[0] = RESP_CODE_SIGNATURE; - _serial->writeFrame(out_frame, 1 + SIGNATURE_SIZE); - } else { - writeErrFrame(ERR_CODE_BAD_STATE); - } - } else if (cmd_frame[0] == CMD_SEND_TRACE_PATH && len > 10 && len - 10 < MAX_PACKET_PAYLOAD-5) { - uint8_t path_len = len - 10; - uint8_t flags = cmd_frame[9]; - uint8_t path_sz = flags & 0x03; // NEW v1.11+ - if ((path_len >> path_sz) > MAX_PATH_SIZE || (path_len % (1 << path_sz)) != 0) { // make sure is multiple of path_sz - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else { - uint32_t tag, auth; - memcpy(&tag, &cmd_frame[1], 4); - memcpy(&auth, &cmd_frame[5], 4); - auto pkt = createTrace(tag, auth, flags); - if (pkt) { - sendDirect(pkt, &cmd_frame[10], path_len); + if (sign_data) { + free(sign_data); + } + sign_data = (uint8_t *)malloc(MAX_SIGN_DATA_LEN); + sign_data_len = 0; + return true; +} - uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2); - uint32_t est_timeout = calcDirectTimeoutMillisFor(t, path_len >> path_sz); +bool MyMesh::cmdSignData(size_t len) { + if (sign_data == NULL || sign_data_len + (len - 1) > MAX_SIGN_DATA_LEN) { + writeErrFrame(sign_data == NULL ? ERR_CODE_BAD_STATE : ERR_CODE_TABLE_FULL); // error: too long + } else { + memcpy(&sign_data[sign_data_len], &cmd_frame[1], len - 1); + sign_data_len += (len - 1); + writeOKFrame(); + } + return true; +} - out_frame[0] = RESP_CODE_SENT; - out_frame[1] = 0; - memcpy(&out_frame[2], &tag, 4); - memcpy(&out_frame[6], &est_timeout, 4); - _serial->writeFrame(out_frame, 10); - } else { - writeErrFrame(ERR_CODE_TABLE_FULL); - } - } - } else if (cmd_frame[0] == CMD_SET_DEVICE_PIN && len >= 5) { +bool MyMesh::cmdSignFinish(size_t) { + if (sign_data) { + self_id.sign(&out_frame[1], sign_data, sign_data_len); - // get pin from command frame - uint32_t pin; - memcpy(&pin, &cmd_frame[1], 4); + free(sign_data); // don't need sign_data now + sign_data = NULL; - // ensure pin is zero, or a valid 6 digit pin - if (pin == 0 || (pin >= 100000 && pin <= 999999)) { - _prefs.ble_pin = pin; - savePrefs(); - writeOKFrame(); + out_frame[0] = RESP_CODE_SIGNATURE; + _serial->writeFrame(out_frame, 1 + SIGNATURE_SIZE); + } else { + writeErrFrame(ERR_CODE_BAD_STATE); + } + return true; +} + +bool MyMesh::cmdSendTracePath(size_t len) { + if (len - 10 >= MAX_PACKET_PAYLOAD-5) return false; + uint8_t path_len = len - 10; + uint8_t flags = cmd_frame[9]; + uint8_t path_sz = flags & 0x03; // NEW v1.11+ + if ((path_len >> path_sz) > MAX_PATH_SIZE || (path_len % (1 << path_sz)) != 0) { // make sure is multiple of path_sz + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + uint32_t tag, auth; + memcpy(&tag, &cmd_frame[1], 4); + memcpy(&auth, &cmd_frame[5], 4); + auto pkt = createTrace(tag, auth, flags); + if (pkt) { + sendDirect(pkt, &cmd_frame[10], path_len); + + uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2); + uint32_t est_timeout = calcDirectTimeoutMillisFor(t, path_len >> path_sz); + + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } - } else if (cmd_frame[0] == CMD_GET_CUSTOM_VARS) { - out_frame[0] = RESP_CODE_CUSTOM_VARS; - char *dp = (char *)&out_frame[1]; - for (int i = 0; i < sensors.getNumSettings() && dp - (char *)&out_frame[1] < 140; i++) { - if (i > 0) { - *dp++ = ','; - } - strcpy(dp, sensors.getSettingName(i)); - dp = strchr(dp, 0); - *dp++ = ':'; - strcpy(dp, sensors.getSettingValue(i)); - dp = strchr(dp, 0); + writeErrFrame(ERR_CODE_TABLE_FULL); } - _serial->writeFrame(out_frame, dp - (char *)out_frame); - } else if (cmd_frame[0] == CMD_SET_CUSTOM_VAR && len >= 4) { - cmd_frame[len] = 0; - char *sp = (char *)&cmd_frame[1]; - char *np = strchr(sp, ':'); // look for separator char - if (np) { - *np++ = 0; // modify 'cmd_frame', replace ':' with null - bool success = sensors.setSettingValue(sp, np); - if (success) { - #if ENV_INCLUDE_GPS == 1 - // Update node preferences for GPS settings - if (strcmp(sp, "gps") == 0) { - _prefs.gps_enabled = (np[0] == '1') ? 1 : 0; - savePrefs(); - } else if (strcmp(sp, "gps_interval") == 0) { - uint32_t interval_seconds = atoi(np); - _prefs.gps_interval = constrain(interval_seconds, 0, 86400); - savePrefs(); - } - #endif - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdSetDevicePin(size_t) { + + // get pin from command frame + uint32_t pin; + memcpy(&pin, &cmd_frame[1], 4); + + // ensure pin is zero, or a valid 6 digit pin + if (pin == 0 || (pin >= 100000 && pin <= 999999)) { + _prefs.ble_pin = pin; + savePrefs(); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdGetCustomVars(size_t) { + out_frame[0] = RESP_CODE_CUSTOM_VARS; + char *dp = (char *)&out_frame[1]; + for (int i = 0; i < sensors.getNumSettings() && dp - (char *)&out_frame[1] < 140; i++) { + if (i > 0) { + *dp++ = ','; + } + strcpy(dp, sensors.getSettingName(i)); + dp = strchr(dp, 0); + *dp++ = ':'; + strcpy(dp, sensors.getSettingValue(i)); + dp = strchr(dp, 0); + } + _serial->writeFrame(out_frame, dp - (char *)out_frame); + return true; +} + +bool MyMesh::cmdSetCustomVar(size_t len) { + cmd_frame[len] = 0; + char *sp = (char *)&cmd_frame[1]; + char *np = strchr(sp, ':'); // look for separator char + if (np) { + *np++ = 0; // modify 'cmd_frame', replace ':' with null + bool success = sensors.setSettingValue(sp, np); + if (success) { + #if ENV_INCLUDE_GPS == 1 + // Update node preferences for GPS settings + if (strcmp(sp, "gps") == 0) { + _prefs.gps_enabled = (np[0] == '1') ? 1 : 0; + savePrefs(); + } else if (strcmp(sp, "gps_interval") == 0) { + uint32_t interval_seconds = atoi(np); + _prefs.gps_interval = constrain(interval_seconds, 0, 86400); + savePrefs(); } + #endif + writeOKFrame(); } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } - } else if (cmd_frame[0] == CMD_GET_ADVERT_PATH && len >= PUB_KEY_SIZE+2) { - // FUTURE use: uint8_t reserved = cmd_frame[1]; - uint8_t *pub_key = &cmd_frame[2]; - AdvertPath* found = NULL; - for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) { - auto p = &advert_paths[i]; - if (memcmp(p->pubkey_prefix, pub_key, sizeof(p->pubkey_prefix)) == 0) { - found = p; - break; - } - } - if (found) { - int i = 0; - out_frame[i++] = RESP_CODE_ADVERT_PATH; - memcpy(&out_frame[i], &found->recv_timestamp, 4); i += 4; - out_frame[i++] = found->path_len; - i += mesh::Packet::writePath(&out_frame[i], found->path, found->path_len); - _serial->writeFrame(out_frame, i); - } else { - writeErrFrame(ERR_CODE_NOT_FOUND); - } - } else if (cmd_frame[0] == CMD_GET_STATS && len >= 2) { - uint8_t stats_type = cmd_frame[1]; - if (stats_type == STATS_TYPE_CORE) { - int i = 0; - out_frame[i++] = RESP_CODE_STATS; - out_frame[i++] = STATS_TYPE_CORE; - uint16_t battery_mv = board.getBattMilliVolts(); - uint32_t uptime_secs = _ms->getMillis() / 1000; - uint8_t queue_len = (uint8_t)_mgr->getOutboundTotal(); - memcpy(&out_frame[i], &battery_mv, 2); i += 2; - memcpy(&out_frame[i], &uptime_secs, 4); i += 4; - memcpy(&out_frame[i], &_err_flags, 2); i += 2; - out_frame[i++] = queue_len; - _serial->writeFrame(out_frame, i); - } else if (stats_type == STATS_TYPE_RADIO) { - int i = 0; - out_frame[i++] = RESP_CODE_STATS; - out_frame[i++] = STATS_TYPE_RADIO; - int16_t noise_floor = (int16_t)_radio->getNoiseFloor(); - int8_t last_rssi = (int8_t)radio_driver.getLastRSSI(); - int8_t last_snr = (int8_t)(radio_driver.getLastSNR() * 4); // scaled by 4 for 0.25 dB precision - uint32_t tx_air_secs = getTotalAirTime() / 1000; - uint32_t rx_air_secs = getReceiveAirTime() / 1000; - memcpy(&out_frame[i], &noise_floor, 2); i += 2; - out_frame[i++] = last_rssi; - out_frame[i++] = last_snr; - memcpy(&out_frame[i], &tx_air_secs, 4); i += 4; - memcpy(&out_frame[i], &rx_air_secs, 4); i += 4; - _serial->writeFrame(out_frame, i); - } else if (stats_type == STATS_TYPE_PACKETS) { - int i = 0; - out_frame[i++] = RESP_CODE_STATS; - out_frame[i++] = STATS_TYPE_PACKETS; - uint32_t recv = radio_driver.getPacketsRecv(); - uint32_t sent = radio_driver.getPacketsSent(); - uint32_t n_sent_flood = getNumSentFlood(); - uint32_t n_sent_direct = getNumSentDirect(); - uint32_t n_recv_flood = getNumRecvFlood(); - uint32_t n_recv_direct = getNumRecvDirect(); - uint32_t n_recv_errors = radio_driver.getPacketsRecvErrors(); - memcpy(&out_frame[i], &recv, 4); i += 4; - memcpy(&out_frame[i], &sent, 4); i += 4; - memcpy(&out_frame[i], &n_sent_flood, 4); i += 4; - memcpy(&out_frame[i], &n_sent_direct, 4); i += 4; - memcpy(&out_frame[i], &n_recv_flood, 4); i += 4; - memcpy(&out_frame[i], &n_recv_direct, 4); i += 4; - memcpy(&out_frame[i], &n_recv_errors, 4); i += 4; - _serial->writeFrame(out_frame, i); - } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type - } - } else if (cmd_frame[0] == CMD_FACTORY_RESET && len >= 6 && memcmp(&cmd_frame[1], "reset", 5) == 0) { - if (_serial) { - 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 - } - bool success = _store->formatFileSystem(); - if (success) { - writeOKFrame(); - delay(1000); - board.reboot(); // doesn't return - } else { - writeErrFrame(ERR_CODE_FILE_IO_ERROR); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + return true; +} + +bool MyMesh::cmdGetAdvertPath(size_t) { + // FUTURE use: uint8_t reserved = cmd_frame[1]; + uint8_t *pub_key = &cmd_frame[2]; + AdvertPath* found = NULL; + for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) { + auto p = &advert_paths[i]; + if (memcmp(p->pubkey_prefix, pub_key, sizeof(p->pubkey_prefix)) == 0) { + found = p; + break; } - } else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 0) { + } + if (found) { + int i = 0; + out_frame[i++] = RESP_CODE_ADVERT_PATH; + memcpy(&out_frame[i], &found->recv_timestamp, 4); i += 4; + out_frame[i++] = found->path_len; + i += mesh::Packet::writePath(&out_frame[i], found->path, found->path_len); + _serial->writeFrame(out_frame, i); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } + return true; +} + +bool MyMesh::cmdGetStats(size_t) { + uint8_t stats_type = cmd_frame[1]; + if (stats_type == STATS_TYPE_CORE) { + int i = 0; + out_frame[i++] = RESP_CODE_STATS; + out_frame[i++] = STATS_TYPE_CORE; + uint16_t battery_mv = board.getBattMilliVolts(); + uint32_t uptime_secs = _ms->getMillis() / 1000; + uint8_t queue_len = (uint8_t)_mgr->getOutboundTotal(); + memcpy(&out_frame[i], &battery_mv, 2); i += 2; + memcpy(&out_frame[i], &uptime_secs, 4); i += 4; + memcpy(&out_frame[i], &_err_flags, 2); i += 2; + out_frame[i++] = queue_len; + _serial->writeFrame(out_frame, i); + } else if (stats_type == STATS_TYPE_RADIO) { + int i = 0; + out_frame[i++] = RESP_CODE_STATS; + out_frame[i++] = STATS_TYPE_RADIO; + int16_t noise_floor = (int16_t)_radio->getNoiseFloor(); + int8_t last_rssi = (int8_t)radio_driver.getLastRSSI(); + int8_t last_snr = (int8_t)(radio_driver.getLastSNR() * 4); // scaled by 4 for 0.25 dB precision + uint32_t tx_air_secs = getTotalAirTime() / 1000; + uint32_t rx_air_secs = getReceiveAirTime() / 1000; + memcpy(&out_frame[i], &noise_floor, 2); i += 2; + out_frame[i++] = last_rssi; + out_frame[i++] = last_snr; + memcpy(&out_frame[i], &tx_air_secs, 4); i += 4; + memcpy(&out_frame[i], &rx_air_secs, 4); i += 4; + _serial->writeFrame(out_frame, i); + } else if (stats_type == STATS_TYPE_PACKETS) { + int i = 0; + out_frame[i++] = RESP_CODE_STATS; + out_frame[i++] = STATS_TYPE_PACKETS; + uint32_t recv = radio_driver.getPacketsRecv(); + uint32_t sent = radio_driver.getPacketsSent(); + uint32_t n_sent_flood = getNumSentFlood(); + uint32_t n_sent_direct = getNumSentDirect(); + uint32_t n_recv_flood = getNumRecvFlood(); + uint32_t n_recv_direct = getNumRecvDirect(); + uint32_t n_recv_errors = radio_driver.getPacketsRecvErrors(); + memcpy(&out_frame[i], &recv, 4); i += 4; + memcpy(&out_frame[i], &sent, 4); i += 4; + memcpy(&out_frame[i], &n_sent_flood, 4); i += 4; + memcpy(&out_frame[i], &n_sent_direct, 4); i += 4; + memcpy(&out_frame[i], &n_recv_flood, 4); i += 4; + memcpy(&out_frame[i], &n_recv_direct, 4); i += 4; + memcpy(&out_frame[i], &n_recv_errors, 4); i += 4; + _serial->writeFrame(out_frame, i); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type + } + return true; +} + +bool MyMesh::cmdFactoryReset(size_t) { + if (memcmp(&cmd_frame[1], "reset", 5) != 0) return false; + if (_serial) { + 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 + } + bool success = _store->formatFileSystem(); + if (success) { + writeOKFrame(); + delay(1000); + board.reboot(); // doesn't return + } else { + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + } + return true; +} + +bool MyMesh::cmdSetFloodScopeKey(size_t len) { + if (len >= 2 && cmd_frame[1] == 0) { if (len >= 2 + 16) { memcpy(send_scope.key, &cmd_frame[2], sizeof(send_scope.key)); // set scope override TransportKey } else { @@ -1967,83 +2219,107 @@ void MyMesh::handleCmdFrame(size_t len) { } send_unscoped = false; writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 1) { // ver 12+ + return true; + } + if (len >= 2 && cmd_frame[1] == 1) { // ver 12+ send_unscoped = true; writeOKFrame(); - } else if (cmd_frame[0] == CMD_SET_DEFAULT_FLOOD_SCOPE && len >= 1) { - if (len >= 1+31+16) { - int n = strlen((char *) &cmd_frame[1]); - if (n > 0 && n < 31) { - strcpy(_prefs.default_scope_name, (char *) &cmd_frame[1]); - memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16); - savePrefs(); - writeOKFrame(); - } else { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } - } else { - memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // set default scope to null - memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); + return true; + } + return false; +} + +bool MyMesh::cmdSetDefaultFloodScope(size_t len) { + if (len >= 1+31+16) { + int n = strlen((char *) &cmd_frame[1]); + if (n > 0 && n < 31) { + strcpy(_prefs.default_scope_name, (char *) &cmd_frame[1]); + memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16); savePrefs(); writeOKFrame(); - } - } else if (cmd_frame[0] == CMD_GET_DEFAULT_FLOOD_SCOPE) { - out_frame[0] = RESP_CODE_DEFAULT_FLOOD_SCOPE; - if (strlen(_prefs.default_scope_name) > 0) { - memcpy(&out_frame[1], _prefs.default_scope_name, 31); - memcpy(&out_frame[1+31], _prefs.default_scope_key, 16); - _serial->writeFrame(out_frame, 1+31+16); - } else { - _serial->writeFrame(out_frame, 1); // no name or key means null - } - } else if (cmd_frame[0] == CMD_SEND_CONTROL_DATA && len >= 2 && (cmd_frame[1] & 0x80) != 0) { - auto resp = createControlData(&cmd_frame[1], len - 1); - if (resp) { - sendZeroHop(resp); - writeOKFrame(); } else { - writeErrFrame(ERR_CODE_TABLE_FULL); - } - } else if (cmd_frame[0] == CMD_SET_AUTOADD_CONFIG && len >= 2) { // autoadd_max_hops stays optional - _prefs.autoadd_config = cmd_frame[1]; - if (len >= 3) { - _prefs.autoadd_max_hops = min(cmd_frame[2], (uint8_t)64); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); } + } else { + memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // set default scope to null + memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); savePrefs(); writeOKFrame(); - } else if (cmd_frame[0] == CMD_GET_AUTOADD_CONFIG) { - int i = 0; - out_frame[i++] = RESP_CODE_AUTOADD_CONFIG; - out_frame[i++] = _prefs.autoadd_config; - out_frame[i++] = _prefs.autoadd_max_hops; - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_GET_ALLOWED_REPEAT_FREQ) { - int i = 0; - out_frame[i++] = RESP_ALLOWED_REPEAT_FREQ; - for (int k = 0; k < sizeof(repeat_freq_ranges)/sizeof(repeat_freq_ranges[0]) && i + 8 < sizeof(out_frame); k++) { - auto r = &repeat_freq_ranges[k]; - memcpy(&out_frame[i], &r->lower_freq, 4); i += 4; - memcpy(&out_frame[i], &r->upper_freq, 4); i += 4; - } - _serial->writeFrame(out_frame, i); - } else if (cmd_frame[0] == CMD_SEND_RAW_PACKET && len >= 4) { - auto pkt = obtainNewPacket(); - if (pkt) { - uint8_t priority = cmd_frame[1]; - if (tryParsePacket(pkt, &cmd_frame[2], len - 2)) { - sendPacket(pkt, priority, 0); - writeOKFrame(); - } else { - releasePacket(pkt); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } + } + return true; +} + +bool MyMesh::cmdGetDefaultFloodScope(size_t) { + out_frame[0] = RESP_CODE_DEFAULT_FLOOD_SCOPE; + if (strlen(_prefs.default_scope_name) > 0) { + memcpy(&out_frame[1], _prefs.default_scope_name, 31); + memcpy(&out_frame[1+31], _prefs.default_scope_key, 16); + _serial->writeFrame(out_frame, 1+31+16); + } else { + _serial->writeFrame(out_frame, 1); // no name or key means null + } + return true; +} + +bool MyMesh::cmdSendControlData(size_t len) { + if ((cmd_frame[1] & 0x80) == 0) return false; + auto resp = createControlData(&cmd_frame[1], len - 1); + if (resp) { + sendZeroHop(resp); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + return true; +} + +// autoadd_max_hops stays optional +bool MyMesh::cmdSetAutoaddConfig(size_t len) { + _prefs.autoadd_config = cmd_frame[1]; + if (len >= 3) { + _prefs.autoadd_max_hops = min(cmd_frame[2], (uint8_t)64); + } + savePrefs(); + writeOKFrame(); + return true; +} + +bool MyMesh::cmdGetAutoaddConfig(size_t) { + int i = 0; + out_frame[i++] = RESP_CODE_AUTOADD_CONFIG; + out_frame[i++] = _prefs.autoadd_config; + out_frame[i++] = _prefs.autoadd_max_hops; + _serial->writeFrame(out_frame, i); + return true; +} + +bool MyMesh::cmdGetAllowedRepeatFreq(size_t) { + int i = 0; + out_frame[i++] = RESP_ALLOWED_REPEAT_FREQ; + for (int k = 0; k < sizeof(repeat_freq_ranges)/sizeof(repeat_freq_ranges[0]) && i + 8 < sizeof(out_frame); k++) { + auto r = &repeat_freq_ranges[k]; + memcpy(&out_frame[i], &r->lower_freq, 4); i += 4; + memcpy(&out_frame[i], &r->upper_freq, 4); i += 4; + } + _serial->writeFrame(out_frame, i); + return true; +} + +bool MyMesh::cmdSendRawPacket(size_t len) { + auto pkt = obtainNewPacket(); + if (pkt) { + uint8_t priority = cmd_frame[1]; + if (tryParsePacket(pkt, &cmd_frame[2], len - 2)) { + sendPacket(pkt, priority, 0); + writeOKFrame(); } else { - writeErrFrame(ERR_CODE_TABLE_FULL); + releasePacket(pkt); + writeErrFrame(ERR_CODE_ILLEGAL_ARG); } } else { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); - MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]); + writeErrFrame(ERR_CODE_TABLE_FULL); } + return true; } static bool save_filter(const ContactInfo& c) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 4f2d87db01f..d2e4b6bdeca 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -220,6 +220,73 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { return _store->putBlobByKey(key, key_len, src_buf, len); } + // The companion protocol, see CMD_TABLE in MyMesh.cpp: one handler per command. + struct CmdHandler { + uint8_t code; + uint8_t min_len; // whole frame, the code byte included + bool (MyMesh::*handle)(size_t len); // false: not this command after all + }; + static const CmdHandler CMD_TABLE[]; + bool cmdDeviceQuery(size_t len); + bool cmdAppStart(size_t len); + bool cmdRunCliCommand(size_t len); + bool cmdSendTxtMsg(size_t len); + bool cmdSendChannelTxtMsg(size_t len); + bool cmdSendChannelData(size_t len); + bool cmdGetContacts(size_t len); + bool cmdSetAdvertName(size_t len); + bool cmdSetAdvertLatlon(size_t len); + bool cmdGetDeviceTime(size_t len); + bool cmdSetDeviceTime(size_t len); + bool cmdSendSelfAdvert(size_t len); + bool cmdResetPath(size_t len); + bool cmdAddUpdateContact(size_t len); + bool cmdRemoveContact(size_t len); + bool cmdShareContact(size_t len); + bool cmdGetContactByKey(size_t len); + bool cmdExportContact(size_t len); + bool cmdImportContact(size_t len); + bool cmdSyncNextMessage(size_t len); + bool cmdSetRadioParams(size_t len); + bool cmdSetRadioTxPower(size_t len); + bool cmdSetTuningParams(size_t len); + bool cmdGetTuningParams(size_t len); + bool cmdSetOtherParams(size_t len); + bool cmdSetPathHashMode(size_t len); + bool cmdReboot(size_t len); + bool cmdGetBattAndStorage(size_t len); + bool cmdExportPrivateKey(size_t len); + bool cmdImportPrivateKey(size_t len); + bool cmdSendRawData(size_t len); + bool cmdSendLogin(size_t len); + bool cmdSendAnonReq(size_t len); + bool cmdSendStatusReq(size_t len); + bool cmdSendPathDiscoveryReq(size_t len); + bool cmdSendTelemetryReq(size_t len); + bool cmdSendBinaryReq(size_t len); + bool cmdHasConnection(size_t len); + bool cmdLogout(size_t len); + bool cmdGetChannel(size_t len); + bool cmdSetChannel(size_t len); + bool cmdSignStart(size_t len); + bool cmdSignData(size_t len); + bool cmdSignFinish(size_t len); + bool cmdSendTracePath(size_t len); + bool cmdSetDevicePin(size_t len); + bool cmdGetCustomVars(size_t len); + bool cmdSetCustomVar(size_t len); + bool cmdGetAdvertPath(size_t len); + bool cmdGetStats(size_t len); + bool cmdFactoryReset(size_t len); + bool cmdSetFloodScopeKey(size_t len); + bool cmdSetDefaultFloodScope(size_t len); + bool cmdGetDefaultFloodScope(size_t len); + bool cmdSendControlData(size_t len); + bool cmdSetAutoaddConfig(size_t len); + bool cmdGetAutoaddConfig(size_t len); + bool cmdGetAllowedRepeatFreq(size_t len); + bool cmdSendRawPacket(size_t len); + void checkCLIRescueCmd(); bool handleCommand(const char* text, uint32_t sender_timestamp, char* reply); void checkSerialInterface(); From 07ff7f0f795d35744d30825127684a7490cb8180 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 19:55:02 +0200 Subject: [PATCH 106/136] tools(refactoring): compare accepted companion commands between two images companion_session.py sends 39 frames that the commands accept (every setter with the value the board already holds, declining sub commands, out of range values, stats, self telemetry, a signing round, channels, CLI) and prints the replies, with clock, uptime and battery masked. Run once per image and diff. On techo-2, 10a against 10b: only the build marker in DEVICE_QUERY and the reply to ver differ. --- tools/refactoring/README.md | 11 +++ tools/refactoring/companion_session.py | 99 ++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tools/refactoring/companion_session.py diff --git a/tools/refactoring/README.md b/tools/refactoring/README.md index 64866b59bb8..43fc8d23c46 100644 --- a/tools/refactoring/README.md +++ b/tools/refactoring/README.md @@ -45,6 +45,17 @@ About 6,800 frames, 12 minutes on a T-Echo. python3 companion_sweep.py ``` +`companion_session.py` complements it with 39 frames the commands accept: every +setter writes back the value the board already holds, so the state is unchanged +afterwards. Run it once per image and diff the two outputs; only the build marker +may differ. + +```sh +python3 companion_session.py > a.txt # first image +python3 companion_session.py > b.txt # second image +diff a.txt b.txt +``` + ## Diagnostic build for step 2 `diag-tuning.patch` adds a `diag tuning` command to the room server and the diff --git a/tools/refactoring/companion_session.py b/tools/refactoring/companion_session.py new file mode 100644 index 00000000000..14117952c4f --- /dev/null +++ b/tools/refactoring/companion_session.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Runs a fixed sequence of accepted companion commands and prints every reply. + + companion_session.py + +companion_sweep.py only sends frames that a command refuses or that only report. +This sends frames the commands accept, so that two firmware images can be compared +on the same board: run it once per image and diff the output. Every setter writes +back the value the board already holds (read first through the protocol), so the +board's state is the same afterwards. Nothing is sent on air, nothing restarts. + +What a reply carries that legitimately differs between two runs (the clock, the +uptime, the battery voltage) is masked in the output. +""" +import os +import struct +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import nodetool # noqa: E402 +from companion_sweep import read_frame # noqa: E402 + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + return 2 + fd = nodetool.comp_open(sys.argv[1]) + + def tx(payload, note, mask=None, want_push=False): + nodetool.read_for(fd, 0.02) + os.write(fd, b"<" + struct.pack(" {shown}", flush=True) + return r + + # read the current values the setters will write back + info = tx(bytes([1, 3, 0, 0, 0, 0, 0, 0]) + b"session", "APP_START") + dev = tx(bytes([22, 3]), "DEVICE_QUERY") + tun = tx(bytes([43]), "GET_TUNING_PARAMS") + auto = tx(bytes([59]), "GET_AUTOADD_CONFIG") + scope = tx(bytes([64]), "GET_DEFAULT_FLOOD_SCOPE") + if not (info and dev and tun and auto and scope): + print("could not read the current values, stopping") + return 1 + + tx_power, lat, lon = info[2], info[36:40], info[40:44] + multi_acks, loc_policy, telemetry, manual_add = info[44], info[45], info[46], info[47] + freq, bw, sf, cr, name = info[48:52], info[52:56], info[56], info[57], info[58:] + pin, repeat, hash_mode = dev[4:8], dev[80], dev[81] + + tx(bytes([12, tx_power]), "SET_RADIO_TX_POWER (same)") + tx(bytes([11]) + freq + bw + bytes([sf, cr, repeat]), "SET_RADIO_PARAMS (same)") + tx(bytes([21]) + tun[1:9], "SET_TUNING_PARAMS (same)") + tx(bytes([38, manual_add, telemetry, loc_policy, multi_acks]), "SET_OTHER_PARAMS (same)") + tx(bytes([58]) + auto[1:3], "SET_AUTOADD_CONFIG (same)") + tx(bytes([61, 0, hash_mode]), "SET_PATH_HASH_MODE (same)") + tx(bytes([8]) + name, "SET_ADVERT_NAME (same)") + tx(bytes([14]) + lat + lon, "SET_ADVERT_LATLON (same)") + tx(bytes([37]) + pin, "SET_DEVICE_PIN (same)") + if len(scope) == 48: + tx(bytes([63]) + scope[1:48], "SET_DEFAULT_FLOOD_SCOPE (same)") + tx(bytes([54, 0]), "SET_FLOOD_SCOPE_KEY clear") + tx(bytes([54, 2]), "SET_FLOOD_SCOPE_KEY sub 2") + tx(bytes([61, 1, hash_mode]), "SET_PATH_HASH_MODE byte 1 not 0") + tx(bytes([61, 0, 3]), "SET_PATH_HASH_MODE 3") + tx(bytes([12, 0x7F]), "SET_RADIO_TX_POWER 127") + tx(bytes([6]) + struct.pack(" Date: Tue, 20 Oct 2026 19:55:02 +0200 Subject: [PATCH 107/136] docs(refactoring): record the verification of step 10b Host suites and mutations aimed at the table, the build matrix with flash and RAM for 13 companion environments on five platform packages plus two repeaters as the control, and the hardware runs on a T-Echo (A/B session and frame sweep). --- .../0010-companion-command-table.md | 116 +++++++++++++++--- 1 file changed, 100 insertions(+), 16 deletions(-) diff --git a/docs/refactoring/0010-companion-command-table.md b/docs/refactoring/0010-companion-command-table.md index 7830e320abe..60c6a5137a0 100644 --- a/docs/refactoring/0010-companion-command-table.md +++ b/docs/refactoring/0010-companion-command-table.md @@ -1,7 +1,7 @@ # 0010: A command table for the companion protocol -Status: step 10a (host harness and characterization) ready for review; step 10b -(the table itself) designed here, not yet built. Behaviour change: none. +Status: steps 10a (host harness and characterization) and 10b (the table) ready +for review; 10c and 10d are proposals. Behaviour change: none. ## Context @@ -162,9 +162,10 @@ Each branch body moves, unchanged apart from the outer test, into a method condition test it first and return `false`, which reproduces the fall through: ```c++ -bool MyMesh::cmdSetPathHashMode(size_t len) { - if (cmd_frame[1] != 0) return false; // was: && cmd_frame[1] == 0 - ... +bool MyMesh::cmdSetPathHashMode(size_t) { + if (cmd_frame[1] != 0) return false; + if (cmd_frame[2] >= 3) { + ... return true; } ``` @@ -183,21 +184,20 @@ visible `1` in a table rather than an absent clause. ### Cost on the targets -To be measured in 10b on the full companion matrix (nRF52, ESP32, RP2040, STM32, -BLE, USB, WiFi) against `integration`. Expected from the shapes involved, not -measured: on ARM a pointer to member function is 8 bytes, so the table is 61 -rows of 12 bytes, about 730 bytes of flash and no RAM (the table is `const`). -The chain itself shrinks, since the 62 comparisons disappear. Each handler gains -a prologue and an epilogue, and GCC may stop sharing code between branches that -used to be one function. A net change of up to 2 kB of flash on nRF52 would be -acceptable (the RAK 4631 BLE companion uses 462 kB of 796 kB); more than that, -and the cheaper variant below is the fallback. +Measured in 10b, see "Verification of step 10b" for the table. RAM is unchanged +in every environment (the table is `const`, 59 rows). Flash grows by about +1.6 to 1.7 kB on nRF52, 1.5 kB on STM32, 1.2 to 1.9 kB on RP2040, 2.6 kB on +ESP32-C3 and 3.5 kB on ESP32-S3, and shrinks by 1.3 kB on ESP32-C6. The design +set 2 kB on nRF52 as the bound beyond which the cheaper `switch` variant would be +used instead; the measured cost is inside it. The extra flash is the 59 function +prologues and epilogues the chain did not need, plus the table (on ARM a pointer +to member function is 8 bytes, so a row is 12). ### Steps | Step | Content | Behaviour | |---|---|---| -| 10a | Host harness for `MyMesh.cpp`, frame sweep, per command tests (this change) | none | +| 10a | Host harness for `MyMesh.cpp`, frame sweep, per command tests | none | | 10b | `CMD_TABLE` and one method per command, bodies moved unchanged | none, the sweep must stay byte identical | | 10c | A bounded frame reader (`FrameReader r(cmd_frame, len); r.u8(); r.u32(); r.bytes(n)`) in the handlers, replacing the hand counted offsets | none; a reader that runs out reports it instead of reading the tail | | 10d | Optional, a protocol change: answer a too short frame with `ILLEGAL_ARG` | visible to apps, needs its own proposal | @@ -341,7 +341,7 @@ All host environments, on the committed tree, including the new one: ``` native_companion test_companion_frames PASSED ... (the other 21 suites in 13 environments) PASSED -================ 404 test cases: 404 succeeded ================ +================ 414 test cases: 414 succeeded ================ ``` `native_companion` is added to `.github/workflows/run-unit-tests.yml`, which @@ -405,3 +405,87 @@ key import, contact and channel changes). For those the host tests are the evidence; the length fixes recorded hardware runs for some of them. Only nRF52 was run; ESP32 and RP2040 companions compile the same `MyMesh.cpp`, and their build is checked by CI, not their behaviour. + +## Verification of step 10b + +### What changed + +`handleCmdFrame()` is the dispatcher above, followed by 59 handlers in the order +of the old chain. The bodies were moved by a script, not retyped: of the 863 +lines the diff removes from `MyMesh.cpp`, 796 reappear with the same text, and +the 67 without a counterpart are exactly the 62 branch heads, the final `else` +(now the dispatcher's fallback) and the three early `return;` of +`SEND_CHANNEL_DATA`, now `return true;`. `MyMesh.h` gains the row type, the +table and the 59 declarations, all private. + +### Host + +The suites of 10a pass unchanged, test files and golden file untouched: the frame +sweep (every code, every length, five fills, replies byte for byte and a digest of +every queued packet and stored file) and the 49 command tests. All host suites on +the committed tree: 414 of 414. + +The 10a mutations were aimed at the chain. These are aimed at the table, each on +its own: + +| Mutation | Caught by | +|---|---| +| `SET_TUNING_PARAMS` row minimum 9 becomes 8 | sweep, `TuningParamsRoundTrip` | +| `SEND_TELEMETRY_REQ` row minimum 4 becomes 5 | sweep, `TelemetryHasTwoForms` | +| `ADD_UPDATE_CONTACT` row minimum loses the 4 timestamp bytes | sweep, `AddContactNeedsEveryMandatoryField` | +| the row of `GET_TUNING_PARAMS` is missing | sweep, `TuningParamsRoundTrip` | +| the dispatcher ignores a handler that declines | sweep and 6 command tests | +| the dispatcher skips the minimum | 32 tests fail | +| `cmdSetPathHashMode` no longer declines byte 1 other than 0 | sweep, `PathHashMode` | +| `cmdSendControlData` declines the wrong flag value | sweep, `ControlData` | + +### Builds + +15 environments built from 10a (`2f5cf53c`) and from 10b with the same +toolchain, each in its own worktree. All build on both sides. + +| Environment | Flash 10a | Flash 10b | Delta | RAM delta | +|---|---:|---:|---:|---:| +| `Heltec_v3_companion_radio_ble` | 1,666,365 | 1,669,897 | +3,532 | +0 | +| `Heltec_v3_companion_radio_wifi` | 1,102,533 | 1,105,985 | +3,452 | +0 | +| `Heltec_v3_companion_radio_usb` | 652,925 | 656,449 | +3,524 | +0 | +| `RAK_4631_companion_radio_ble` | 462,228 | 463,956 | +1,728 | +0 | +| `RAK_4631_companion_radio_usb` | 453,188 | 454,916 | +1,728 | +0 | +| `ProMicro_companion_radio_usb` | 356,980 | 358,708 | +1,728 | +0 | +| `LilyGo_T-Echo_companion_radio_ble` | 373,776 | 375,360 | +1,584 | +0 | +| `LilyGo_T-Echo_companion_radio_usb` | 366,384 | 367,968 | +1,584 | +0 | +| `PicoW_companion_radio_ble` | 632,364 | 633,580 | +1,216 | +0 | +| `PicoW_companion_radio_wifi` | 526,780 | 528,708 | +1,928 | +0 | +| `LilyGo_Tlora_C6_companion_radio_ble_` | 1,446,171 | 1,444,861 | -1,310 | +0 | +| `wio-e5-mini_companion_radio_usb` | 207,472 | 208,944 | +1,472 | +0 | +| `Xiao_C3_companion_radio_ble` | 1,252,846 | 1,255,424 | +2,578 | +0 | +| `Heltec_v3_repeater` | 1,174,625 | 1,174,625 | +0 | +0 | +| `RAK_4631_repeater` | 441,656 | 441,656 | +0 | +0 | + +The thirteen companions cover the five platform packages that compile +`MyMesh.cpp` (nRF52, Espressif 32 for S3 and C3, pioarduino for C6, RP2040, +STM32) and the BLE, USB and WiFi interfaces. The two repeaters do not compile the +file and are there as the control. The tightest flash budget among them is the +STM32WLE5 of `wio-e5-mini` (256 kB), which goes from 79.1 % to 79.7 %. + +### Hardware + +The same T-Echo, `techo-2`, with two USB companion images built from 10a and from +10b, marked `c10a-int` and `c10b-tab` and confirmed by `ver` before each run. + +- `tools/refactoring/companion_session.py` sends 39 frames the commands accept: + every setter with the value the board already holds (TX power, radio, tuning, + other parameters, auto add, path hash mode, name, location, PIN, default scope), + the declining sub commands, out of range values, stats, self telemetry, a whole + signing round, channels, two CLI commands and the wrong reboot and reset words. + Run once per image and compared: the only two differing lines are the build + marker inside `DEVICE_QUERY` and the reply to `ver`. +- `companion_sweep.py` against the 10b image: 6502 of 6502 frames as on the host. +- State before and after, read as in 10a: identical apart from the build marker. +- Afterwards the official 1.18.0 BLE image went back on, and the phone that uses + the board reconnected 4 seconds later. + +Not covered on hardware: the setters with new values, sends on air, `REBOOT`, +`FACTORY_RESET`, private key import, contact and channel changes. Their handlers +are moved text, pinned on the host by the sweep and the command tests. Only nRF52 +ran on hardware. From bb5b10401532effaac6586b466944b3a6ee7e207 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 20:15:54 +0200 Subject: [PATCH 108/136] docs(refactoring): list step 10 in the step table The companion command table (note 0010) is merged now, so it gets its row next to steps 9 and 11, and the sentence that called it in progress goes. --- docs/refactoring/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index 959d692b5a0..f9d3dcc3c14 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -52,12 +52,12 @@ The full analysis, with file and line references for every finding, is in | 3 | Drive the radio settings (JSON, CLI, key/value store) from one table | [0003](0003-radio-settings-table.md) | ready for review | | 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4g; the boot sequence and the forwarding checks are the last two) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md), [0008](0008-forward-policy.md) | ready for review | | 9 | One RadioLib adapter per chip family, part one: the receive detection the seven chip classes shared as copies (step 9a; 9b to 9d planned in the note) | [0009](0009-radio-chip-adapter.md) | 9a ready for review | +| 10 | A command table for the companion protocol: a host harness that pins every frame (10a), then one row per command code instead of the if chain (10b); 10c and 10d proposed in the note | [0010](0010-companion-command-table.md) | 10a and 10b ready for review | | 11 | One generic variant target built from a board description, cut A: one shared `radio_new_identity()` (cuts B to E planned in the note) | [0011](0011-variant-target.md) | cut A ready for review | Steps are numbered after their design note, so the numbers have gaps: 5 to 8 -are notes of fixes and of step 4. Step 10, a companion frame codec with a -command table (note 0010), is in progress on its own branch. See the review for -the reasoning behind the order. +are notes of fixes and of step 4. See the review for the reasoning behind the +order. ## Verifying a step yourself From 91ea10c637bd79c1ed29f45ed41aba3e5453c1f6 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 20:26:09 +0200 Subject: [PATCH 109/136] docs(refactoring): record cut B of note 0008, one FEM RX gain handler The section says what moved into src/helpers/FemRxGain.h and why a class template, how the null store check relates to the open upstream pull request meshcore-dev/MeshCore#3496 (and to #3453 and #2682, which touch the same files), the host tests and the mutation table, and the before and after builds of all 50 environments of the four boards plus the 18 of the series: RAM unchanged, flash +16 on T096 and +24 to +72 on the ESP32 boards, measured down to the functions. --- docs/refactoring/0008-variant-target.md | 310 +++++++++++++++++++++++- 1 file changed, 307 insertions(+), 3 deletions(-) diff --git a/docs/refactoring/0008-variant-target.md b/docs/refactoring/0008-variant-target.md index e4a114928c2..bb20b3f2467 100644 --- a/docs/refactoring/0008-variant-target.md +++ b/docs/refactoring/0008-variant-target.md @@ -1,6 +1,7 @@ # 0008: One generic variant target built from a board description -Status: cut A (one shared `radio_new_identity()`) implemented. Cuts B to E are +Status: cut A (one shared `radio_new_identity()`) and cut B (one shared FEM RX gain +handler) implemented, each its own pull request, B stacked on A. Cuts C to E are planned and need their own review before code. Behaviour change: none. ## Context @@ -82,8 +83,8 @@ weak" below). | cut | what | files touched | status | |---|---|---|---| -| A | one `radio_new_identity()` for all RadioLib variants | 87 `target.cpp`, 1 new source, 1 header | this step | -| B | one class for the FEM gain handlers of HeltecV4, HeltecV4R8, T096, HeltecTrackerV2 | 4 board classes, 1 new helper | planned | +| A | one `radio_new_identity()` for all RadioLib variants | 87 `target.cpp`, 1 new source, 1 header | implemented, see "Cut A" | +| B | one class for the FEM gain handlers of HeltecV4, HeltecV4R8, T096, HeltecTrackerV2 | 4 board classes, 1 new helper | implemented, see "Cut B" | | C | a shared default `radio_init()` for the largest groups (28 + 18 + 5 + 4 + 3 of 89) | about 58 `target.cpp` | planned | | D | the radio, wrapper and RTC globals from the board's build flags | about 87 `target.cpp` | planned (description: build flags, decided) | | E | sensors, display and button from the board description | the rest | planned | @@ -404,6 +405,309 @@ boot, which nothing in this step can change differently per board. If a reviewer wants one, the check is: erase the identity on a board (`/identity` on the file system), boot this firmware, see a new public key in `ver`/advert. +## Cut B: one FEM RX gain handler + +### What changes + +- `src/helpers/FemRxGain.h` (new) holds the bodies of `attachDynamicPrefs()` and + `handleCommand()` that HeltecV4Board, HeltecV4R8Board, T096Board and + HeltecTrackerV2Board carried, as a class template on the board class: + ```cpp + template + class FemRxGain { + KeyValueStore* _prefs = NULL; + public: + void attachPrefs(BOARD& board, KeyValueStore* prefs); + bool handleCommand(BOARD& board, const char* command, char* reply); + }; + ``` + The bodies are the boards' own, with `board.` in front of the board members they + use (`loRaFEMControl`, `setLoRaFemLnaEnabled()`, `isLoRaFemLnaEnabled()`) and one + difference, the null check described below. +- Each of the four board headers replaces `KeyValueStore* _prefs = NULL;` with + ```cpp + friend class FemRxGain; + FemRxGain fem_rxgain; + ``` + and includes the new header. Each board source keeps both functions, as one line + that forwards to `fem_rxgain`. The diff in `variants/` is 8 files, 20 lines added, + 156 removed. +- Unchanged: the boards' base classes, their public interface (both functions keep + their signature, `handleCommand()` still overrides `MainBoard`), their own + `setLoRaFemLnaEnabled()` and `isLoRaFemLnaEnabled()`, which stay private + (protected in T096Board), and their `LoRaFEMControl` classes. + +Measured with `tools/refactoring/variant_duplication.py` after the step: the four +boards' `attachDynamicPrefs()` is 94 characters normalized (was 217), +`handleCommand()` 146 (was 999), still one body for the four, now a forwarding line. +TBeam1W, StationG3 and WioTrackerL1 keep their own handlers (fan settings, other +commands), as planned. + +### Why it is the same code + +- The bodies were compared as text before the move (one body for the four once the + class name is replaced, see "Measured on the commit this step started from"). The + host tests run a verbatim copy of the HeltecV4Board bodies and the shared template + against the same cases, with the same expectations (see "Host tests"). +- `_prefs` keeps its place: it was the first data member of each board and + `fem_rxgain` is now, and `FemRxGain` holds nothing but that pointer (a test checks + `sizeof(FemRxGain<...>) == sizeof(KeyValueStore*)`). So the boards keep size and + layout, and RAM is unchanged in every build (see "Builds"). +- The callers are unchanged: `ServerMesh::begin()` and the companion's `begin()` call + `board.attachDynamicPrefs(_prefs.getCustom())`, `CommonCLI` calls + `_board->handleCommand(...)` (the three server roles). Both go through the board's + own functions as before. + +### The null store, and how this relates to meshcore-dev/MeshCore#3496 + +The open upstream pull request #3496 (KISS modem AGC and FEM gain control) changes +the two `setByKey` lines in each of the four boards to +`if (_prefs) _prefs->setByKey(...)`, because its KISS modem calls the board's +`handleCommand()` without ever calling `attachDynamicPrefs()`, and the copies then +dereference NULL. The shared code has the same check, in the same two places: + +```cpp +if (_prefs) _prefs->setByKey("fem_rxgain", "1"); +``` + +- Behaviour in this tree: unchanged. The KISS modem here does not call + `handleCommand()`, and every role that does (the three server roles through + `CommonCLI`) attaches the store in `ServerMesh::begin()` first. So no reply and no + store write changes; only the case that dereferenced NULL before is now defined + (the LNA is switched, the reply is the same, nothing is stored). A test pins that. +- `attachPrefs(board, NULL)` is not guarded, as in the copies and in #3496: no caller + passes NULL, and what a board should do without a store at boot (LNA on, off or + unchanged) is a product decision this step does not make. +- Order: the check is taken over now so that the shared code is correct for either + order. If this step lands first, #3496 drops its four variant hunks on rebase (they + edit lines that no longer exist and the shared code already has the check); the + rest of #3496 is not touched by this step. If #3496 lands first, this step's + variant diff deletes the guarded lines instead of the unguarded ones and nothing + else changes. Checked on 20.10.2026: the variant hunks of #3496 are exactly those + two lines per board, 8 in all (`git diff upstream/dev pull/3496/head` per file), and + applied to this step's tree with `git apply --check -3` all four conflict, as + expected. + +The other two upstream pull requests that touch these four files: + +- **#3453** (standardized `powerOff()`, `shutdownPeripherals()`, `enterDeepSleep()`, + open) edits `powerOff()` in all four sources and headers and removes + `HeltecV4R8Board::enterDeepSleep()`. It does not change the FEM handlers. Applied to + this step's tree with `git apply --check -3`: six of the eight files apply cleanly, + `HeltecV4Board.cpp` and `HeltecTrackerV2Board.cpp` conflict on one hunk that only + re-indents the closing brace of `handleCommand()`, a brace this step replaces. A + rebase resolution, no logic. +- **#2682** (ThinkNode M4 variant, open) lists the four files, but its head is + identical to `upstream/dev` in all of them (0 changed lines, + `git diff upstream/dev pull/2682/head` per file): the entries come from a merge of + `dev` into the branch. No conflict. + +### Alternatives considered + +- **A base class with virtual calls** (`class FemLnaBoard { virtual bool + setLoRaFemLnaEnabled(bool) = 0; ... }` and a non-template handler compiled once). + Rejected: it adds a vtable pointer to each board object (RAM) and indirect calls + for code that exists once per firmware anyway, because a firmware contains exactly + one board. Upstream had such virtuals on `MainBoard` (`setLoRaFemLnaEnabled()`, + `canControlLoRaFemLna()`, `isLoRaFemLnaEnabled()`, with the commands in `CommonCLI`) + and removed them in August 2026 (`47b0b7b3`, `a1cf5bd8`), moving the commands into + the boards; going back to them is not this step's call. +- **A mixin between the board and its base** (`class HeltecV4Board : public + FemRxGainBoard`). Rejected: it changes each board's base + class, and T096Board initializes its virtual base `NRF52Board` directly, which a + mixin template would have to forward. The forwarding lines it saves are two per + board. +- **Move `setLoRaFemLnaEnabled()` and `isLoRaFemLnaEnabled()` into the shared code + as well.** Not in this step: they are identical in the four, but they belong with + the boards' `LoRaFEMControl`, which is its own class per variant (the four boards' + `LoRaFEMControl.cpp` all differ, as do those of HeltecTowerV2 and StationG3). That + is a separate concern for a later step. +- **Template on the FEM control class instead of the board.** Rejected: the handler + needs the board's `setLoRaFemLnaEnabled()`, which also puts the FEM back into RX + mode, not only the FEM control. + +### Host tests + +`test_fem_rxgain` (new, in the `native` environment, header only) is a typed suite +that runs the same cases against two boards built on a stand-in for the boards' +common parts (public `loRaFEMControl`, the boards' own `setLoRaFemLnaEnabled()` and +`isLoRaFemLnaEnabled()`, plus a switch that makes a switch fail): + +- the first commit: a verbatim copy of the HeltecV4Board bodies, class name replaced, +- the second commit adds the shared `FemRxGain`, with the cases unchanged. + +The store is a recording `KeyValueStore` whose `getByKey()` copies like +`DynamicConfigSerializer` (strncpy, terminator at `max_len`). Commands are passed in a +zero filled buffer, as `CommonCLI` passes them. Pinned: + +- attach reads `fem_rxgain` once with `max_len` 7, switches the LNA on only for exactly + `"1"` (off for `"0"`, `""`, `"on"`, `"10"`, `" 1"`, `"1 "`, `"true"`, a missing key and a + value cut to seven characters), tries even when the LNA cannot be controlled, and + writes nothing, +- `get radio.fem.rxgain`: `> on`, `> off`, or `Error: unsupported`, +- `set radio.fem.rxgain on|off`: switches the LNA, writes `"1"` or `"0"` to the store + attached last, replies `OK - LoRa FEM RX gain on|off`, ignores the store's return + value; the state matches by its first two or three characters (`onward`, `offline`); + any other state is `Error: state must be on or off`; `Error: unsupported` when the + LNA cannot be controlled; `Error: failed to apply LoRa FEM RX gain` and nothing + stored when the switch fails, +- every other command (`get radio.fem.rxgain ` with a trailing space, `set + radio.fem.rxgain` without one, other keys, upper case, empty) is not handled and + the reply is left as it was. + +The second commit adds two tests for the shared code only: `set` without an attached +store switches the LNA and replies as with one, and `FemRxGain` is the size of a +pointer. + +All 15 host environments pass on both commits: 289 test cases on the first, 307 on +the second (273 on cut A). + +Mutation tests on the shared code, each of which must fail: + +| mutation | result | +|---|---| +| M1: drop the null check before the `"1"` write | the test without a store crashes (SIGSEGV), the suite fails | +| M2: store `"on"` instead of `"1"` | 1 test fails | +| M3: attach switches on for anything but `"1"` (`!= 0`) | 6 tests fail | +| M4: match the `set` prefix with 20 instead of 21 characters | 1 test fails (`set radio.fem.rxgain` without the space is handled) | +| M5: read the stored value with `max_len` 8 | the terminator lands outside the 8 byte buffer, the stack protector aborts the suite | +| M6: write `"0"` before switching the LNA off | 3 tests fail | +| M7: match `off` by two characters | 1 test fails (`of` is taken as off) | +| M8: reply `>on` instead of `> on` | 2 tests fail | +| M9: drop the `unsupported` check of `set` | 1 test fails | + +### Builds + +68 environments were built from cut A (`a3d3961e`, the commit this step started +from; the test commit changes no firmware source) and from the code commit of this +step (`2884fd34`), each side in its own worktrees, with the same toolchain: + +- the 18 environments every step of the series is built for (all roles, all + platforms), +- all 50 environments of the four boards (every environment whose + `build_src_filter` names `variants/heltec_v4`, `heltec_v4_r8`, `heltec_t096` or + `heltec_tracker_v2`, from `pio project config`): repeater, room server, sensor, + terminal chat, companion USB, BLE and WiFi, KISS modem and the bridges, OLED and + TFT. No other source file includes the four board headers (checked with `grep`). + +Platforms in the set: 49 ESP32, 15 nRF52, 2 STM32WL, 2 RP2040. Of the four boards, +T096 is nRF52 (7 environments) and the other three are ESP32-S3 (43). + +Result: + +- 67 build on both commits. `heltec_tracker_v2_kiss_modem` fails on both with the + same error (`examples/kiss_modem/main.cpp:118`, `USBCDC` has no + `setTxBufferSize`), in the KISS modem, not in the board; the other three boards' + KISS modem environments build and link the shared code. +- Five builds were stopped by a signal (exit 143) on the first run, by another + session's `pkill` on the same machine, and were rebuilt from clean: all five + build. +- RAM is unchanged in all 67. +- Flash: unchanged in the 18 series environments (none builds one of the four + boards). In the 49 environments of the four boards it grows by 16 to 72 bytes: + - T096 (nRF52), 7 environments: +16. Measured with `nm`: `T096Board::handleCommand()` + and its virtual thunk (the board has a virtual base) are each 8 bytes larger + (0x148 to 0x150, 0x14c to 0x150), the two null checks; the template is inlined. + - ESP32, 42 environments: +24 to +72, most +56. On `heltec_v4_repeater` (+56): + `.flash.text` +32, of which `FemRxGain::handleCommand()` is 8 bytes + larger than the board's own body was (0xe2 to 0xea, the null checks) and the + board's `handleCommand()` is now a forwarder of 0x15 bytes, with alignment + between functions; `.flash.rodata` +24, not attributed further. + `attachDynamicPrefs()` is inlined into the board's function and keeps its size + (0x52). + +The growth is the price of the null check and, on ESP32, of one forwarding function +that the compiler does not merge with the template. A board without a virtual base +could avoid the forwarder only by making `handleCommand()` the template's own +override (the rejected mixin). + +
+All 68 environments + +| Environment | Flash before | Flash after | Flash delta | RAM before | RAM after | RAM delta | +|---|---:|---:|---:|---:|---:|---:| +| `Heltec_v3_repeater` | 1,174,209 | 1,174,209 | +0 | 62,512 | 62,512 | +0 | +| `Heltec_v3_room_server` | 1,169,497 | 1,169,497 | +0 | 66,200 | 66,200 | +0 | +| `Heltec_v3_companion_radio_ble` | 1,666,097 | 1,666,097 | +0 | 194,844 | 194,844 | +0 | +| `Heltec_v3_companion_radio_wifi` | 1,102,269 | 1,102,269 | +0 | 172,384 | 172,384 | +0 | +| `Heltec_v3_sensor` | 1,180,717 | 1,180,717 | +0 | 60,600 | 60,600 | +0 | +| `RAK_4631_repeater` | 441,368 | 441,368 | +0 | 37,240 | 37,240 | +0 | +| `RAK_4631_room_server` | 434,552 | 434,552 | +0 | 40,928 | 40,928 | +0 | +| `RAK_4631_companion_radio_ble` | 462,068 | 462,068 | +0 | 149,768 | 149,768 | +0 | +| `RAK_4631_sensor` | 438,136 | 438,136 | +0 | 35,328 | 35,328 | +0 | +| `PicoW_repeater` | 492,420 | 492,420 | +0 | 85,368 | 85,368 | +0 | +| `wio-e5-mini_repeater` | 203,832 | 203,832 | +0 | 17,172 | 17,172 | +0 | +| `wio-e5-mini_sensor` | 203,304 | 203,304 | +0 | 15,276 | 15,276 | +0 | +| `RAK_3112_repeater_bridge_espnow` | 1,175,521 | 1,175,521 | +0 | 64,072 | 64,072 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | 240,832 | 240,832 | +0 | 26,704 | 26,704 | +0 | +| `ProMicro_repeater` | 345,824 | 345,824 | +0 | 33,892 | 33,892 | +0 | +| `ProMicro_room_server` | 338,920 | 338,920 | +0 | 37,588 | 37,588 | +0 | +| `ProMicro_companion_radio_usb` | 356,836 | 356,836 | +0 | 99,468 | 99,468 | +0 | +| `ProMicro_sensor` | 342,476 | 342,476 | +0 | 31,988 | 31,988 | +0 | +| `heltec_v4_r8_repeater` | 1,175,793 | 1,175,857 | +64 | 62,400 | 62,400 | +0 | +| `heltec_v4_r8_room_server` | 1,171,153 | 1,171,209 | +56 | 66,088 | 66,088 | +0 | +| `heltec_v4_r8_terminal_chat` | 603,329 | 603,389 | +60 | 95,752 | 95,752 | +0 | +| `heltec_v4_r8_companion_radio_usb` | 654,449 | 654,509 | +60 | 103,288 | 103,288 | +0 | +| `heltec_v4_r8_companion_radio_ble` | 1,667,785 | 1,667,845 | +60 | 194,732 | 194,732 | +0 | +| `heltec_v4_r8_companion_radio_wifi` | 1,103,937 | 1,103,993 | +56 | 172,272 | 172,272 | +0 | +| `heltec_v4_r8_sensor` | 1,182,385 | 1,182,441 | +56 | 60,488 | 60,488 | +0 | +| `heltec_v4_r8_tft_repeater` | 1,177,885 | 1,177,941 | +56 | 62,496 | 62,496 | +0 | +| `heltec_v4_r8_tft_room_server` | 1,173,109 | 1,173,165 | +56 | 66,192 | 66,192 | +0 | +| `heltec_v4_r8_tft_terminal_chat` | 603,721 | 603,777 | +56 | 95,752 | 95,752 | +0 | +| `heltec_v4_r8_tft_companion_radio_usb` | 658,313 | 658,369 | +56 | 103,440 | 103,440 | +0 | +| `heltec_v4_r8_tft_companion_radio_ble` | 1,284,809 | 1,284,869 | +60 | 172,044 | 172,044 | +0 | +| `heltec_v4_r8_tft_companion_radio_wifi` | 1,107,789 | 1,107,845 | +56 | 172,424 | 172,424 | +0 | +| `heltec_v4_r8_tft_sensor` | 1,184,369 | 1,184,429 | +60 | 60,592 | 60,592 | +0 | +| `heltec_v4_r8_kiss_modem` | 582,409 | 582,465 | +56 | 24,464 | 24,464 | +0 | +| `heltec_v4_r8_tft_kiss_modem` | 582,797 | 582,853 | +56 | 24,464 | 24,464 | +0 | +| `Heltec_t096_repeater` | 413,904 | 413,920 | +16 | 35,564 | 35,564 | +0 | +| `Heltec_t096_repeater_bridge_rs232` | 416,144 | 416,160 | +16 | 37,148 | 37,148 | +0 | +| `Heltec_t096_room_server` | 407,040 | 407,056 | +16 | 39,260 | 39,260 | +0 | +| `Heltec_t096_sensor` | 410,724 | 410,740 | +16 | 33,660 | 33,660 | +0 | +| `Heltec_t096_companion_radio_ble` | 431,524 | 431,540 | +16 | 148,052 | 148,052 | +0 | +| `Heltec_t096_companion_radio_usb` | 424,788 | 424,804 | +16 | 101,140 | 101,140 | +0 | +| `Heltec_t096_kiss_modem` | 340,360 | 340,376 | +16 | 21,700 | 21,700 | +0 | +| `heltec_v4_repeater` | 1,172,273 | 1,172,329 | +56 | 62,476 | 62,476 | +0 | +| `heltec_v4_expansionkit_repeater` | 1,172,341 | 1,172,393 | +52 | 62,476 | 62,476 | +0 | +| `heltec_v4_repeater_bridge_espnow` | 1,182,797 | 1,182,857 | +60 | 64,132 | 64,132 | +0 | +| `heltec_v4_room_server` | 1,167,677 | 1,167,745 | +68 | 66,164 | 66,164 | +0 | +| `heltec_v4_terminal_chat` | 599,877 | 599,933 | +56 | 95,832 | 95,832 | +0 | +| `heltec_v4_companion_radio_usb` | 651,001 | 651,057 | +56 | 103,368 | 103,368 | +0 | +| `heltec_v4_companion_radio_ble` | 1,664,305 | 1,664,357 | +52 | 194,808 | 194,808 | +0 | +| `heltec_v4_companion_radio_wifi` | 1,100,445 | 1,100,501 | +56 | 172,348 | 172,348 | +0 | +| `heltec_v4_sensor` | 1,178,909 | 1,178,965 | +56 | 60,564 | 60,564 | +0 | +| `heltec_v4_tft_repeater` | 1,174,021 | 1,174,077 | +56 | 62,572 | 62,572 | +0 | +| `heltec_v4_tft_repeater_bridge_espnow` | 1,184,529 | 1,184,581 | +52 | 64,228 | 64,228 | +0 | +| `heltec_v4_tft_room_server` | 1,169,221 | 1,169,277 | +56 | 66,268 | 66,268 | +0 | +| `heltec_v4_tft_terminal_chat` | 599,877 | 599,933 | +56 | 95,832 | 95,832 | +0 | +| `heltec_v4_tft_companion_radio_usb` | 652,677 | 652,733 | +56 | 103,464 | 103,464 | +0 | +| `heltec_v4_tft_companion_radio_ble` | 1,279,125 | 1,279,181 | +56 | 172,056 | 172,056 | +0 | +| `heltec_v4_tft_companion_radio_wifi` | 1,102,173 | 1,102,229 | +56 | 172,444 | 172,444 | +0 | +| `heltec_v4_tft_sensor` | 1,180,513 | 1,180,569 | +56 | 60,668 | 60,668 | +0 | +| `heltec_v4_kiss_modem` | 578,941 | 578,997 | +56 | 24,544 | 24,544 | +0 | +| `heltec_tracker_v2_repeater` | 1,225,437 | 1,225,501 | +64 | 74,344 | 74,344 | +0 | +| `heltec_tracker_v2_repeater_bridge_espnow` | 1,235,873 | 1,235,917 | +44 | 76,000 | 76,000 | +0 | +| `heltec_tracker_v2_room_server` | 1,220,737 | 1,220,793 | +56 | 78,040 | 78,040 | +0 | +| `heltec_tracker_v2_terminal_chat` | 627,357 | 627,413 | +56 | 107,480 | 107,480 | +0 | +| `heltec_tracker_v2_companion_radio_usb` | 707,033 | 707,105 | +72 | 115,272 | 115,272 | +0 | +| `heltec_tracker_v2_companion_radio_ble` | 1,333,625 | 1,333,681 | +56 | 183,836 | 183,836 | +0 | +| `heltec_tracker_v2_companion_radio_wifi` | 1,153,609 | 1,153,633 | +24 | 184,224 | 184,224 | +0 | +| `heltec_tracker_v2_sensor` | 1,231,965 | 1,232,021 | +56 | 72,440 | 72,440 | +0 | +| `heltec_tracker_v2_kiss_modem` | FAIL | FAIL | | | | | + +
+ +### Hardware + +No hardware evidence. None of the four boards (Heltec V4, V4 R8, T096, Tracker V2) is +in the device pool (`kb device list`, 20.10.2026). The handlers' code is unchanged +apart from the null check, the host tests show the same replies and store writes, and +the builds show every environment of the four boards compiles and links the shared +code with unchanged RAM. If a reviewer wants a board run, the check on any of the +four: `get radio.fem.rxgain`, `set radio.fem.rxgain off`, reboot, `get radio.fem.rxgain` +shows `> off`; the same with `on`; on a board whose FEM cannot switch the LNA both +reply `Error: unsupported`. + ## Upstream Searched meshcore-dev/MeshCore on 20.10.2026: text queries over all pull requests From e5a8f1641bde3f1188ace74d664a5b021f3bcc61 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 20:42:42 +0200 Subject: [PATCH 110/136] docs(refactoring): list cut B of step 11 in the step table --- docs/refactoring/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index f9d3dcc3c14..f599308735e 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -53,7 +53,7 @@ The full analysis, with file and line references for every finding, is in | 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4g; the boot sequence and the forwarding checks are the last two) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md), [0008](0008-forward-policy.md) | ready for review | | 9 | One RadioLib adapter per chip family, part one: the receive detection the seven chip classes shared as copies (step 9a; 9b to 9d planned in the note) | [0009](0009-radio-chip-adapter.md) | 9a ready for review | | 10 | A command table for the companion protocol: a host harness that pins every frame (10a), then one row per command code instead of the if chain (10b); 10c and 10d proposed in the note | [0010](0010-companion-command-table.md) | 10a and 10b ready for review | -| 11 | One generic variant target built from a board description, cut A: one shared `radio_new_identity()` (cuts B to E planned in the note) | [0011](0011-variant-target.md) | cut A ready for review | +| 11 | One generic variant target built from a board description, cut A: one shared `radio_new_identity()`; cut B: one FEM RX gain handler for four Heltec boards (cuts C to E planned in the note) | [0011](0011-variant-target.md) | cuts A and B ready for review | Steps are numbered after their design note, so the numbers have gaps: 5 to 8 are notes of fixes and of step 4. See the review for the reasoning behind the From c28513b1a284ea81eba6c27c5a245188ea190748 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 22:12:16 +0200 Subject: [PATCH 111/136] docs(refactoring): design cut C of note 0011, one default radio_init() --- docs/refactoring/0011-variant-target.md | 167 +++++++++++++++++++++++- 1 file changed, 165 insertions(+), 2 deletions(-) diff --git a/docs/refactoring/0011-variant-target.md b/docs/refactoring/0011-variant-target.md index 0e73d2a1a19..606037956c7 100644 --- a/docs/refactoring/0011-variant-target.md +++ b/docs/refactoring/0011-variant-target.md @@ -1,7 +1,8 @@ # 0011: One generic variant target built from a board description Status: cut A (one shared `radio_new_identity()`) and cut B (one shared FEM RX gain -handler) implemented, each its own pull request, B stacked on A. Cuts C to E are +handler) implemented, each its own pull request, B stacked on A. Cut C (one default +`radio_init()` for 31 nRF52 variants) designed, see "Cut C". Cuts D and E are planned and need their own review before code. Behaviour change: none. ## Context @@ -85,7 +86,7 @@ weak" below). |---|---|---|---| | A | one `radio_new_identity()` for all RadioLib variants | 87 `target.cpp`, 1 new source, 1 header | implemented, see "Cut A" | | B | one class for the FEM gain handlers of HeltecV4, HeltecV4R8, T096, HeltecTrackerV2 | 4 board classes, 1 new helper | implemented, see "Cut B" | -| C | a shared default `radio_init()` for the largest groups (28 + 18 + 5 + 4 + 3 of 89) | about 58 `target.cpp` | planned | +| C | a shared default `radio_init()` for the bodies that need no new description (28 + 3 of 89, all nRF52) | 31 `target.cpp`, 1 source, 1 header | designed, see "Cut C" | | D | the radio, wrapper and RTC globals from the board's build flags | about 87 `target.cpp` | planned (description: build flags, decided) | | E | sensors, display and button from the board description | the rest | planned | @@ -708,6 +709,168 @@ four: `get radio.fem.rxgain`, `set radio.fem.rxgain off`, reboot, `get radio.fem shows `> off`; the same with `on`; on a board whose FEM cannot switch the LNA both reply `Error: unsupported`. +## Cut C: one default `radio_init()` + +Design, written before any code of this cut. The results (tests, mutations, +builds) are added below when the code is done. + +### Inventory of the `radio_init()` bodies + +Measured on `e5a8f164` (`integration`, cut B merged) with +`tools/refactoring/variant_duplication.py` (the bodies compared normalized: +comments and white space removed), plus `pio project config` for the environments +of each variant (an environment belongs to a variant when its `build_src_filter` +names `variants/`). 89 `target.cpp`, 29 different bodies. The groups with +three or more files: + +| files | envs | body (normalized) | platform, chip | variants | +|---:|---:|---|---|---| +| 28 | 172 | `rtc_clock.begin(Wire); return radio.std_init(&SPI);` | nRF52, SX1262 | `gat562_30s_mesh_kit`, `gat562_mesh_evb_pro`, `gat562_mesh_tracker_pro`, `gat562_mesh_watch13`, `heltec_mesh_solar`, `heltec_tower_v2`, `ikoka_handheld_nrf`, `ikoka_nano_nrf`, `ikoka_stick_nrf`, `keepteen_lt1`, `lilygo_t_impulse_plus`, `lilygo_techo`, `lilygo_techo_card`, `lilygo_techo_lite`, `meshtiny`, `muziworks_r1_neo`, `nano_g2_ultra`, `promicro`, `rak3401`, `rak4631`, `rak_wismesh_tag`, `sensecap_solar`, `thinknode_m1`, `thinknode_m6`, `thinknode_m8`, `wio-tracker-l1-1w`, `wio-tracker-l1`, `xiao_nrf52` | +| 18 | 168 | `fallback_clock.begin(); rtc_clock.begin(Wire);` then `std_init(&spi)` if `P_LORA_SCLK`, else `std_init()` | ESP32, SX1262/SX1268/SX1276 | `generic-e22`, `heltec_e213`, `heltec_e290`, `heltec_rc32`, `heltec_t190`, `heltec_tracker`, `heltec_tracker_v2`, `heltec_v2`, `heltec_v3`, `heltec_v4`, `heltec_v4_r8`, `heltec_wireless_paper`, `lilygo_tbeam_SX1276`, `lilygo_tlora_c6`, `lilygo_tlora_v2_1`, `meshadventurer`, `rak3112`, `tenstar_c3` | +| 5 | 32 | `fallback_clock.begin(); rtc_clock.begin(Wire); return radio.std_init(&spi);` | ESP32, SX1262 | `lilygo_t3s3`, `lilygo_tbeam_SX1262`, `lilygo_teth_elite`, `nibble_screen_connect`, `nibble_zero_connect` | +| 4 | 29 | as the 18, with `spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI)` before `std_init(&spi)` | ESP32, SX1262/SX1276 | `lilygo_t3s3_sx1276`, `station_g2`, `station_g3_esp32`, `xiao_c3` | +| 3 | 23 | `rtc_clock.begin(Wire);` then `std_init(&SPI)` if `P_LORA_SCLK`, else `std_init()` | nRF52, SX1262 | `heltec_t096`, `heltec_t1`, `heltec_t114` | +| 3 | 26 | as the 4, with `pinMode(21, INPUT); pinMode(48, OUTPUT);` first | ESP32, SX1262 | `thinknode_m2`, `xiao_s3`, `xiao_s3_wio` | + +The other 25 bodies occur once or twice; most of them are their own radio setup +(LR11x0, STM32WL, own `begin()` arguments), the rest differ in the SPI object +(`SPI1` on `rak11310` and `rpi_picow`) or leave out the RTC (`mesh_pocket`, +`meshtracker_x1`). + +### What cut C shares, and why exactly that + +Cut C shares the 28 and the 3 nRF52 bodies (31 files, 195 environments) and +nothing else. The criterion is the one the cut plan set: share only what needs no +new board description. + +- **The 28 and the 3 are one function.** The 3 differ from the 28 only in passing + nothing instead of `&SPI` when `P_LORA_SCLK` is not defined. `std_init()` of + every chip class that has one (`CustomSX1262`, `CustomSX1268`, `CustomSX1276`, + `CustomLLCC68`, `CustomLR2021`) reads its `spi` argument only inside + `#if defined(P_LORA_SCLK)`, so without `P_LORA_SCLK` the argument is unused and + both calls do the same; with it, both pass `&SPI`. Checked by reading the five + headers (`grep -n "P_LORA_SCLK\|spi" src/helpers/radiolib/Custom*.h`). +- **Everything the body names is already declared for the shared code.** All 31 + `target.h` declare `extern AutoDiscoverRTCClock rtc_clock;`, `Wire` and `SPI` are + the Arduino globals, and the radio is reached through `radio_driver` (see + below). Checked by script over the 31 files: each has exactly the declaration, + defines `RADIO_CLASS radio = new Module(...)` and `WRAPPER_CLASS + radio_driver(radio, board);`, uses `VolatileRTCClock fallback_clock` (which + needs no `begin()`, which is why none of them calls it), and has its + `radio_init()` outside any `#if`. None uses CRLF. +- **All 195 environments are nRF52 with `CustomSX1262` and `CustomSX1262Wrapper`** + (from `pio project config`), so one platform and one chip class to verify. +- **The 18, 5, 4 and 3 ESP32 bodies are left for cut D.** They start + `fallback_clock`, which is an `ESP32RTCClock` there and a file local global no + `target.h` declares, and most pass `spi`, a file local `SPIClass` of the + variant. Sharing them needs exactly the new description cut D introduces (which + fallback clock, which SPI object). `heltec_ct62` (`fallback_clock.begin()` and + `&SPI`) and the two `SPI1` variants belong to D for the same reason. + +### What changes + +- `src/helpers/radiolib/RadioLibTarget.cpp` gets a second weak definition, next + to `radio_new_identity()`: + ```cpp + #if defined(NRF52_PLATFORM) && defined(USE_SX1262) + __attribute__((weak)) bool radio_init() { + rtc_clock.begin(Wire); + return RadioLibTargetAccess::radio(radio_driver).std_init(&SPI); + } + #endif + ``` +- `RadioLibWrapper` names one more friend, a small accessor the shared target code + uses to reach the radio it wraps, as `RadioNoiseListener` does for cut A: + `static_cast(*driver._radio)`. The cast is the one every chip + wrapper already makes (`((CustomSX1262 *)_radio)`), and `radio_driver` wraps + `radio` in all 31 files (checked above). +- The 31 copies are deleted from `variants/*/target.cpp`, deletions only, as in + cut A. The declaration stays in every `target.h`. + +The guard: `NRF52_PLATFORM` and `USE_SX1262` (which `CustomSX1262Wrapper.h` +defines and every `target.h` of the 31 includes) are existing macros, so no new +description. It is that narrow on purpose: + +- The default is only correct where it was the copy. On ESP32 every RadioLib + variant starts its `fallback_clock`, on RP2040 they pass `SPI1` or `spi`. A new + ESP32 board that forgets its `radio_init()` should fail to link, not silently get + an nRF52 default that never starts its clock. +- The weak function is compiled in every environment where the guard holds, also + in variants that keep their own. Among the nRF52 variants with another chip, + `meshtracker_x1` (LR2021) declares `rtc_clock` as `VolatileRTCClock`, which has + no `begin(Wire)`, and the LR11x0 classes have no `std_init()`, so a wider guard + would not compile there. The two nRF52 SX1262 variants that keep their own + body (`mesh_pocket`, `muzi_base`) both declare the same `rtc_clock` and have + `std_init()`, so the default compiles there and is not used. + +### Alternatives considered + +- **Share the ESP32 groups now as well, with `fallback_clock` and `spi` declared + `extern` in the shared file.** Rejected: both are file local names of the + variant that no `target.h` declares, and whether there is a `spi` and which + clock class it is, is board description. That is cut D. +- **`extern RADIO_CLASS radio;` in the shared file.** Rejected for the reason given + in cut A: `radio` is not part of any variant's declared interface, + `radio_driver` is. Also, the weak default is compiled where the variant keeps + its own, and would then reference `radio` from a function that is not used. +- **A public `getRadio()` on `RadioLibWrapper`.** Rejected for now, as in cut A: it + widens the wrapper's interface for the shared target code only. With two users + (cut A's listener and this default) a friend accessor is still local; if cut D + constructs the radio in the shared code, the accessor goes away again. +- **A virtual `std_init()` on the wrapper.** Rejected: a vtable entry and an indirect + call in every RadioLib wrapper for a function called once at boot, and the + LR11x0 and STM32WL wrappers have no such function to override. +- **One default for every platform with `#if` branches per platform inside.** + Rejected: it would write the ESP32 and RP2040 board description as `#if` chains + into the shared code, which is what cut D replaces with the build flags. +- **Leave out the 3 (`heltec_t096`, `heltec_t1`, `heltec_t114`).** Not chosen: they + are the same function (see above), and deleting their copy is the same edit. + +### Verification plan + +- Host tests in `native_variant_target`, as for cut A. A first commit pins the + variants' body, copied verbatim into the test, with a recording RTC clock and a + recording radio: the RTC is started first, with `Wire`; then `std_init()` is + called once on the radio that `radio_driver` wraps, with `&SPI`; its result is + returned unchanged (`true` and `false`). The second commit runs the same cases + against the shared default. `native_variant_target_override` gets a variant + `radio_init()` of its own and checks it is the one called (does not link if the + shared one is not weak). +- Mutation tests on the shared default (examples: RTC started after the radio, + `std_init()` without `&SPI`, result inverted or dropped, not weak, guarded wider + or narrower). +- Builds from `e5a8f164` and from the code commit, each in its own worktree: one + environment for each of the 31 edited variants, `mesh_pocket` and `muzi_base` + (compile the default, keep their own), `meshtracker_x1` and an LR1110 nRF52 + environment (guard false), the 18 environments of the series' matrix (ESP32, + nRF52, RP2040, STM32), and the two C6 variants without a `target.cpp`. Flash and + RAM compared per environment; on one environment `nm` shows `radio_init()` weak + (`W`) and a variant that keeps its own shows it strong (`T`). +- No hardware unless a board from the 31 is free in the pool (`kb device claim`); + otherwise recorded as without board. + +### Upstream collisions + +Checked on 20.10.2026 against meshcore-dev/MeshCore: the file lists of all 500 +open pull requests (GraphQL, 100 files each; the four with more than 100 files +read through the REST file list), and the diffs of every open pull request that +touches one of the 31 `target.cpp`, checked hunk by hunk for changed lines in or +around `radio_init()`. + +- **No open pull request changes a line of the 31 `radio_init()` bodies.** +- **#1962** (keep time across warm resets on nRF52, open, updated 2026-10-12) + touches 19 of the 31 files, but only the line `VolatileRTCClock fallback_clock;` + (to `NRF52RTCClock`). No conflict with this cut. It is relevant for cut D: it + makes the nRF52 fallback clock a board choice as well. +- Context only (a hunk that shows `radio_init()` lines without changing them, so a + rebase without logic): #1799 (5 files), #1913, #1937, #2185, #2794, #3331, + #3374, #3527, #3613 (1 file each). +- **#2682** (ThinkNode M4) lists `thinknode_m8` and `wio-tracker-l1-1w` as changed, + but its head is identical to `upstream/dev` in both (`git diff upstream/dev + pull/2682/head`, 0 lines), as found for cut B: a merge of `dev`, not a change. +- Open pull requests that add an nRF52 SX1262 board carry their own + `radio_init()`; with the weak default they keep linking unchanged. + ## Upstream Searched meshcore-dev/MeshCore on 20.10.2026: text queries over all pull requests From da1589cf9e0533d68a03d5418af5f9892a1e5d12 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 22:13:56 +0200 Subject: [PATCH 112/136] test(variants): pin how 31 nRF52 variants start the radio --- test/mocks/radiolib_variant/SPI.h | 7 ++ test/mocks/radiolib_variant/Wire.h | 7 ++ test/mocks/radiolib_variant/target.h | 35 ++++++++++ .../test_variant_target.cpp | 64 +++++++++++++++++-- 4 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 test/mocks/radiolib_variant/SPI.h create mode 100644 test/mocks/radiolib_variant/Wire.h diff --git a/test/mocks/radiolib_variant/SPI.h b/test/mocks/radiolib_variant/SPI.h new file mode 100644 index 00000000000..6bfbe442e22 --- /dev/null +++ b/test/mocks/radiolib_variant/SPI.h @@ -0,0 +1,7 @@ +#pragma once + +// Stand-in for the Arduino SPI library: only the type and the global, so a test +// can see which bus the radio is started on. +class SPIClass { }; + +inline SPIClass SPI; diff --git a/test/mocks/radiolib_variant/Wire.h b/test/mocks/radiolib_variant/Wire.h new file mode 100644 index 00000000000..4e387fa7436 --- /dev/null +++ b/test/mocks/radiolib_variant/Wire.h @@ -0,0 +1,7 @@ +#pragma once + +// Stand-in for the Arduino Wire library: only the type and the global, so a +// test can see which bus the RTC clock is started on. +class TwoWire { }; + +inline TwoWire Wire; diff --git a/test/mocks/radiolib_variant/target.h b/test/mocks/radiolib_variant/target.h index 579e241cd33..65dcede0fb6 100644 --- a/test/mocks/radiolib_variant/target.h +++ b/test/mocks/radiolib_variant/target.h @@ -7,6 +7,40 @@ // (here: the test) defines the globals declared below. #include +#include +#include + +#include +#include + +// What the stand-ins below were asked to do, in order, for the tests to compare. +inline std::vector target_calls; + +// Stand-in for a chip class with std_init() (CustomSX1262 and its siblings): +// records the call and the SPI bus it was given, and counts the calls per radio. +class TestRadio : public PhysicalLayer { +public: + bool std_init_result = true; + int std_init_calls = 0; + + bool std_init(SPIClass* spi = nullptr) { + target_calls.push_back(spi == &SPI ? "std_init(&SPI)" : spi ? "std_init(other SPI)" : "std_init()"); + std_init_calls++; + return std_init_result; + } +}; + +// Stand-in for AutoDiscoverRTCClock: records which bus it was started on. +class TestRTCClock { +public: + void begin(TwoWire& wire) { target_calls.push_back(&wire == &Wire ? "rtc_clock.begin(Wire)" : "rtc_clock.begin(other)"); } +}; + +// What CustomSX1262Wrapper.h defines, which every target.h of an SX1262 variant +// includes. +#ifndef USE_SX1262 +#define USE_SX1262 +#endif class TestRadioWrapper : public RadioLibWrapper { public: @@ -25,6 +59,7 @@ class TestBoard : public mesh::MainBoard { }; extern WRAPPER_CLASS radio_driver; +extern TestRTCClock rtc_clock; bool radio_init(); mesh::LocalIdentity radio_new_identity(); diff --git a/test/test_variant_target/test_variant_target.cpp b/test/test_variant_target/test_variant_target.cpp index 4a51cbcdd0a..920326aadef 100644 --- a/test/test_variant_target/test_variant_target.cpp +++ b/test/test_variant_target/test_variant_target.cpp @@ -18,13 +18,14 @@ mesh::Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } mesh::LocalIdentity::LocalIdentity(mesh::RNG* rng) { rng->random(pub_key, SEED_SIZE); } -// The globals a variant's target.cpp defines. The shared radio_new_identity() -// must reach the radio through radio_driver, so a second radio that nothing -// wraps shows whether it picks the right one. -PhysicalLayer radio; -PhysicalLayer other_radio; +// The globals a variant's target.cpp defines. The shared definitions must +// reach the radio through radio_driver, so a second radio that nothing wraps +// shows whether they pick the right one. +TestRadio radio; +TestRadio other_radio; TestBoard board; WRAPPER_CLASS radio_driver(radio, board); +TestRTCClock rtc_clock; namespace { @@ -102,6 +103,59 @@ TEST(VariantTarget, ListenerOnTheDriverMatchesListenerOnTheRadio) { EXPECT_EQ(0, memcmp(via_radio, via_driver, sizeof(via_radio))); } +// The body of radio_init() as 28 nRF52 variants' target.cpp carry it, verbatim. +// The 3 others in cut C (heltec_t096, heltec_t1, heltec_t114) pass nothing +// instead of &SPI when P_LORA_SCLK is not defined, which std_init() ignores +// then; see the note, "Cut C". +bool variant_copy_radio_init() { + rtc_clock.begin(Wire); + return radio.std_init(&SPI); +} + +// The cases below run against each radio_init() in the list: the variants' +// copy, and from the code commit of cut C on, the shared default as well. +class RadioInit : public ::testing::TestWithParam { +protected: + void SetUp() override { + target_calls.clear(); + radio.std_init_result = true; + radio.std_init_calls = 0; + other_radio.std_init_calls = 0; + } +}; + +TEST_P(RadioInit, StartsTheRtcOnWireThenTheRadioOnSpi) { + GetParam()(); + std::vector expected = {"rtc_clock.begin(Wire)", "std_init(&SPI)"}; + EXPECT_EQ(expected, target_calls); +} + +TEST_P(RadioInit, StartsTheRadioOnce) { + GetParam()(); + EXPECT_EQ(1, radio.std_init_calls); +} + +TEST_P(RadioInit, ReturnsTrueWhenTheRadioStarts) { + radio.std_init_result = true; + EXPECT_TRUE(GetParam()()); +} + +TEST_P(RadioInit, ReturnsFalseWhenTheRadioFails) { + // The roles stop on false ("Radio init failed"), so a failure must not be + // turned into success. + radio.std_init_result = false; + EXPECT_FALSE(GetParam()()); + std::vector expected = {"rtc_clock.begin(Wire)", "std_init(&SPI)"}; + EXPECT_EQ(expected, target_calls); +} + +TEST_P(RadioInit, LeavesOtherRadiosAlone) { + GetParam()(); + EXPECT_EQ(0, other_radio.std_init_calls); +} + +INSTANTIATE_TEST_SUITE_P(VariantCopy, RadioInit, ::testing::Values(&variant_copy_radio_init)); + } // namespace int main(int argc, char **argv) { From 2b7122c61a10b1a29e95ccf6f721eb53b505595b Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 22:16:21 +0200 Subject: [PATCH 113/136] refactor(variants): define radio_init() once for 31 nRF52 SX1262 variants --- platformio.ini | 9 ++++--- src/helpers/radiolib/RadioLibTarget.cpp | 24 +++++++++++++++++ src/helpers/radiolib/RadioLibWrappers.h | 1 + .../test_variant_target.cpp | 4 +++ .../test_variant_target_override.cpp | 26 ++++++++++++++----- variants/gat562_30s_mesh_kit/target.cpp | 5 ---- variants/gat562_mesh_evb_pro/target.cpp | 5 ---- variants/gat562_mesh_tracker_pro/target.cpp | 5 ---- variants/gat562_mesh_watch13/target.cpp | 5 ---- variants/heltec_mesh_solar/target.cpp | 5 ---- variants/heltec_t096/target.cpp | 10 ------- variants/heltec_t1/target.cpp | 10 ------- variants/heltec_t114/target.cpp | 10 ------- variants/heltec_tower_v2/target.cpp | 5 ---- variants/ikoka_handheld_nrf/target.cpp | 6 ----- variants/ikoka_nano_nrf/target.cpp | 6 ----- variants/ikoka_stick_nrf/target.cpp | 6 ----- variants/keepteen_lt1/target.cpp | 6 ----- variants/lilygo_t_impulse_plus/target.cpp | 6 ----- variants/lilygo_techo/target.cpp | 6 ----- variants/lilygo_techo_card/target.cpp | 6 ----- variants/lilygo_techo_lite/target.cpp | 6 ----- variants/meshtiny/target.cpp | 5 ---- variants/muziworks_r1_neo/target.cpp | 5 ---- variants/nano_g2_ultra/target.cpp | 5 ---- variants/promicro/target.cpp | 6 ----- variants/rak3401/target.cpp | 5 ---- variants/rak4631/target.cpp | 5 ---- variants/rak_wismesh_tag/target.cpp | 5 ---- variants/sensecap_solar/target.cpp | 6 ----- variants/thinknode_m1/target.cpp | 5 ---- variants/thinknode_m6/target.cpp | 5 ---- variants/thinknode_m8/target.cpp | 5 ---- variants/wio-tracker-l1-1w/target.cpp | 6 ----- variants/wio-tracker-l1/target.cpp | 6 ----- variants/xiao_nrf52/target.cpp | 6 ----- 36 files changed, 55 insertions(+), 192 deletions(-) diff --git a/platformio.ini b/platformio.ini index 6b793aa1a97..5a57d9fa55d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -483,9 +483,10 @@ lib_deps = ; What a board variant's target.cpp gives the roles, on the host: the real ; RadioLibWrapper and RadioNoiseListener against the RadioLib stand-in, and the ; shared definitions in RadioLibTarget.cpp against the target.h of a RadioLib -; variant (test/mocks/radiolib_variant, ahead of test/mocks). LORA_SF and -; WRAPPER_CLASS are what every RadioLib variant's build flags set; the LORA_SF -; value does not matter here. +; variant (test/mocks/radiolib_variant, ahead of test/mocks). LORA_SF, +; WRAPPER_CLASS and RADIO_CLASS are what every RadioLib variant's build flags +; set, NRF52_PLATFORM what every nRF52 one sets (the default radio_init() is +; only built there); the LORA_SF value does not matter here. platform = native test_framework = googletest build_flags = -std=c++17 @@ -494,6 +495,8 @@ build_flags = -std=c++17 -I test/mocks -D LORA_SF=8 -D WRAPPER_CLASS=TestRadioWrapper + -D RADIO_CLASS=TestRadio + -D NRF52_PLATFORM test_build_src = yes test_filter = test_variant_target build_src_filter = diff --git a/src/helpers/radiolib/RadioLibTarget.cpp b/src/helpers/radiolib/RadioLibTarget.cpp index 0aa679d5937..a9259e2b180 100644 --- a/src/helpers/radiolib/RadioLibTarget.cpp +++ b/src/helpers/radiolib/RadioLibTarget.cpp @@ -16,4 +16,28 @@ __attribute__((weak)) mesh::LocalIdentity radio_new_identity() { return mesh::LocalIdentity(&rng); } +// The radio_init() of the nRF52 variants with an SX1262: start the RTC, then the +// radio on the default SPI bus. Only there, because elsewhere the variants also +// start a fallback clock or use another SPI bus, which they still write out +// themselves; a board there without its own radio_init() fails to link instead +// of getting this one. +#if defined(NRF52_PLATFORM) && defined(USE_SX1262) + +#include +#include + +// The radio a driver wraps, as the variant's RADIO_CLASS (which it is, since the +// variant constructs radio_driver from its global radio). +struct RadioLibTargetAccess { + template + static RADIO& radio(RadioLibWrapper& driver) { return static_cast(*driver._radio); } +}; + +__attribute__((weak)) bool radio_init() { + rtc_clock.begin(Wire); + return RadioLibTargetAccess::radio(radio_driver).std_init(&SPI); +} + +#endif + #endif diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 1b3448c1ce2..715c3c65ffa 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -13,6 +13,7 @@ struct PacketMillis { class RadioLibWrapper : public mesh::Radio { friend class RadioNoiseListener; // listens on _radio, without a public accessor for it + friend struct RadioLibTargetAccess; // RadioLibTarget.cpp starts _radio in the default radio_init() protected: PhysicalLayer* _radio; mesh::MainBoard* _board; diff --git a/test/test_variant_target/test_variant_target.cpp b/test/test_variant_target/test_variant_target.cpp index 920326aadef..801b25ba09a 100644 --- a/test/test_variant_target/test_variant_target.cpp +++ b/test/test_variant_target/test_variant_target.cpp @@ -156,6 +156,10 @@ TEST_P(RadioInit, LeavesOtherRadiosAlone) { INSTANTIATE_TEST_SUITE_P(VariantCopy, RadioInit, ::testing::Values(&variant_copy_radio_init)); +// The default in src/helpers/radiolib/RadioLibTarget.cpp, which the 31 variants +// no longer repeat. +INSTANTIATE_TEST_SUITE_P(SharedDefault, RadioInit, ::testing::Values(&radio_init)); + } // namespace int main(int argc, char **argv) { diff --git a/test/test_variant_target_override/test_variant_target_override.cpp b/test/test_variant_target_override/test_variant_target_override.cpp index e0904047eea..2c8fe0d9ff0 100644 --- a/test/test_variant_target_override/test_variant_target_override.cpp +++ b/test/test_variant_target_override/test_variant_target_override.cpp @@ -5,18 +5,19 @@ #include #include "target.h" -// A variant that keeps its own radio_new_identity(), as the two ESP-NOW variants -// do and as every variant in a downstream fork still does. Its definition must -// take precedence over the shared one in src/helpers/radiolib/RadioLibTarget.cpp, -// which is built into this binary as well; if the shared one were not weak, this -// suite would not link. +// A variant that keeps its own radio_new_identity() and radio_init(), as the +// ESP-NOW variants and the variants outside cut C do and as every variant in a +// downstream fork still does. Its definitions must take precedence over the +// shared ones in src/helpers/radiolib/RadioLibTarget.cpp, which are built into +// this binary as well; if a shared one were not weak, this suite would not link. mesh::Identity::Identity() { memset(pub_key, 0, sizeof(pub_key)); } mesh::LocalIdentity::LocalIdentity(mesh::RNG* rng) { rng->random(pub_key, SEED_SIZE); } -PhysicalLayer radio; +TestRadio radio; TestBoard board; WRAPPER_CLASS radio_driver(radio, board); +TestRTCClock rtc_clock; namespace { @@ -33,8 +34,21 @@ mesh::LocalIdentity radio_new_identity() { return mesh::LocalIdentity(&rng); } +bool radio_init() { + target_calls.push_back("variant radio_init()"); + return false; +} + namespace { +TEST(VariantTargetOverride, VariantRadioInitTakesPrecedence) { + target_calls.clear(); + EXPECT_FALSE(radio_init()); + std::vector expected = {"variant radio_init()"}; + EXPECT_EQ(expected, target_calls); + EXPECT_EQ(0, radio.std_init_calls); +} + TEST(VariantTargetOverride, VariantDefinitionTakesPrecedence) { mesh::LocalIdentity id = radio_new_identity(); uint8_t marker[SEED_SIZE]; diff --git a/variants/gat562_30s_mesh_kit/target.cpp b/variants/gat562_30s_mesh_kit/target.cpp index 1a1b01fc1bc..11ee09f07aa 100644 --- a/variants/gat562_30s_mesh_kit/target.cpp +++ b/variants/gat562_30s_mesh_kit/target.cpp @@ -32,8 +32,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/gat562_mesh_evb_pro/target.cpp b/variants/gat562_mesh_evb_pro/target.cpp index d1ce7c6f612..d1b021c8cf4 100644 --- a/variants/gat562_mesh_evb_pro/target.cpp +++ b/variants/gat562_mesh_evb_pro/target.cpp @@ -31,8 +31,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/gat562_mesh_tracker_pro/target.cpp b/variants/gat562_mesh_tracker_pro/target.cpp index bcf61e481c9..ad85c842f2d 100644 --- a/variants/gat562_mesh_tracker_pro/target.cpp +++ b/variants/gat562_mesh_tracker_pro/target.cpp @@ -32,8 +32,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/gat562_mesh_watch13/target.cpp b/variants/gat562_mesh_watch13/target.cpp index 58096165487..dddd24485ab 100644 --- a/variants/gat562_mesh_watch13/target.cpp +++ b/variants/gat562_mesh_watch13/target.cpp @@ -23,8 +23,3 @@ VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); EnvironmentSensorManager sensors; - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index 9daae8a7b67..c89eea6b800 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -20,11 +20,6 @@ SolarExternalWatchdog external_watchdog; DISPLAY_CLASS display; #endif -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - void SolarSensorManager::start_gps() { if (!gps_active) { gps_active = true; diff --git a/variants/heltec_t096/target.cpp b/variants/heltec_t096/target.cpp index 5ebbc8821f6..34452dfbf3f 100644 --- a/variants/heltec_t096/target.cpp +++ b/variants/heltec_t096/target.cpp @@ -32,13 +32,3 @@ EnvironmentSensorManager sensors; DISPLAY_CLASS display(&board.periph_power); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - -#if defined(P_LORA_SCLK) - return radio.std_init(&SPI); -#else - return radio.std_init(); -#endif -} diff --git a/variants/heltec_t1/target.cpp b/variants/heltec_t1/target.cpp index 7ef0eb0e276..cc372637d90 100644 --- a/variants/heltec_t1/target.cpp +++ b/variants/heltec_t1/target.cpp @@ -31,13 +31,3 @@ EnvironmentSensorManager sensors; DISPLAY_CLASS display(&board.periph_power); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - -#if defined(P_LORA_SCLK) - return radio.std_init(&SPI); -#else - return radio.std_init(); -#endif -} diff --git a/variants/heltec_t114/target.cpp b/variants/heltec_t114/target.cpp index c40be44ae8e..625812d2d56 100644 --- a/variants/heltec_t114/target.cpp +++ b/variants/heltec_t114/target.cpp @@ -32,13 +32,3 @@ EnvironmentSensorManager sensors; DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - -#if defined(P_LORA_SCLK) - return radio.std_init(&SPI); -#else - return radio.std_init(); -#endif -} diff --git a/variants/heltec_tower_v2/target.cpp b/variants/heltec_tower_v2/target.cpp index d7b9fc51a32..54c2621e80b 100644 --- a/variants/heltec_tower_v2/target.cpp +++ b/variants/heltec_tower_v2/target.cpp @@ -21,11 +21,6 @@ DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - bool TowerV2ExternalWatchdog::begin() { last_feed_watchdog = 0; pinMode(EXTERNAL_WATCHDOG_WAKE_PIN, INPUT); diff --git a/variants/ikoka_handheld_nrf/target.cpp b/variants/ikoka_handheld_nrf/target.cpp index 31c4a70dceb..6f658f83195 100644 --- a/variants/ikoka_handheld_nrf/target.cpp +++ b/variants/ikoka_handheld_nrf/target.cpp @@ -18,9 +18,3 @@ EnvironmentSensorManager sensors; MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/ikoka_nano_nrf/target.cpp b/variants/ikoka_nano_nrf/target.cpp index 9788ead279a..85177c24a05 100644 --- a/variants/ikoka_nano_nrf/target.cpp +++ b/variants/ikoka_nano_nrf/target.cpp @@ -16,9 +16,3 @@ WRAPPER_CLASS radio_driver(radio, board); VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); EnvironmentSensorManager sensors; - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/ikoka_stick_nrf/target.cpp b/variants/ikoka_stick_nrf/target.cpp index e8cbde71139..c48f677de60 100644 --- a/variants/ikoka_stick_nrf/target.cpp +++ b/variants/ikoka_stick_nrf/target.cpp @@ -16,9 +16,3 @@ WRAPPER_CLASS radio_driver(radio, board); VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); EnvironmentSensorManager sensors; - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/keepteen_lt1/target.cpp b/variants/keepteen_lt1/target.cpp index 23845927b38..396c2fa711e 100644 --- a/variants/keepteen_lt1/target.cpp +++ b/variants/keepteen_lt1/target.cpp @@ -22,9 +22,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/lilygo_t_impulse_plus/target.cpp b/variants/lilygo_t_impulse_plus/target.cpp index 1181a3d31ee..cb34ae6e83f 100644 --- a/variants/lilygo_t_impulse_plus/target.cpp +++ b/variants/lilygo_t_impulse_plus/target.cpp @@ -23,9 +23,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/lilygo_techo/target.cpp b/variants/lilygo_techo/target.cpp index c71929d9fea..e3f797e5c94 100644 --- a/variants/lilygo_techo/target.cpp +++ b/variants/lilygo_techo/target.cpp @@ -24,9 +24,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/lilygo_techo_card/target.cpp b/variants/lilygo_techo_card/target.cpp index 6c69a7e4f4b..15059da30f7 100644 --- a/variants/lilygo_techo_card/target.cpp +++ b/variants/lilygo_techo_card/target.cpp @@ -25,9 +25,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); MomentaryButton back_btn(PIN_BUTTON2, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/lilygo_techo_lite/target.cpp b/variants/lilygo_techo_lite/target.cpp index 3dd43b193dd..6bff6ad95dd 100644 --- a/variants/lilygo_techo_lite/target.cpp +++ b/variants/lilygo_techo_lite/target.cpp @@ -23,9 +23,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/meshtiny/target.cpp b/variants/meshtiny/target.cpp index f7560900d8b..179ed75d31a 100644 --- a/variants/meshtiny/target.cpp +++ b/variants/meshtiny/target.cpp @@ -20,8 +20,3 @@ DISPLAY_CLASS display; MomentaryButton joystick_right(ENCODER_RIGHT, 1000, true, true); MomentaryButton back_btn(PIN_SIDE_BUTTON, 1000, true, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/muziworks_r1_neo/target.cpp b/variants/muziworks_r1_neo/target.cpp index 32e4b704a95..401eab17b0e 100644 --- a/variants/muziworks_r1_neo/target.cpp +++ b/variants/muziworks_r1_neo/target.cpp @@ -25,8 +25,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/nano_g2_ultra/target.cpp b/variants/nano_g2_ultra/target.cpp index 7323ad007dd..2e3c9c5622c 100644 --- a/variants/nano_g2_ultra/target.cpp +++ b/variants/nano_g2_ultra/target.cpp @@ -21,11 +21,6 @@ DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - void NanoG2UltraSensorManager::start_gps() { MESH_DEBUG_PRINTLN("Starting GPS"); if (!gps_active) { diff --git a/variants/promicro/target.cpp b/variants/promicro/target.cpp index 4358b6c6843..a476bf7e487 100644 --- a/variants/promicro/target.cpp +++ b/variants/promicro/target.cpp @@ -22,9 +22,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/rak3401/target.cpp b/variants/rak3401/target.cpp index ad5cca8e9ea..a1a73588577 100644 --- a/variants/rak3401/target.cpp +++ b/variants/rak3401/target.cpp @@ -31,8 +31,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/rak4631/target.cpp b/variants/rak4631/target.cpp index 4711397d6a1..b7691dd26c3 100644 --- a/variants/rak4631/target.cpp +++ b/variants/rak4631/target.cpp @@ -31,8 +31,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/rak_wismesh_tag/target.cpp b/variants/rak_wismesh_tag/target.cpp index 67b193cd36e..95f8961eb2e 100644 --- a/variants/rak_wismesh_tag/target.cpp +++ b/variants/rak_wismesh_tag/target.cpp @@ -27,8 +27,3 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock); #else EnvironmentSensorManager sensors; #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/sensecap_solar/target.cpp b/variants/sensecap_solar/target.cpp index 5a4683a14c3..01574e9f796 100644 --- a/variants/sensecap_solar/target.cpp +++ b/variants/sensecap_solar/target.cpp @@ -18,9 +18,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); #else EnvironmentSensorManager sensors = EnvironmentSensorManager(); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/thinknode_m1/target.cpp b/variants/thinknode_m1/target.cpp index 73faf5bb877..3a0a7f04f98 100644 --- a/variants/thinknode_m1/target.cpp +++ b/variants/thinknode_m1/target.cpp @@ -20,11 +20,6 @@ ThinkNodeM1SensorManager sensors = ThinkNodeM1SensorManager(nmea); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} - void ThinkNodeM1SensorManager::start_gps() { if (!gps_active) { gps_active = true; diff --git a/variants/thinknode_m6/target.cpp b/variants/thinknode_m6/target.cpp index 40f1330eff8..f8258af1d02 100644 --- a/variants/thinknode_m6/target.cpp +++ b/variants/thinknode_m6/target.cpp @@ -22,8 +22,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); DISPLAY_CLASS display; MomentaryButton user_btn(PIN_USER_BTN, 1000, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/thinknode_m8/target.cpp b/variants/thinknode_m8/target.cpp index 9b6177888ed..5bf588139a1 100644 --- a/variants/thinknode_m8/target.cpp +++ b/variants/thinknode_m8/target.cpp @@ -24,8 +24,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton user_btn(PIN_USER_BTN, 1000, true); RotaryInputGPIO rotary_input(PIN_BUTTON1_A, PIN_BUTTON1_B); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - return radio.std_init(&SPI); -} diff --git a/variants/wio-tracker-l1-1w/target.cpp b/variants/wio-tracker-l1-1w/target.cpp index 589de98831f..1170282a50f 100644 --- a/variants/wio-tracker-l1-1w/target.cpp +++ b/variants/wio-tracker-l1-1w/target.cpp @@ -26,9 +26,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, false, false); MomentaryButton back_btn(PIN_BACK_BTN, 1000, true, false, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/wio-tracker-l1/target.cpp b/variants/wio-tracker-l1/target.cpp index 589de98831f..1170282a50f 100644 --- a/variants/wio-tracker-l1/target.cpp +++ b/variants/wio-tracker-l1/target.cpp @@ -26,9 +26,3 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, false, false); MomentaryButton back_btn(PIN_BACK_BTN, 1000, true, false, true); #endif - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} diff --git a/variants/xiao_nrf52/target.cpp b/variants/xiao_nrf52/target.cpp index 6af7628e3a4..ecdcbdb6e9c 100644 --- a/variants/xiao_nrf52/target.cpp +++ b/variants/xiao_nrf52/target.cpp @@ -17,9 +17,3 @@ VolatileRTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); EnvironmentSensorManager sensors; - -bool radio_init() { - rtc_clock.begin(Wire); - - return radio.std_init(&SPI); -} From 3d48b1de3dad63144d85a815f4e597fb1d51c1fc Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Tue, 20 Oct 2026 23:25:27 +0200 Subject: [PATCH 114/136] docs(refactoring): record cut C of note 0011, one default radio_init() --- docs/refactoring/0011-variant-target.md | 172 +++++++++++++++++++++++- docs/refactoring/README.md | 2 +- 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/docs/refactoring/0011-variant-target.md b/docs/refactoring/0011-variant-target.md index 606037956c7..b163c540967 100644 --- a/docs/refactoring/0011-variant-target.md +++ b/docs/refactoring/0011-variant-target.md @@ -2,8 +2,8 @@ Status: cut A (one shared `radio_new_identity()`) and cut B (one shared FEM RX gain handler) implemented, each its own pull request, B stacked on A. Cut C (one default -`radio_init()` for 31 nRF52 variants) designed, see "Cut C". Cuts D and E are -planned and need their own review before code. Behaviour change: none. +`radio_init()` for 31 nRF52 variants) implemented, its own pull request, on top of +A and B. Cuts D and E are planned and need their own review before code. Behaviour change: none. ## Context @@ -86,7 +86,7 @@ weak" below). |---|---|---|---| | A | one `radio_new_identity()` for all RadioLib variants | 87 `target.cpp`, 1 new source, 1 header | implemented, see "Cut A" | | B | one class for the FEM gain handlers of HeltecV4, HeltecV4R8, T096, HeltecTrackerV2 | 4 board classes, 1 new helper | implemented, see "Cut B" | -| C | a shared default `radio_init()` for the bodies that need no new description (28 + 3 of 89, all nRF52) | 31 `target.cpp`, 1 source, 1 header | designed, see "Cut C" | +| C | a shared default `radio_init()` for the bodies that need no new description (28 + 3 of 89, all nRF52) | 31 `target.cpp`, 1 source, 1 header | implemented, see "Cut C" | | D | the radio, wrapper and RTC globals from the board's build flags | about 87 `target.cpp` | planned (description: build flags, decided) | | E | sensors, display and button from the board description | the rest | planned | @@ -711,8 +711,8 @@ reply `Error: unsupported`. ## Cut C: one default `radio_init()` -Design, written before any code of this cut. The results (tests, mutations, -builds) are added below when the code is done. +The design (up to "Upstream collisions") was written and committed before any code +of this cut; the results follow from "Host tests" on. ### Inventory of the `radio_init()` bodies @@ -871,6 +871,168 @@ around `radio_init()`. - Open pull requests that add an nRF52 SX1262 board carry their own `radio_init()`; with the weak default they keep linking unchanged. +### Host tests + +`native_variant_target` builds the real `RadioLibTarget.cpp` against the variant +stand-in, which gets a recording radio (`TestRadio`, a `PhysicalLayer` with +`std_init()`), a recording RTC clock with `begin(TwoWire&)`, and stand-ins for +`Wire.h` and `SPI.h` (only the types and the globals), all in +`test/mocks/radiolib_variant`. The environment sets `RADIO_CLASS=TestRadio` and +`NRF52_PLATFORM`, as the 195 environments do; the stand-in `target.h` defines +`USE_SX1262`, as `CustomSX1262Wrapper.h` does. + +The first commit (`da1589cf`) pins the variants' body, copied verbatim into the +test, as a parameterized suite: + +- the RTC is started with `Wire`, then `std_init(&SPI)` is called, in that order + and nothing else, +- `std_init()` is called once, +- its result is returned unchanged, `true` and `false` (on `false` the RTC is still + started first), +- a second radio that `radio_driver` does not wrap is not touched. + +The code commit (`2b7122c6`) runs the same five cases against the shared default, +with the cases unchanged, and `native_variant_target_override` defines a +`radio_init()` of its own and checks it is the one called. + +All 17 host environments pass on both commits: 571 test cases on the first, 577 on +the second (566 on `e5a8f164`). + +Mutation tests on the shared default, each of which must fail: + +| mutation | result | +|---|---| +| M1: start the radio before the RTC | 2 tests fail | +| M2: `std_init()` without `&SPI` | 2 tests fail | +| M3: return `true` whatever `std_init()` returned | 1 test fails | +| M4: do not start the RTC | 2 tests fail | +| M5: call `std_init()` twice | 3 tests fail | +| M6: shared default not weak | `native_variant_target_override` does not link (`duplicate symbol 'radio_init()'`) | +| M7: guard `USE_SX1268` instead of `USE_SX1262` | `native_variant_target` does not link (`radio_init()` undefined) | + +### What the code commit changes + +As designed above: the weak default and `RadioLibTargetAccess` in +`RadioLibTarget.cpp` (24 lines), one friend line in `RadioLibWrappers.h`, and in +`variants/` deletions only: 31 files, 183 lines (each copy and the blank line before +it), no line added or changed. `tools/refactoring/variant_duplication.py` after the +step: `radio_init()` in 58 files with 27 normalized bodies (was 89 and 29), the +largest group is the 18 ESP32 bodies left for cut D. + +### Builds + +53 environments were built from `e5a8f164` and from the code commit `2b7122c6`, each +side in its own worktree, with the same toolchain: + +- the 18 environments every step of the series is built for (all roles; ESP32, + nRF52, RP2040, STM32), +- one environment for each of the 31 edited variants (a repeater where there is + one; `rak4631` and `promicro` are among the 18), +- `Mesh_pocket_repeater` and `muzi_base_uno_repeater` (nRF52, SX1262, own body: + the default is compiled and not used), +- `MeshTracker_X1_repeater` (nRF52, LR2021) and `t1000e_repeater` (nRF52, LR1110), + where the guard is false, +- `Xiao_C6_repeater_` and `M5Stack_Unit_C6L_repeater`, the variants without a + `target.cpp`. + +Platforms in the set: 41 nRF52, 8 ESP32, 2 RP2040, 2 STM32WL. + +Result: + +- 52 build on both commits. `ThinkNode_M8_repeater` fails on both with the same + link errors (`EnvironmentSensorManager` undefined, known since cut A); + `radio_init()` is not among the undefined symbols, so it links the shared + default. +- RAM is unchanged in all 52. +- Flash is unchanged in every environment outside the 31 variants (ESP32, RP2040, + STM32, the two C6, and the four nRF52 controls). +- In the 31 variants' environments flash grows by 0 to 32 bytes: 30 by +16, 2 by + +32 (`ProMicro_repeater`, `ProMicro_sensor`), 4 by +0. Measured with `nm`: + `radio_init()` is 16 bytes larger than the variant's own (0xe8 to 0xf8 on + `RAK_4631_repeater`, 0xf4 to 0x104 on `ProMicro_repeater` and on the +0 ones), + because the radio is now loaded from `radio_driver` and cast, instead of being a + fixed address; on the +0 environments `.text` keeps its size, the 16 bytes fall + into alignment padding. `radio_init()` is a weak symbol (`W`) in the new firmware + where it was a normal one (`T`). +- `Mesh_pocket_repeater` keeps its own definition: same size, and `nm` shows its + `radio_init()` as `T` with the same size (0xd8) and address as before. The + precedence the override test checks on the host, on the nRF52 toolchain. + +
+All 53 environments + +| Environment | Platform | Flash before | Flash after | Flash delta | RAM before | RAM after | RAM delta | +|---|---|---:|---:|---:|---:|---:|---:| +| `Heltec_v3_repeater` | ESP32 | 1,174,765 | 1,174,765 | +0 | 62,512 | 62,512 | +0 | +| `Heltec_v3_room_server` | ESP32 | 1,169,913 | 1,169,913 | +0 | 66,200 | 66,200 | +0 | +| `Heltec_v3_companion_radio_ble` | ESP32 | 1,669,989 | 1,669,989 | +0 | 194,844 | 194,844 | +0 | +| `Heltec_v3_companion_radio_wifi` | ESP32 | 1,106,129 | 1,106,129 | +0 | 172,384 | 172,384 | +0 | +| `Heltec_v3_sensor` | ESP32 | 1,181,293 | 1,181,293 | +0 | 60,600 | 60,600 | +0 | +| `RAK_4631_repeater` | nRF52 | 441,768 | 441,784 | +16 | 37,240 | 37,240 | +0 | +| `RAK_4631_room_server` | nRF52 | 434,920 | 434,936 | +16 | 40,928 | 40,928 | +0 | +| `RAK_4631_companion_radio_ble` | nRF52 | 464,036 | 464,052 | +16 | 149,768 | 149,768 | +0 | +| `RAK_4631_sensor` | nRF52 | 438,616 | 438,632 | +16 | 35,328 | 35,328 | +0 | +| `PicoW_repeater` | RP2040 | 492,668 | 492,668 | +0 | 85,368 | 85,368 | +0 | +| `wio-e5-mini_repeater` | STM32 | 204,136 | 204,136 | +0 | 17,172 | 17,172 | +0 | +| `wio-e5-mini_sensor` | STM32 | 203,664 | 203,664 | +0 | 15,276 | 15,276 | +0 | +| `RAK_3112_repeater_bridge_espnow` | ESP32 | 1,176,065 | 1,176,065 | +0 | 64,072 | 64,072 | +0 | +| `waveshare_rp2040_lora_repeater_bridge_rs232` | RP2040 | 241,080 | 241,080 | +0 | 26,704 | 26,704 | +0 | +| `ProMicro_repeater` | nRF52 | 346,208 | 346,240 | +32 | 33,892 | 33,892 | +0 | +| `ProMicro_room_server` | nRF52 | 339,272 | 339,288 | +16 | 37,588 | 37,588 | +0 | +| `ProMicro_companion_radio_usb` | nRF52 | 358,820 | 358,836 | +16 | 99,468 | 99,468 | +0 | +| `ProMicro_sensor` | nRF52 | 342,924 | 342,956 | +32 | 31,988 | 31,988 | +0 | +| `GAT562_30S_Mesh_Kit_repeater` | nRF52 | 411,392 | 411,408 | +16 | 35,932 | 35,932 | +0 | +| `GAT562_Mesh_EVB_Pro_repeater` | nRF52 | 365,588 | 365,604 | +16 | 35,124 | 35,124 | +0 | +| `GAT562_Mesh_Tracker_Pro_repeater` | nRF52 | 381,100 | 381,116 | +16 | 35,492 | 35,492 | +0 | +| `GAT562_Mesh_Watch13_companion_radio_ble` | nRF52 | 399,432 | 399,448 | +16 | 147,888 | 147,888 | +0 | +| `Heltec_mesh_solar_repeater` | nRF52 | 365,584 | 365,600 | +16 | 34,284 | 34,284 | +0 | +| `Heltec_tower_v2_repeater` | nRF52 | 309,512 | 309,528 | +16 | 33,292 | 33,292 | +0 | +| `ikoka_handheld_nrf_e22_30dbm_repeater` | nRF52 | 358,136 | 358,136 | +0 | 34,468 | 34,468 | +0 | +| `ikoka_nano_nrf_22dbm_repeater` | nRF52 | 361,016 | 361,016 | +0 | 34,556 | 34,556 | +0 | +| `ikoka_stick_nrf_22dbm_repeater` | nRF52 | 373,608 | 373,608 | +0 | 34,708 | 34,708 | +0 | +| `KeepteenLT1_repeater` | nRF52 | 322,012 | 322,028 | +16 | 33,436 | 33,436 | +0 | +| `LilyGo_T_Impulse_Plus_repeater` | nRF52 | 366,332 | 366,348 | +16 | 35,236 | 35,236 | +0 | +| `LilyGo_T-Echo_repeater` | nRF52 | 350,080 | 350,096 | +16 | 38,484 | 38,484 | +0 | +| `LilyGo_T-Echo_Card_repeater` | nRF52 | 322,352 | 322,368 | +16 | 34,004 | 34,004 | +0 | +| `LilyGo_T-Echo-Lite_repeater` | nRF52 | 335,756 | 335,772 | +16 | 37,716 | 37,716 | +0 | +| `Meshtiny_companion_radio_usb` | nRF52 | 330,136 | 330,152 | +16 | 141,364 | 141,364 | +0 | +| `R1Neo_repeater` | nRF52 | 368,740 | 368,756 | +16 | 34,892 | 34,892 | +0 | +| `Nano_G2_Ultra_repeater` | nRF52 | 319,484 | 319,500 | +16 | 33,332 | 33,332 | +0 | +| `RAK_3401_repeater` | nRF52 | 410,864 | 410,880 | +16 | 35,636 | 35,636 | +0 | +| `RAK_WisMesh_Tag_repeater` | nRF52 | 368,900 | 368,916 | +16 | 34,916 | 34,916 | +0 | +| `SenseCap_Solar_repeater` | nRF52 | 365,932 | 365,948 | +16 | 34,876 | 34,876 | +0 | +| `ThinkNode_M1_repeater` | nRF52 | 306,164 | 306,180 | +16 | 32,980 | 32,980 | +0 | +| `ThinkNode_M6_repeater` | nRF52 | 366,036 | 366,052 | +16 | 34,932 | 34,932 | +0 | +| `ThinkNode_M8_repeater` | nRF52 | FAIL | FAIL | | | | | +| `WioTrackerL1-1W_repeater` | nRF52 | 378,556 | 378,556 | +0 | 35,436 | 35,436 | +0 | +| `WioTrackerL1_repeater` | nRF52 | 377,548 | 377,564 | +16 | 35,428 | 35,428 | +0 | +| `Xiao_nrf52_repeater` | nRF52 | 361,624 | 361,640 | +16 | 34,596 | 34,596 | +0 | +| `Heltec_t096_repeater` | nRF52 | 414,288 | 414,304 | +16 | 35,564 | 35,564 | +0 | +| `Heltec_t1_repeater` | nRF52 | 413,332 | 413,348 | +16 | 35,204 | 35,204 | +0 | +| `Heltec_t114_without_display_repeater` | nRF52 | 366,084 | 366,100 | +16 | 35,140 | 35,140 | +0 | +| `Mesh_pocket_repeater` | nRF52 | 326,840 | 326,840 | +0 | 36,820 | 36,820 | +0 | +| `muzi_base_uno_repeater` | nRF52 | 301,236 | 301,236 | +0 | 32,932 | 32,932 | +0 | +| `MeshTracker_X1_repeater` | nRF52 | 301,152 | 301,152 | +0 | 32,956 | 32,956 | +0 | +| `t1000e_repeater` | nRF52 | 294,712 | 294,712 | +0 | 32,876 | 32,876 | +0 | +| `Xiao_C6_repeater_` | ESP32 | 542,481 | 542,481 | +0 | 29,856 | 29,856 | +0 | +| `M5Stack_Unit_C6L_repeater` | ESP32 | 552,823 | 552,823 | +0 | 29,752 | 29,752 | +0 | + +
+ +### Hardware + +No hardware evidence (without board). Of the boards in the device pool +(`kb device list`, 20.10.2026), four are of the 31 variants and free: two are the +FUOTA proof of concept boards (`faketec-6e71`, ProMicro, and `p1-4134`, SenseCAP +Solar) with their own flash layout, one is a running sensor node with its own +build (`techo-b43b`, T-Echo), and the T114 is the companion of a test phone. +Flashing a plain repeater onto any of them would destroy what they are kept for, +so none was used. The change moves a function whose body is unchanged into another +translation unit, and the builds show every edited variant links the shared +definition. If a reviewer wants a board run, the check on any of the 31: flash the +repeater firmware, see it boot past `radio_init()` (no `Radio init failed`), and +`get radio` answers with the stored settings. + ## Upstream Searched meshcore-dev/MeshCore on 20.10.2026: text queries over all pull requests diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index f599308735e..d9852995f0b 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -53,7 +53,7 @@ The full analysis, with file and line references for every finding, is in | 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4g; the boot sequence and the forwarding checks are the last two) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md), [0008](0008-forward-policy.md) | ready for review | | 9 | One RadioLib adapter per chip family, part one: the receive detection the seven chip classes shared as copies (step 9a; 9b to 9d planned in the note) | [0009](0009-radio-chip-adapter.md) | 9a ready for review | | 10 | A command table for the companion protocol: a host harness that pins every frame (10a), then one row per command code instead of the if chain (10b); 10c and 10d proposed in the note | [0010](0010-companion-command-table.md) | 10a and 10b ready for review | -| 11 | One generic variant target built from a board description, cut A: one shared `radio_new_identity()`; cut B: one FEM RX gain handler for four Heltec boards (cuts C to E planned in the note) | [0011](0011-variant-target.md) | cuts A and B ready for review | +| 11 | One generic variant target built from a board description, cut A: one shared `radio_new_identity()`; cut B: one FEM RX gain handler for four Heltec boards; cut C: one default `radio_init()` for 31 nRF52 boards (cuts D and E planned in the note) | [0011](0011-variant-target.md) | cuts A, B and C ready for review | Steps are numbered after their design note, so the numbers have gaps: 5 to 8 are notes of fixes and of step 4. See the review for the reasoning behind the From 629cedb1bf0d727efd3d3bea2c4d4effc9bb11f6 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 07:45:03 +0200 Subject: [PATCH 115/136] docs(refactoring): design step 9b, one template for the SX126x chip classes --- docs/refactoring/0009-radio-chip-adapter.md | 162 ++++++++++++++++++ tools/refactoring/scripts/sx126x_inventory.py | 98 +++++++++++ 2 files changed, 260 insertions(+) create mode 100644 tools/refactoring/scripts/sx126x_inventory.py diff --git a/docs/refactoring/0009-radio-chip-adapter.md b/docs/refactoring/0009-radio-chip-adapter.md index 8c2d899ab7a..c3e732236e2 100644 --- a/docs/refactoring/0009-radio-chip-adapter.md +++ b/docs/refactoring/0009-radio-chip-adapter.md @@ -271,3 +271,165 @@ Each step is its own branch with characterization first, as here. Flash cost expected for 9b to 9d: none or a few bytes, because a firmware still links exactly one chip class; the gain is in the source, not in the image. + +## Step 9b: one template for SX1262, SX1268 and LLCC68 + +Status: designed, not implemented yet. Behaviour change: none intended. + +### Measured inventory + +`tools/refactoring/scripts/sx126x_inventory.py` cuts every member out of +`CustomSX1262.h`, `CustomSX1268.h` and `CustomLLCC68.h`, removes comments and +white space, replaces the chip name with a placeholder and compares the copies. +On `integration` 68e0fcdc: + +| Member | Lines (normalized, SX1262) | Groups of identical copies | +|--------|----------------------------|----------------------------| +| includes | 4 | SX1262 / SX1268, LLCC68 | +| `_rxActivity`, constructor | 1 each | all three | +| `std_init()` | 75 | SX1262 / SX1268, LLCC68 | +| `startReceive()` | 3 | all three | +| `isReceiving()` | 14 | all three | +| `setPreambleMillis()`, `setMaxPayloadMillis()` | 1 each | all three | +| `getRxBoostedGainMode()` | 5 | all three | + +SX1268 and LLCC68 are identical to each other in every member. Against SX1262 +they differ in two places only, both in or for `std_init()`: + +1. `#include "MeshCore.h"` (SX1262 only; it brings `MESH_DEBUG_PRINTLN`). +2. In `std_init()`, SX1262 alone has + - the `SX126X_USE_REGULATOR_LDO` option, passed as the last argument of both + `begin()` calls; SX1268 and LLCC68 call `begin()` with eight arguments, so + RadioLib's default `useRegulatorLDO = false` applies (checked in RadioLib + at the pinned commit 6d89348: `SX1262::begin`, `SX1268::begin` and + `LLCC68::begin` all default it to `false`); + - the `SX126X_REGISTER_PATCH` block (read register 0x8B5, set bit 0, write + it back); + - three `MESH_DEBUG_PRINTLN` lines: regulator requested, the retry with the + TCXO at 0 V, and status and device errors at the end. + +Everything else in `std_init()` (TCXO voltage, coding rate, SPI pins per +platform, the retry on -706/-707, `setCRC(1)`, current limit, DIO2, boosted +gain, RF switch pins) is the same text in all three. + +In the tree, no variant that builds SX1268 (`generic-e22`, `meshadventurer`, +`tenstar_c3`) sets `SX126X_USE_REGULATOR_LDO` or `SX126X_REGISTER_PATCH`, and no +variant uses LLCC68 at all. So taking SX1262's `std_init()` for all three would +change no in-tree release image; it would change debug builds (three more lines) +and any downstream board that sets one of the two macros with an SX1268 or +LLCC68. That is still a behaviour change and needs a decision of its own, so +9b keeps the differences. + +### Template form + +A new header `src/helpers/radiolib/CustomSX126x.h`: + +```cpp +template +class CustomSX126x : public Base { + RxActivityTracker _rxActivity; +public: + explicit CustomSX126x(Module* mod) : Base(mod) { } + bool std_init(SPIClass* spi = NULL); // SX1262's body, see below + int16_t startReceive() override; // Base::startReceive(...) + bool isReceiving(); + void setPreambleMillis(uint32_t); + void setMaxPayloadMillis(uint32_t); + bool getRxBoostedGainMode(); +}; +``` + +The three chip headers keep their class, now a thin subclass: + +```cpp +struct SX1262InitTraits { + static constexpr bool regulatorOption = true; // honour SX126X_USE_REGULATOR_LDO + static constexpr bool registerPatch = true; // honour SX126X_REGISTER_PATCH + static constexpr bool initDebug = true; // the three MESH_DEBUG lines + static const char* name() { return "SX1262"; } +}; +class CustomSX1262 : public CustomSX126x { +public: + CustomSX1262(Module* mod) : CustomSX126x(mod) { } +}; +``` + +`SX1268InitTraits` and `LLCC68InitTraits` set all three flags to `false`. In +`std_init()` the flags are plain `if (Traits::flag)` on compile time constants +(C++11, no `if constexpr`; the firmware toolchains do not all build C++17), so +the compiler drops the dead branches: + +- regulator: `bool useRegulatorLDO = Traits::regulatorOption ? : false;` + and `begin()` always gets it as the ninth argument. For SX1268 and LLCC68 that + is `false`, the value RadioLib's default gave them before. +- register patch: the `#ifdef SX126X_REGISTER_PATCH` block stays, its body under + `if (Traits::registerPatch)`. +- debug lines: under `if (Traits::initDebug)`, printing `Traits::name()` through + `%s`, which prints the same text as the literal "SX1262" before. + +`CustomSX126x.h` includes `MeshCore.h` for all three; for SX1268 and LLCC68 that +is one more include, no new symbol a firmware uses. The wrappers, `SX126xReset.h` +and `CustomSTM32WLx` are untouched (wrappers are step 9c; STM32WLx has a different +module type and no `std_init()`, and keeps its own class with the tracker). + +Deciding later to give SX1268 and LLCC68 the LDO option, the patch or the debug +lines is then a one line change of a trait, reviewable as the behaviour change +it is. + +### Alternatives considered for 9b + +- **Take SX1262's `std_init()` as is for all three.** Smallest code, but the + behaviour change described above. Rejected for this step, offered as a + separate decision. +- **`using CustomSX1262 = CustomSX126x;` instead of a subclass.** + Breaks a downstream forward declaration `class CustomSX1262;`, and puts every + SX1262 only addition (such as #3638's) into the shared template. The thin + subclass keeps the class name a real class and gives chip only code a place. +- **One `bool` template parameter instead of a traits struct.** The three + differences happen to coincide today, but one flag would make "give SX1268 + the LDO option" impossible without also giving it the patch. Three named + flags say what each one guards. +- **A non template base class over `SX126x`.** `begin()` is not virtual in + `SX1268` (it is in `SX1262`), so `std_init()` must call the concrete chip's + `begin()`; that needs the chip type, i.e. a template. +- **A macro that stamps out the class body.** Unreadable in a debugger and in + compiler errors; nothing gained over a template. + +### Upstream collisions (checked 21 October 2026) + +All open pull requests on `meshcore-dev/MeshCore` (499) were listed with their +files; these touch `CustomSX1262.h`, `CustomSX1268.h` or `CustomLLCC68.h`: + +| PR | Touches | Collision with 9b | +|----|---------|-------------------| +| #3638 dual radio profiles | `CustomSX1262.h` +380 -2: new members (profile switching, `standby()`/`sleep()`/`setBandwidth()`... overrides, TCXO restore) and a new `startReceive()` body; `std_init()` and `isReceiving()` unchanged | Heavy textual, light semantic. Its members are SX1262 only and land in the thin `CustomSX1262` subclass unchanged; its `startReceive()` there overrides the template's. A rebase moves its hunks from the old class body into the subclass. No line of the shared template needs changing for it. | +| #2131 error aware receive hook | +6 -1 in each of the three | Textual; with the template it becomes one helper in `CustomSX126x` instead of three. | +| #2869 nRF52 radio init recovery | +27 -2 in each of the three `std_init()` | Textual; again one edit instead of three after 9b. | +| #2912 bump RadioLib to 7.7.1 | +14 to +17 in each `std_init()` (`ConfigLoRa_t`) | Textual; one edit instead of three. | +| #2933 noise floor | `CustomSX1262.h` +3 -1, the status debug line | Textual, one line in the template. | +| #3530 nRF54L platform | `CustomSX1262.h` +2, one more SPI branch | Textual, two lines in the template; would then also reach SX1268 and LLCC68 boards on that platform, which is that PR's concern. | +| #2624 DX-LR20 variant | `CustomLLCC68.h` +5 -5 | Textual; to be checked on rebase whether its change is LLCC68 only (then a trait or the subclass) or general. | + +#2682, #2999, #2944 (named in the 9a section) no longer touch these three files. +#3638 is the heavy one, but because 9b keeps `CustomSX1262` as a class and its +additions are SX1262 only, the collision is a hunk move, not a redesign. This +does not make 9b pointless; four of the seven PRs edit three copies identically +and would get smaller with it. + +### Planned verification + +- Characterization tests first, on the host against the stubs in + `test/test_radio_chips/stubs` (extended to record `begin()` arguments, register + reads and writes and the setter calls): `std_init()` without any option macro + for all three chips, the retry with TCXO 0 V, the failure path; and in a second + suite (`test/test_radio_chips_init`, its own program, so the macros differ + without an ODR clash) with `SX126X_USE_REGULATOR_LDO`, `SX126X_REGISTER_PATCH`, + `MESH_DEBUG` and the other option macros set, pinning per chip that only SX1262 + passes LDO, patches 0x8B5 and prints the three lines. +- Mutation tests, among them each trait flipped for SX1268. +- Build matrix: every environment with SX1268 (`Generic_E22_sx1268_*`, + `meshadventurer` SX1268, `tenstar_c3` SX1268), the five boards that set + `SX126X_REGISTER_PATCH`, the standard 18, STM32WLx; flash and RAM against + 68e0fcdc. No LLCC68 firmware environment exists; LLCC68 is covered by the host + suites only. +- All 17 native environments. diff --git a/tools/refactoring/scripts/sx126x_inventory.py b/tools/refactoring/scripts/sx126x_inventory.py new file mode 100644 index 00000000000..6f85955f86a --- /dev/null +++ b/tools/refactoring/scripts/sx126x_inventory.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Measure how far the SX126x chip classes in src/helpers/radiolib differ. + +Usage: python3 tools/refactoring/scripts/sx126x_inventory.py [repo root] + +Cuts every member out of CustomSX1262.h, CustomSX1268.h and CustomLLCC68.h, +removes comments and white space and replaces the chip name with a placeholder, +then prints per member which chips carry an identical copy. For members that +differ, it prints a unified diff of the normalized lines against SX1262. +Lines outside the class body (includes) are compared the same way. +""" +import difflib +import hashlib +import re +import sys +from pathlib import Path + +CHIPS = ["SX1262", "SX1268", "LLCC68"] + + +def normalize(text, chip): + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + text = re.sub(r"//[^\n]*", "", text) + text = text.replace("Custom" + chip, "Custom@CHIP@").replace(chip, "@CHIP@") + lines = [re.sub(r"\s+", " ", l).strip() for l in text.splitlines()] + return [l for l in lines if l] + + +def members(lines): + """Split the normalized class body into members by brace depth.""" + out, head, cur, depth, in_class = {}, [], [], 0, False + for l in lines: + if not in_class: + if l.startswith("class "): + in_class = True + depth = l.count("{") - l.count("}") + else: + head.append(l) + continue + if depth == 1 and not cur and l in ("public:", "};"): + continue + cur.append(l) + depth += l.count("{") - l.count("}") + if depth == 1 and (l.endswith("}") or l.endswith(";")): + sig = next((c for c in cur if not c.startswith("#")), cur[0]) + name = re.search(r"([~\w]+)\s*\(", sig) + key = name.group(1) if name else sig + while key in out: + key += "'" + out[key] = cur + cur = [] + return head, out + + +def main(): + root = Path(sys.argv[1] if len(sys.argv) > 1 else ".") + parsed = {} + for chip in CHIPS: + src = (root / "src/helpers/radiolib" / f"Custom{chip}.h").read_text() + parsed[chip] = members(normalize(src, chip)) + + names = [] + for chip in CHIPS: + for n in ["(includes)"] + list(parsed[chip][1]): + if n not in names: + names.append(n) + + def body(chip, n): + head, ms = parsed[chip] + return head if n == "(includes)" else ms.get(n) + + print(f"{'member':<22} {'lines':>5} groups of identical copies") + diffs = [] + for n in names: + groups = {} + for chip in CHIPS: + b = body(chip, n) + h = hashlib.sha1("\n".join(b).encode()).hexdigest()[:8] if b is not None else "absent" + groups.setdefault(h, []).append(chip) + ln = len(body("SX1262", n) or []) + desc = " / ".join(", ".join(v) + ("" if k != "absent" else " (absent)") for k, v in groups.items()) + print(f"{n:<22} {ln:>5} {desc}") + if len(groups) > 1: + diffs.append(n) + + for n in diffs: + for chip in CHIPS[1:]: + a, b = body("SX1262", n) or [], body(chip, n) or [] + if a == b: + continue + print(f"\n--- {n}: SX1262 vs {chip}") + for l in difflib.unified_diff(a, b, "SX1262", chip, n=0, lineterm=""): + if not l.startswith(("---", "+++")): + print(l) + + +if __name__ == "__main__": + main() From 568891f9fe9f9c0655aa81121980d35ef0c638c6 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 07:45:09 +0200 Subject: [PATCH 116/136] docs(refactoring): wording in the 9b collision list --- docs/refactoring/0009-radio-chip-adapter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/refactoring/0009-radio-chip-adapter.md b/docs/refactoring/0009-radio-chip-adapter.md index c3e732236e2..dfc5d27794a 100644 --- a/docs/refactoring/0009-radio-chip-adapter.md +++ b/docs/refactoring/0009-radio-chip-adapter.md @@ -410,7 +410,7 @@ files; these touch `CustomSX1262.h`, `CustomSX1268.h` or `CustomLLCC68.h`: | #3530 nRF54L platform | `CustomSX1262.h` +2, one more SPI branch | Textual, two lines in the template; would then also reach SX1268 and LLCC68 boards on that platform, which is that PR's concern. | | #2624 DX-LR20 variant | `CustomLLCC68.h` +5 -5 | Textual; to be checked on rebase whether its change is LLCC68 only (then a trait or the subclass) or general. | -#2682, #2999, #2944 (named in the 9a section) no longer touch these three files. +#2682, #2999, #2944 (named in the 9a section) do not touch these three files. #3638 is the heavy one, but because 9b keeps `CustomSX1262` as a class and its additions are SX1262 only, the collision is a hunk move, not a redesign. This does not make 9b pointless; four of the seven PRs edit three copies identically From 61d3a4f47abff1c9072992678d19943e4becb0d2 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 07:47:49 +0200 Subject: [PATCH 117/136] test(radio_chips): pin std_init() of SX1262, SX1268 and LLCC68 with and without option macros --- platformio.ini | 5 +- test/test_radio_chips/stubs/RadioLib.h | 72 +++++++++-- test/test_radio_chips/test_radio_chips.cpp | 74 +++++++++++ .../test_radio_chips_init.cpp | 122 ++++++++++++++++++ 4 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 test/test_radio_chips_init/test_radio_chips_init.cpp diff --git a/platformio.ini b/platformio.ini index 5a57d9fa55d..301a1f66753 100644 --- a/platformio.ini +++ b/platformio.ini @@ -175,6 +175,7 @@ test_ignore = test_node_prefs test_radio_wrappers test_radio_chips + test_radio_chips_init test_server_base test_sensor_role test_repeater_role @@ -573,7 +574,9 @@ build_flags = -std=c++17 -D LORA_TX_POWER=22 -D LR2021_IRQ_DIO=8 test_build_src = yes -test_filter = test_radio_chips +test_filter = + test_radio_chips + test_radio_chips_init build_src_filter = -<*> lib_deps = diff --git a/test/test_radio_chips/stubs/RadioLib.h b/test/test_radio_chips/stubs/RadioLib.h index c8f54074683..7cea666da9a 100644 --- a/test/test_radio_chips/stubs/RadioLib.h +++ b/test/test_radio_chips/stubs/RadioLib.h @@ -14,6 +14,9 @@ #include #include +#include +#include +#include #include #define RADIOLIB_ERR_NONE 0 @@ -63,10 +66,24 @@ enum { #define RADIOLIB_LR2021_RX_TIMEOUT_INF (0xFFFFFFUL) #define RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE (0x12) +// Formats a call and its arguments for the call logs below, so a test can pin +// which calls a chip class makes, with which arguments, in which order. +inline std::string stubArg(double v) { char b[32]; snprintf(b, sizeof(b), "%g", v); return b; } +inline std::string stubArg(float v) { return stubArg((double)v); } +template std::string stubArg(T v) { return std::to_string(v); } +template std::string stubCall(const char* name, A... a) { + std::string s = name; + s += "("; + const char* sep = ""; + ((s += sep, s += stubArg(a), sep = ","), ...); + return s + ")"; +} + class SPIClass { public: - template void begin(A...) { } - template void setPins(A...) { } + std::vector calls; + template void begin(A... a) { calls.push_back(stubCall("begin", a...)); } + template void setPins(A... a) { calls.push_back(stubCall("setPins", a...)); } }; class Module { @@ -112,21 +129,52 @@ class StubChip { template int16_t begin(A...) { return RADIOLIB_ERR_NONE; } template int16_t standby(A...) { return RADIOLIB_ERR_NONE; } - template int16_t setCRC(A...) { return RADIOLIB_ERR_NONE; } - template int16_t setCurrentLimit(A...) { return RADIOLIB_ERR_NONE; } - template int16_t setDio2AsRfSwitch(A...) { return RADIOLIB_ERR_NONE; } - template int16_t setRxBoostedGainMode(A...) { return RADIOLIB_ERR_NONE; } - template void setRfSwitchPins(A...) { } - template int16_t readRegister(A...) { return RADIOLIB_ERR_NONE; } - template int16_t writeRegister(A...) { return RADIOLIB_ERR_NONE; } + // The configuration calls std_init() makes, in order, with their arguments. + std::vector calls; + // Register contents readRegister() returns and writeRegister() changes. + std::map regs; + + template int16_t setCRC(A... a) { calls.push_back(stubCall("setCRC", a...)); return RADIOLIB_ERR_NONE; } + template int16_t setCurrentLimit(A... a) { calls.push_back(stubCall("setCurrentLimit", a...)); return RADIOLIB_ERR_NONE; } + template int16_t setDio2AsRfSwitch(A... a) { calls.push_back(stubCall("setDio2AsRfSwitch", a...)); return RADIOLIB_ERR_NONE; } + template int16_t setRxBoostedGainMode(A... a) { calls.push_back(stubCall("setRxBoostedGainMode", a...)); return RADIOLIB_ERR_NONE; } + template void setRfSwitchPins(A... a) { calls.push_back(stubCall("setRfSwitchPins", a...)); } + int16_t readRegister(uint16_t addr, uint8_t* data, size_t len) { + calls.push_back(stubCall("readRegister", addr, len)); + for (size_t i = 0; i < len; i++) data[i] = regs[addr + i]; + return RADIOLIB_ERR_NONE; + } + int16_t writeRegister(uint16_t addr, const uint8_t* data, size_t len) { + std::string c = stubCall("writeRegister", addr, len); + for (size_t i = 0; i < len; i++) { regs[addr + i] = data[i]; c += ":" + std::to_string(data[i]); } + calls.push_back(c); + return RADIOLIB_ERR_NONE; + } template int16_t setRegulatorDCDC(A...) { return RADIOLIB_ERR_NONE; } template int16_t checkOutputPower(A...) { return RADIOLIB_ERR_NONE; } template int16_t explicitHeader(A...) { return RADIOLIB_ERR_NONE; } - uint8_t getStatus() { return 0; } - uint16_t getDeviceErrors() { return 0; } + // Distinct values, so a debug line that prints them is pinned with them. + uint8_t getStatus() { return 0xA5; } + uint16_t getDeviceErrors() { return 0x0120; } }; -class SX126x : public StubChip { public: using StubChip::StubChip; }; +// The SX126x begin() with RadioLib's parameters and defaults (SX1262.h, SX1268.h +// and LLCC68.h at the pinned commit all default useRegulatorLDO to false). Each +// call is logged; the results a test queues are returned in order, then +// RADIOLIB_ERR_NONE. +class SX126x : public StubChip { +public: + using StubChip::StubChip; + std::vector begin_results; + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, uint8_t syncWord = RADIOLIB_SX126X_SYNC_WORD_PRIVATE, + int8_t power = 10, uint16_t preambleLength = 8, float tcxoVoltage = 1.6, bool useRegulatorLDO = false) { + calls.push_back(stubCall("begin", freq, bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage, useRegulatorLDO)); + if (begin_results.empty()) return RADIOLIB_ERR_NONE; + int16_t r = begin_results.front(); + begin_results.erase(begin_results.begin()); + return r; + } +}; class SX1262 : public SX126x { public: using SX126x::SX126x; }; class SX1268 : public SX126x { public: using SX126x::SX126x; }; class LLCC68 : public SX1262 { public: using SX1262::SX1262; }; diff --git a/test/test_radio_chips/test_radio_chips.cpp b/test/test_radio_chips/test_radio_chips.cpp index bc0dab8abdf..2eb366db37a 100644 --- a/test/test_radio_chips/test_radio_chips.cpp +++ b/test/test_radio_chips/test_radio_chips.cpp @@ -254,6 +254,80 @@ TYPED_TEST(RadioChipTest, StartReceiveAsksForThePreambleIrq) { EXPECT_EQ(c.len, 0u); } +// std_init() and getRxBoostedGainMode() of the three SX126x classes that have a +// std_init(), built here without any of the board option macros +// (SX126X_USE_REGULATOR_LDO, SX126X_REGISTER_PATCH, SX126X_CURRENT_LIMIT, ...), +// without P_LORA_SCLK and without MESH_DEBUG. test_radio_chips_init builds the +// same classes with all of them set. + +template +struct SX126xInitTest : public ::testing::Test { + Module mod; + T radio{&mod}; + void SetUp() override { Serial.reset(); } +}; +using SX126xChips = ::testing::Types; +TYPED_TEST_SUITE(SX126xInitTest, SX126xChips); + +// LORA_FREQ, LORA_BW, LORA_SF and LORA_TX_POWER come from the environment's +// build flags; coding rate 5, the private sync word (0x12 = 18), preamble 16, +// TCXO 1.6 V and the DC-DC regulator are the defaults std_init() supplies. +const std::string BEGIN_DEFAULT = "begin(869.525,250,11,5,18,22,16,1.6,0)"; +const std::string BEGIN_TCXO_OFF = "begin(869.525,250,11,5,18,22,16,0,0)"; + +TYPED_TEST(SX126xInitTest, StdInitWithoutOptions) { + EXPECT_TRUE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT, "setCRC(1)"}; + EXPECT_EQ(this->radio.calls, want); + EXPECT_EQ(Serial.out, ""); +} + +TYPED_TEST(SX126xInitTest, StdInitWithSpiButNoPinsLeavesSpiAlone) { + SPIClass spi; + EXPECT_TRUE(this->radio.std_init(&spi)); + EXPECT_TRUE(spi.calls.empty()); +} + +TYPED_TEST(SX126xInitTest, SpiCmdFailedRetriesWithTcxoOff) { + this->radio.begin_results = {RADIOLIB_ERR_SPI_CMD_FAILED}; + EXPECT_TRUE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT, BEGIN_TCXO_OFF, "setCRC(1)"}; + EXPECT_EQ(this->radio.calls, want); + EXPECT_EQ(Serial.out, ""); +} + +TYPED_TEST(SX126xInitTest, SpiCmdInvalidRetriesWithTcxoOff) { + this->radio.begin_results = {RADIOLIB_ERR_SPI_CMD_INVALID}; + EXPECT_TRUE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT, BEGIN_TCXO_OFF, "setCRC(1)"}; + EXPECT_EQ(this->radio.calls, want); +} + +TYPED_TEST(SX126xInitTest, FailedRetryReportsTheSecondError) { + this->radio.begin_results = {RADIOLIB_ERR_SPI_CMD_FAILED, RADIOLIB_ERR_SPI_CMD_INVALID}; + EXPECT_FALSE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT, BEGIN_TCXO_OFF}; + EXPECT_EQ(this->radio.calls, want); + EXPECT_EQ(Serial.out, "ERROR: radio init failed: -706\n"); +} + +TYPED_TEST(SX126xInitTest, OtherErrorIsNotRetried) { + this->radio.begin_results = {-2}; + EXPECT_FALSE(this->radio.std_init()); + std::vector want = {BEGIN_DEFAULT}; + EXPECT_EQ(this->radio.calls, want); + EXPECT_EQ(Serial.out, "ERROR: radio init failed: -2\n"); +} + +TYPED_TEST(SX126xInitTest, RxBoostedGainModeReadsTheGainRegister) { + this->radio.regs[RADIOLIB_SX126X_REG_RX_GAIN] = RADIOLIB_SX126X_RX_GAIN_BOOSTED; + EXPECT_TRUE(this->radio.getRxBoostedGainMode()); + this->radio.regs[RADIOLIB_SX126X_REG_RX_GAIN] = 0x94; // power saving gain + EXPECT_FALSE(this->radio.getRxBoostedGainMode()); + std::vector want = {"readRegister(2220,1)", "readRegister(2220,1)"}; + EXPECT_EQ(this->radio.calls, want); +} + } // namespace int main(int argc, char **argv) { diff --git a/test/test_radio_chips_init/test_radio_chips_init.cpp b/test/test_radio_chips_init/test_radio_chips_init.cpp new file mode 100644 index 00000000000..1002a6536d7 --- /dev/null +++ b/test/test_radio_chips_init/test_radio_chips_init.cpp @@ -0,0 +1,122 @@ +// The board option macros std_init() of the SX126x classes reads, all set, and +// MESH_DEBUG on. This is its own test program (not part of test_radio_chips), +// because the chip classes are header only: the two builds must not be linked +// into one binary with different macro values. +#define ARDUINO 1 +#define MESH_DEBUG 1 +#define SX126X_USE_REGULATOR_LDO true +#define SX126X_REGISTER_PATCH 1 +#define SX126X_DIO3_TCXO_VOLTAGE 1.8f +#define LORA_CR 8 +#define SX126X_CURRENT_LIMIT 140 +#define SX126X_DIO2_AS_RF_SWITCH true +#define SX126X_RX_BOOSTED_GAIN 1 +#define SX126X_RXEN 7 +#define SX126X_TXEN 8 +#define P_LORA_SCLK 5 +#define P_LORA_MISO 3 +#define P_LORA_MOSI 6 + +#include + +#include +#include +#include + +// What std_init() does with every option set. All three chips share the +// configuration path; SX1262 alone passes the LDO option to begin(), applies the +// register patch for 0x8B5 and prints its three debug lines. SX1268 and LLCC68 +// ignore those two macros and print nothing. These differences are pinned per +// chip, so a refactor that merges the classes has to keep them. + +namespace { + +template +struct InitTest : public ::testing::Test { + Module mod; + T radio{&mod}; + SPIClass spi; + void SetUp() override { + Serial.reset(); + radio.regs[0x8B5] = 0x40; + } +}; + +class SX1262Init : public InitTest { }; +class SX1268Init : public InitTest { }; +class LLCC68Init : public InitTest { }; + +// The calls between begin() and the register patch, the same for all three. +const std::vector CONFIG = { + "setCRC(1)", "setCurrentLimit(140)", "setDio2AsRfSwitch(1)", + "setRxBoostedGainMode(1)", "setRfSwitchPins(7,8)", +}; +const std::vector SPI_BEGIN = {"begin(5,3,6)"}; + +std::vector join(std::vector a, const std::vector& b) { + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +TEST_F(SX1262Init, AllOptions) { + EXPECT_TRUE(radio.std_init(&spi)); + EXPECT_EQ(spi.calls, SPI_BEGIN); + auto want = join({"begin(869.525,250,11,8,18,22,16,1.8,1)"}, CONFIG); + want = join(want, {"readRegister(2229,1)", "writeRegister(2229,1):65"}); + EXPECT_EQ(radio.calls, want); + EXPECT_EQ(radio.regs[0x8B5], 0x41); + EXPECT_EQ(Serial.out, + "DEBUG: SX1262 regulator requested: LDO\n" + "DEBUG: SX1262 status=0xA5 device_errors=0x0120\n"); +} + +TEST_F(SX1262Init, RetryKeepsLdoAndPrintsTheError) { + radio.begin_results = {RADIOLIB_ERR_SPI_CMD_FAILED}; + EXPECT_TRUE(radio.std_init(&spi)); + auto want = join({"begin(869.525,250,11,8,18,22,16,1.8,1)", "begin(869.525,250,11,8,18,22,16,0,1)"}, CONFIG); + want = join(want, {"readRegister(2229,1)", "writeRegister(2229,1):65"}); + EXPECT_EQ(radio.calls, want); + EXPECT_EQ(Serial.out, + "DEBUG: SX1262 regulator requested: LDO\n" + "DEBUG: SX1262 init failed with error -707, retrying with TCXO at 0.0V\n" + "DEBUG: SX1262 status=0xA5 device_errors=0x0120\n"); +} + +TEST_F(SX1262Init, FailureSkipsPatchAndStatusLine) { + radio.begin_results = {-2}; + EXPECT_FALSE(radio.std_init(&spi)); + std::vector want = {"begin(869.525,250,11,8,18,22,16,1.8,1)"}; + EXPECT_EQ(radio.calls, want); + EXPECT_EQ(Serial.out, + "DEBUG: SX1262 regulator requested: LDO\n" + "ERROR: radio init failed: -2\n"); +} + +// SX1268 and LLCC68: begin() without the LDO argument (RadioLib's default false), +// no register patch, no debug lines. +template void expectWithoutSX1262Extras(F& t) { + EXPECT_TRUE(t.radio.std_init(&t.spi)); + EXPECT_EQ(t.spi.calls, SPI_BEGIN); + EXPECT_EQ(t.radio.calls, join({"begin(869.525,250,11,8,18,22,16,1.8,0)"}, CONFIG)); + EXPECT_EQ(t.radio.regs[0x8B5], 0x40); + EXPECT_EQ(Serial.out, ""); +} + +template void expectRetryWithoutSX1262Extras(F& t) { + t.radio.begin_results = {RADIOLIB_ERR_SPI_CMD_FAILED}; + EXPECT_TRUE(t.radio.std_init(&t.spi)); + EXPECT_EQ(t.radio.calls, join({"begin(869.525,250,11,8,18,22,16,1.8,0)", "begin(869.525,250,11,8,18,22,16,0,0)"}, CONFIG)); + EXPECT_EQ(Serial.out, ""); +} + +TEST_F(SX1268Init, AllOptions) { expectWithoutSX1262Extras(*this); } +TEST_F(SX1268Init, RetryIsSilent) { expectRetryWithoutSX1262Extras(*this); } +TEST_F(LLCC68Init, AllOptions) { expectWithoutSX1262Extras(*this); } +TEST_F(LLCC68Init, RetryIsSilent) { expectRetryWithoutSX1262Extras(*this); } + +} // namespace + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 2228a5d6e6e8c2936e0a427f800bd4cde81d87f5 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 07:50:51 +0200 Subject: [PATCH 118/136] refactor(radiolib): one template CustomSX126x for SX1262, SX1268 and LLCC68 --- src/helpers/radiolib/CustomLLCC68.h | 121 +++-------------------- src/helpers/radiolib/CustomSX1262.h | 141 +++------------------------ src/helpers/radiolib/CustomSX1268.h | 122 +++--------------------- src/helpers/radiolib/CustomSX126x.h | 143 ++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 339 deletions(-) create mode 100644 src/helpers/radiolib/CustomSX126x.h diff --git a/src/helpers/radiolib/CustomLLCC68.h b/src/helpers/radiolib/CustomLLCC68.h index a2cc0a65692..360261e0414 100644 --- a/src/helpers/radiolib/CustomLLCC68.h +++ b/src/helpers/radiolib/CustomLLCC68.h @@ -1,109 +1,18 @@ #pragma once -#include -#include "RxActivityTracker.h" - -class CustomLLCC68 : public LLCC68 { - RxActivityTracker _rxActivity; - +#include "CustomSX126x.h" + +// LLCC68 has never had the SX1262 only parts of std_init(): it ignores +// SX126X_USE_REGULATOR_LDO and SX126X_REGISTER_PATCH and prints no init debug +// lines. Giving it any of them is a behaviour change of its own. +struct CustomLLCC68Traits { + static constexpr bool regulatorOption = false; + static constexpr bool registerPatch = false; + static constexpr bool initDebug = false; + static const char* name() { return "LLCC68"; } +}; + +class CustomLLCC68 : public CustomSX126x { public: - CustomLLCC68(Module *mod) : LLCC68(mod) { } - - #ifdef RP2040_PLATFORM - bool std_init(SPIClassRP2040* spi = NULL) - #else - bool std_init(SPIClass* spi = NULL) - #endif - { - #ifdef SX126X_DIO3_TCXO_VOLTAGE - float tcxo = SX126X_DIO3_TCXO_VOLTAGE; - #else - float tcxo = 1.6f; - #endif - - #ifdef LORA_CR - uint8_t cr = LORA_CR; - #else - uint8_t cr = 5; - #endif - - #if defined(P_LORA_SCLK) - #ifdef NRF52_PLATFORM - if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } - #elif defined(RP2040_PLATFORM) - if (spi) { - spi->setMISO(P_LORA_MISO); - //spi->setCS(P_LORA_NSS); // Setting CS results in freeze - spi->setSCK(P_LORA_SCLK); - spi->setMOSI(P_LORA_MOSI); - spi->begin(); - } - #else - if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); - #endif - #endif - int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); - // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f - if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { - tcxo = 0.0f; - status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); - } - if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); - return false; // fail - } - - setCRC(1); - - #ifdef SX126X_CURRENT_LIMIT - setCurrentLimit(SX126X_CURRENT_LIMIT); - #endif - #ifdef SX126X_DIO2_AS_RF_SWITCH - setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); - #endif - #ifdef SX126X_RX_BOOSTED_GAIN - setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); - #endif - #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX126X_RXEN - #define SX126X_RXEN RADIOLIB_NC - #endif - #ifndef SX126X_TXEN - #define SX126X_TXEN RADIOLIB_NC - #endif - setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); - #endif - - return true; // success - } - - int16_t startReceive() override { - // include the PREAMBLE_DETECTED irq bit in reported flags - return LLCC68::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - } - - bool isReceiving() { - uint32_t irq = getIrqFlags(); - RxActivityTracker::Result r = _rxActivity.update( - irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, - irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, - irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, - millis()); - if (r == RxActivityTracker::CLEAR_PREAMBLE) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - } else if (r == RxActivityTracker::CLEAR_ALL) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - } - return r == RxActivityTracker::RECEIVING; - } - - void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } - void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } - - bool getRxBoostedGainMode() { - uint8_t rxGain = 0; - readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); - return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); - } -}; \ No newline at end of file + CustomLLCC68(Module *mod) : CustomSX126x(mod) { } +}; diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b78c3836086..31ae6eccdd7 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -1,129 +1,18 @@ #pragma once -#include -#include "MeshCore.h" -#include "RxActivityTracker.h" - -class CustomSX1262 : public SX1262 { - RxActivityTracker _rxActivity; - +#include "CustomSX126x.h" + +// SX1262 is the chip std_init() was written for: it honours +// SX126X_USE_REGULATOR_LDO and SX126X_REGISTER_PATCH and prints its init debug +// lines. +struct CustomSX1262Traits { + static constexpr bool regulatorOption = true; + static constexpr bool registerPatch = true; + static constexpr bool initDebug = true; + static const char* name() { return "SX1262"; } +}; + +class CustomSX1262 : public CustomSX126x { public: - CustomSX1262(Module *mod) : SX1262(mod) { } - - #ifdef RP2040_PLATFORM - bool std_init(SPIClassRP2040* spi = NULL) - #else - bool std_init(SPIClass* spi = NULL) - #endif - { - #ifdef SX126X_DIO3_TCXO_VOLTAGE - float tcxo = SX126X_DIO3_TCXO_VOLTAGE; - #else - float tcxo = 1.6f; - #endif - - #ifdef LORA_CR - uint8_t cr = LORA_CR; - #else - uint8_t cr = 5; - #endif - - #ifdef SX126X_USE_REGULATOR_LDO - constexpr bool useRegulatorLDO = SX126X_USE_REGULATOR_LDO; - #else - constexpr bool useRegulatorLDO = false; - #endif - - MESH_DEBUG_PRINTLN("SX1262 regulator requested: %s", useRegulatorLDO ? "LDO" : "DC-DC"); - - #if defined(P_LORA_SCLK) - #ifdef NRF52_PLATFORM - if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } - #elif defined(RP2040_PLATFORM) - if (spi) { - spi->setMISO(P_LORA_MISO); - //spi->setCS(P_LORA_NSS); // Setting CS results in freeze - spi->setSCK(P_LORA_SCLK); - spi->setMOSI(P_LORA_MOSI); - spi->begin(); - } - #else - if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); - #endif - #endif - int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); - // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f - if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { - MESH_DEBUG_PRINTLN("SX1262 init failed with error %d, retrying with TCXO at 0.0V", status); - tcxo = 0.0f; - status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); - } - if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); - return false; // fail - } - - setCRC(1); - - #ifdef SX126X_CURRENT_LIMIT - setCurrentLimit(SX126X_CURRENT_LIMIT); - #endif - #ifdef SX126X_DIO2_AS_RF_SWITCH - setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); - #endif - #ifdef SX126X_RX_BOOSTED_GAIN - setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); - #endif - #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX126X_RXEN - #define SX126X_RXEN RADIOLIB_NC - #endif - #ifndef SX126X_TXEN - #define SX126X_TXEN RADIOLIB_NC - #endif - setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); - #endif - - // for improved RX with Heltec v4 - #ifdef SX126X_REGISTER_PATCH - uint8_t r_data = 0; - readRegister(0x8B5, &r_data, 1); - r_data |= 0x01; - writeRegister(0x8B5, &r_data, 1); - #endif - - MESH_DEBUG_PRINTLN("SX1262 status=0x%02X device_errors=0x%04X", getStatus(), getDeviceErrors()); - - return true; // success - } - - int16_t startReceive() override { - // include the PREAMBLE_DETECTED irq bit in reported flags - return SX1262::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - } - - bool isReceiving() { - uint32_t irq = getIrqFlags(); - RxActivityTracker::Result r = _rxActivity.update( - irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, - irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, - irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, - millis()); - if (r == RxActivityTracker::CLEAR_PREAMBLE) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - } else if (r == RxActivityTracker::CLEAR_ALL) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - } - return r == RxActivityTracker::RECEIVING; - } - - void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } - void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } - - bool getRxBoostedGainMode() { - uint8_t rxGain = 0; - readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); - return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); - } -}; \ No newline at end of file + CustomSX1262(Module *mod) : CustomSX126x(mod) { } +}; diff --git a/src/helpers/radiolib/CustomSX1268.h b/src/helpers/radiolib/CustomSX1268.h index 4fd95517c46..8172dc96142 100644 --- a/src/helpers/radiolib/CustomSX1268.h +++ b/src/helpers/radiolib/CustomSX1268.h @@ -1,110 +1,18 @@ #pragma once -#include -#include "RxActivityTracker.h" - -class CustomSX1268 : public SX1268 { - RxActivityTracker _rxActivity; - +#include "CustomSX126x.h" + +// SX1268 has never had the SX1262 only parts of std_init(): it ignores +// SX126X_USE_REGULATOR_LDO and SX126X_REGISTER_PATCH and prints no init debug +// lines. Giving it any of them is a behaviour change of its own. +struct CustomSX1268Traits { + static constexpr bool regulatorOption = false; + static constexpr bool registerPatch = false; + static constexpr bool initDebug = false; + static const char* name() { return "SX1268"; } +}; + +class CustomSX1268 : public CustomSX126x { public: - CustomSX1268(Module *mod) : SX1268(mod) { } - - #ifdef RP2040_PLATFORM - bool std_init(SPIClassRP2040* spi = NULL) - #else - bool std_init(SPIClass* spi = NULL) - #endif - { - #ifdef SX126X_DIO3_TCXO_VOLTAGE - float tcxo = SX126X_DIO3_TCXO_VOLTAGE; - #else - float tcxo = 1.6f; - #endif - - #ifdef LORA_CR - uint8_t cr = LORA_CR; - #else - uint8_t cr = 5; - #endif - - #if defined(P_LORA_SCLK) - #ifdef NRF52_PLATFORM - if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } - #elif defined(RP2040_PLATFORM) - if (spi) { - spi->setMISO(P_LORA_MISO); - //spi->setCS(P_LORA_NSS); // Setting CS results in freeze - spi->setSCK(P_LORA_SCLK); - spi->setMOSI(P_LORA_MOSI); - spi->begin(); - } - #else - if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); - #endif - #endif - int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); - // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f - if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { - tcxo = 0.0f; - status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo); - } - if (status != RADIOLIB_ERR_NONE) { - Serial.print("ERROR: radio init failed: "); - Serial.println(status); - return false; // fail - } - - setCRC(1); - - #ifdef SX126X_CURRENT_LIMIT - setCurrentLimit(SX126X_CURRENT_LIMIT); - #endif - #ifdef SX126X_DIO2_AS_RF_SWITCH - setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); - #endif - #ifdef SX126X_RX_BOOSTED_GAIN - setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); - #endif - #if defined(SX126X_RXEN) || defined(SX126X_TXEN) - #ifndef SX126X_RXEN - #define SX126X_RXEN RADIOLIB_NC - #endif - #ifndef SX126X_TXEN - #define SX126X_TXEN RADIOLIB_NC - #endif - setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); - #endif - - return true; // success - } - - int16_t startReceive() override { - // include the PREAMBLE_DETECTED irq bit in reported flags - return SX1268::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - } - - bool isReceiving() { - uint32_t irq = getIrqFlags(); - RxActivityTracker::Result r = _rxActivity.update( - irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, - irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, - irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, - millis()); - if (r == RxActivityTracker::CLEAR_PREAMBLE) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); - } else if (r == RxActivityTracker::CLEAR_ALL) { - clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); - } - return r == RxActivityTracker::RECEIVING; - } - - void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } - void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } - - - bool getRxBoostedGainMode() { - uint8_t rxGain = 0; - readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); - return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); - } -}; \ No newline at end of file + CustomSX1268(Module *mod) : CustomSX126x(mod) { } +}; diff --git a/src/helpers/radiolib/CustomSX126x.h b/src/helpers/radiolib/CustomSX126x.h new file mode 100644 index 00000000000..1831c588767 --- /dev/null +++ b/src/helpers/radiolib/CustomSX126x.h @@ -0,0 +1,143 @@ +#pragma once + +#include +#include "MeshCore.h" +#include "RxActivityTracker.h" + +// The chip class shared by CustomSX1262, CustomSX1268 and CustomLLCC68: Base is +// the RadioLib chip class, Traits says which of the SX1262 only parts of +// std_init() a chip gets (see CustomSX1262.h for the members and +// docs/refactoring/0009-radio-chip-adapter.md, step 9b, for why they differ). +template +class CustomSX126x : public Base { + RxActivityTracker _rxActivity; + + public: + CustomSX126x(Module *mod) : Base(mod) { } + + #ifdef RP2040_PLATFORM + bool std_init(SPIClassRP2040* spi = NULL) + #else + bool std_init(SPIClass* spi = NULL) + #endif + { + #ifdef SX126X_DIO3_TCXO_VOLTAGE + float tcxo = SX126X_DIO3_TCXO_VOLTAGE; + #else + float tcxo = 1.6f; + #endif + + #ifdef LORA_CR + uint8_t cr = LORA_CR; + #else + uint8_t cr = 5; + #endif + + // without the option, begin() gets false, RadioLib's default for it + #ifdef SX126X_USE_REGULATOR_LDO + constexpr bool useRegulatorLDO = Traits::regulatorOption && SX126X_USE_REGULATOR_LDO; + #else + constexpr bool useRegulatorLDO = false; + #endif + + if (Traits::initDebug) { + MESH_DEBUG_PRINTLN("%s regulator requested: %s", Traits::name(), useRegulatorLDO ? "LDO" : "DC-DC"); + } + + #if defined(P_LORA_SCLK) + #ifdef NRF52_PLATFORM + if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } + #elif defined(RP2040_PLATFORM) + if (spi) { + spi->setMISO(P_LORA_MISO); + //spi->setCS(P_LORA_NSS); // Setting CS results in freeze + spi->setSCK(P_LORA_SCLK); + spi->setMOSI(P_LORA_MOSI); + spi->begin(); + } + #else + if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); + #endif + #endif + int status = this->begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); + // if radio init fails with -707/-706, try again with tcxo voltage set to 0.0f + if (status == RADIOLIB_ERR_SPI_CMD_FAILED || status == RADIOLIB_ERR_SPI_CMD_INVALID) { + if (Traits::initDebug) { + MESH_DEBUG_PRINTLN("%s init failed with error %d, retrying with TCXO at 0.0V", Traits::name(), status); + } + tcxo = 0.0f; + status = this->begin(LORA_FREQ, LORA_BW, LORA_SF, cr, RADIOLIB_SX126X_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, tcxo, useRegulatorLDO); + } + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: radio init failed: "); + Serial.println(status); + return false; // fail + } + + this->setCRC(1); + + #ifdef SX126X_CURRENT_LIMIT + this->setCurrentLimit(SX126X_CURRENT_LIMIT); + #endif + #ifdef SX126X_DIO2_AS_RF_SWITCH + this->setDio2AsRfSwitch(SX126X_DIO2_AS_RF_SWITCH); + #endif + #ifdef SX126X_RX_BOOSTED_GAIN + this->setRxBoostedGainMode(SX126X_RX_BOOSTED_GAIN); + #endif + #if defined(SX126X_RXEN) || defined(SX126X_TXEN) + #ifndef SX126X_RXEN + #define SX126X_RXEN RADIOLIB_NC + #endif + #ifndef SX126X_TXEN + #define SX126X_TXEN RADIOLIB_NC + #endif + this->setRfSwitchPins(SX126X_RXEN, SX126X_TXEN); + #endif + + // for improved RX with Heltec v4 + #ifdef SX126X_REGISTER_PATCH + if (Traits::registerPatch) { + uint8_t r_data = 0; + this->readRegister(0x8B5, &r_data, 1); + r_data |= 0x01; + this->writeRegister(0x8B5, &r_data, 1); + } + #endif + + if (Traits::initDebug) { + MESH_DEBUG_PRINTLN("%s status=0x%02X device_errors=0x%04X", Traits::name(), this->getStatus(), this->getDeviceErrors()); + } + + return true; // success + } + + int16_t startReceive() override { + // include the PREAMBLE_DETECTED irq bit in reported flags + return Base::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + + bool isReceiving() { + uint32_t irq = this->getIrqFlags(); + RxActivityTracker::Result r = _rxActivity.update( + irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED, + irq & RADIOLIB_SX126X_IRQ_HEADER_VALID, + irq & RADIOLIB_SX126X_IRQ_HEADER_ERR, + millis()); + if (r == RxActivityTracker::CLEAR_PREAMBLE) { + this->clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); + } else if (r == RxActivityTracker::CLEAR_ALL) { + this->clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID | RADIOLIB_SX126X_IRQ_HEADER_ERR | RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID); + } + return r == RxActivityTracker::RECEIVING; + } + + void setPreambleMillis(uint32_t preambleMillis) { _rxActivity.setPreambleMillis(preambleMillis); } + void setMaxPayloadMillis(uint32_t payloadMillis) { _rxActivity.setMaxPayloadMillis(payloadMillis); } + + bool getRxBoostedGainMode() { + uint8_t rxGain = 0; + this->readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); + return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); + } +}; From 43ca021c80225de8922301bae9bdcca23d0fd444 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 08:09:13 +0200 Subject: [PATCH 119/136] docs(refactoring): design step 10c, a bounded frame reader for the companion handlers Measured inventory of the cmd_frame accesses in the 59 handlers (static script and an AddressSanitizer probe of the host harness), the reader API, why the answers stay byte for byte the same, one pull request in four commits, rejected alternatives, and the upstream overlap on dec8ad6e (including the ByteReader of meshcore-dev/MeshCore#2864). --- .../0010-companion-command-table.md | 217 +++++++++++++++++- tools/refactoring/README.md | 23 ++ tools/refactoring/asan_link.py | 4 + tools/refactoring/cmd_frame_offsets.py | 113 +++++++++ tools/refactoring/companion_asan_probe.patch | 88 +++++++ .../companion_asan_probe_summary.py | 24 ++ 6 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 tools/refactoring/asan_link.py create mode 100644 tools/refactoring/cmd_frame_offsets.py create mode 100644 tools/refactoring/companion_asan_probe.patch create mode 100644 tools/refactoring/companion_asan_probe_summary.py diff --git a/docs/refactoring/0010-companion-command-table.md b/docs/refactoring/0010-companion-command-table.md index 60c6a5137a0..4783fb32a70 100644 --- a/docs/refactoring/0010-companion-command-table.md +++ b/docs/refactoring/0010-companion-command-table.md @@ -1,7 +1,8 @@ # 0010: A command table for the companion protocol Status: steps 10a (host harness and characterization) and 10b (the table) ready -for review; 10c and 10d are proposals. Behaviour change: none. +for review; 10c (a bounded frame reader) designed below; 10d is a proposal. +Behaviour change: none. ## Context @@ -249,6 +250,220 @@ this fork, already in `integration`. Step 10a touches none of those lines: it adds tests and a native environment. +## Step 10c: a bounded frame reader + +### What the handlers read today + +Measured on `integration` at `68e0fcdc` in two ways. + +`tools/refactoring/cmd_frame_offsets.py` lists every access to `cmd_frame` in the +59 handlers of `CMD_TABLE`. 48 handlers touch the frame, 11 read nothing past the +code byte. Between them they use 68 constant offsets (`cmd_frame[1]`, +`&cmd_frame[1 + PUB_KEY_SIZE]`), 30 accesses through a running index `i`, 7 +lengths derived from `len` that go to a callee together with a pointer into the +frame, and 5 writes into the frame: `cmd_frame[len] = 0` in `APP_START`, +`RUN_CLI_COMMAND`, `SEND_TXT_MSG`, `SEND_LOGIN` and `SET_CUSTOM_VAR` (the in place +terminator; `cmd_frame` is `MAX_FRAME_SIZE + 1` bytes, so index `len` is always +inside it) and the `':'` that `SET_CUSTOM_VAR` replaces. Every constant offset that +is read without a further length test lies below the row's minimum; the reads +above it (`alt` of `SET_ADVERT_LATLON`, the tail of `SET_OTHER_PARAMS`, the repeat +flag, `since`, the 256 bit scope key) each sit behind their own `len` test. + +The static list cannot see what a callee does with a pointer, and a running index +can be driven by a byte of the frame. So the second measurement is dynamic: the +host harness built with AddressSanitizer (`tools/refactoring/companion_asan_probe.patch`), +the bytes of `cmd_frame` above the frame poisoned before each frame is handled, +and every code 0 to 69 sent at every length 1 to 176 with ten fills (`00 01 02 3f +40 7f 81 bf ff 5a`, which include the path length bytes of every hash size), each +to a freshly booted node. 123,200 frames. Reads above `len`: + +| Handler | Where | Lengths | What it reads | Answer, either way | +|---|---|---|---|---| +| `SEND_CHANNEL_DATA` | `Packet::writePath()` | 4 to 64 | the path the length byte announces, before the test whether path and `data_type` fit | `ILLEGAL_ARG` | +| `SEND_RAW_DATA` | `Packet::writePath()` | 6 to 63 | the same, before the test for 4 payload bytes | `ILLEGAL_ARG` | +| `SEND_RAW_PACKET` | `Dispatcher::tryParsePacket()` | 4 to 6 | the 4 transport code bytes and the path length byte, before its length test | `ILLEGAL_ARG` | +| `RUN_CLI_COMMAND` | `CommonRadioPrefs::handleCommand()`, `MyMesh::handleCommand()` | 3 to 22 | `memcmp()` of a command prefix longer than the text, past the terminator | the CLI reply | +| `SET_DEFAULT_FLOOD_SCOPE` | `strlen()` in the handler | 48 to 175 | the byte at `len`, and on a board onwards while no terminator comes | `ILLEGAL_ARG` | + +The last row only shows up with the byte at `len` poisoned too (`PROBE_SKIP=0`): in +the probe that byte is always 0 (every node is fresh), on a board it is left over +from the previous frame, and `strlen()` runs on to the next zero. Reads of the byte +at `len` by the five handlers that just wrote the terminator there are not listed; +with the poison starting at `len + 1` (`PROBE_SKIP=1`) the first four rows remain +and nothing else. + +None of the five changes an answer. For the first three, a path or header that +runs past the frame always fails the length test that follows, because the cursor +is then already beyond `len`; the stale bytes go into a local buffer that is +discarded. For `SET_DEFAULT_FLOOD_SCOPE`, `strlen()` only runs past the frame when +bytes 1 to `len - 1` (at least 47) hold no zero, and then it returns at least 47, +which is refused as at least 31. For the CLI, `memcmp()` stops being decisive at +the terminator, which differs from the prefix. The first two and the fifth are in +handlers and go away with the reader without changing an answer; the CLI and +`tryParsePacket()` are callees that 10c does not touch (the latter also parses +every received radio packet), recorded here as findings for their own fixes. + +A side observation of the ASan build, not checked further: built with ASan, the +frame sweep's packet digests differ for codes 2, 3, 26, 27 and 39 (encrypted +requests and messages) while every reply stays the same. Unproven guess: the +encryption pads its last block from an uninitialised stack buffer, which the +sweep's stack scrub reaches in a normal build and does not reach under ASan's +stack layout. Not part of 10c. + +### The reader + +A header only class in `src/helpers/FrameReader.h`: + +```c++ +class FrameReader { +public: + FrameReader(uint8_t* buf, size_t len, size_t pos = 0); + uint8_t u8(); // 0 when it runs out + uint16_t u16(); // memcpy, as the handlers do today + uint32_t u32(); + int32_t i32(); + uint8_t* bytes(size_t n); // n bytes at the cursor, nullptr when they are not all there + void skip(size_t n); + bool has(size_t n) const; // n more bytes are in the frame + size_t remaining() const; + size_t pos() const; + bool ok() const; // false once a read ran out; stays false + char* restAsCString(); // the rest as a C string: writes 0 at buf[len], as today +}; +``` + +Each handler starts with `FrameReader r(cmd_frame, len, 1);` and reads its fields in +order instead of counting offsets: + +```c++ +bool MyMesh::cmdSetTuningParams(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint32_t rx = r.u32(); + uint32_t af = r.u32(); + ... +``` + +Optional trailing fields keep their own test, written as `r.has(n)` (or +`r.remaining()`, where the old test compared `len`). Reads of several bytes use +`memcpy` into the value, exactly as the handlers do today, so the byte order is +the same on every target by construction rather than by a little endian +assumption. + +`restAsCString()` writes the terminator at `buf[len]`, the same byte the five +handlers write today; its precondition (the buffer has room for `len + 1` bytes) +is what `cmd_frame` already guarantees. `SET_CUSTOM_VAR` keeps editing its text in +place after that. + +### Why the answers stay byte for byte the same + +- **The minimum stays in the table.** A frame below a row's minimum never reaches + the handler and gets the dispatcher's `UNSUPPORTED` as before. The reader does + not add a second check that could answer differently. +- **Inside the minimum, nothing runs out.** Every unconditional read is below the + row's minimum (measured above), so for every frame a handler sees, the reader + returns the same bytes the offsets did. A read that would run out yields 0 and + clears `ok()`; in 10c that can only happen in the places listed next, each of + which already tests and refuses. +- **The handlers that check their own length keep the exact test.** `len < 4` in + `SEND_CHANNEL_DATA`, the 4 payload bytes of `SEND_RAW_DATA`, `len == 4` and + `len >= 36` of `SEND_TELEMETRY_REQ`, the two lengths of `SET_CHANNEL`, `len < 33` + of `EXPORT_CONTACT`, `len >= 48` of `SET_DEFAULT_FLOOD_SCOPE`, `len >= 18` of + `SET_FLOOD_SCOPE_KEY` and the upper bound of `SEND_TRACE_PATH` stay the same + comparisons, on `len` or on `r.remaining()` with the same value. Where a test + follows a path copy today (`SEND_CHANNEL_DATA`, `SEND_RAW_DATA`), the copy becomes + `r.bytes(n)`, and "did not fit" (`!r.ok()`) joins the existing test with `||`; + both lead to the `ILLEGAL_ARG` the over-read case answers today. +- **The unbounded `strlen()`** of `SET_DEFAULT_FLOOD_SCOPE` becomes a length + bounded to the frame (`strnlen(p, r.remaining())`); argued above, the answer is + the same for every frame. +- **Callees are not changed.** `updateContactFromFrame()`, `importContact()`, + `tryParsePacket()`, `createControlData()`, `sendAnonReq()` and the CLI parser get + the same pointer and length as today (`r.bytes()`/`r.remaining()` instead of + `&cmd_frame[n]`/`len - n`). + +The reader has a hook for the host, `FRAME_READER_OVERRUN()`, empty on the +targets. The harness counts it, so a test can assert that no frame of the sweep +makes a handler run out, apart from the two path cases where running out is the +refusal. + +### Tests and evidence planned + +- The sweep and the 49 command tests of 10a, unchanged, before and after. +- New tests first, on the unchanged handlers: for every row, the frame one byte + below the minimum (`UNSUPPORTED`) and exactly at it (accepted); every optional + field one byte short and complete (`SET_ADVERT_LATLON` 12/13, `SET_RADIO_PARAMS` + 11/12, `SET_OTHER_PARAMS` 2 to 5, `SET_AUTOADD_CONFIG` 2/3, `GET_CONTACTS` 4/5, + `SET_FLOOD_SCOPE_KEY` 17/18, `SET_CHANNEL` 49/50/65/66, `SEND_TELEMETRY_REQ` + 4/5/35/36, `EXPORT_CONTACT` 32/33, `SET_DEFAULT_FLOOD_SCOPE` 47/48, + `SEND_TRACE_PATH` at its upper bound); and each measured over-read with a + stale tail that would change the outcome if it were used (a path length byte + announcing more than the frame holds, behind a previous frame whose bytes form a + valid path and payload; a 47 byte scope name without terminator behind a tail + with a zero after 3 more bytes). +- Unit tests of the reader itself (each read at the end of the frame, past it, + `ok()` staying false, `restAsCString()` writing exactly `buf[len]`). +- Mutations of the converted handlers (a field read with the wrong width, an + optional field read without `has()`, `has(n)` off by one, a lost `!r.ok()`). +- Builds of every companion environment (BLE, USB, WiFi) per platform plus control + environments, flash and RAM against `68e0fcdc`; all 17 host environments; + `companion_session.py` on a board if one can be claimed, otherwise recorded as + not run. + +The cost to expect: the bounds test in every read. Target: at most 1 kB of flash on +nRF52 for the whole step; if it is more, the reads inside the minimum get an +unchecked variant (the table has already checked them) and the checked reads stay +for the optional fields. + +### One pull request or several + +One. The reader is useless without users, and the handlers are independent of one +another, so the step is cut into commits instead: the reader with its unit tests; +the new boundary and stale tail tests on the unchanged handlers; the 34 handlers +that read their fields one after the other without a length test of their own; +the 14 with optional fields, sub commands, a path or their own length tests, +where a reviewer has to compare each test. Each commit passes all suites on its +own. Splitting into two pull requests along the last line would double the build +matrix and the review overhead without making either half easier to read. + +### Alternatives considered for 10c + +- **The dispatcher passes the reader** (`bool (MyMesh::*)(FrameReader&)`). Makes it + harder to bypass the reader, but changes 59 declarations, the table type and + every handler that only wants `len`, while a handler could still index + `cmd_frame`. Not worth a diff that touches the 11 handlers that read nothing. +- **Reading into packed structs** (`memcpy(&frame, cmd_frame, sizeof frame)`). + The fewest lines, but the layout then depends on the compiler's packing rules on + five platform packages, and variable length fields (paths, text) do not fit a + struct. +- **A macro per handler (`NEED(n)`)** that returns `ILLEGAL_ARG` when the frame is + short. That is step 10d, a protocol change; in 10c the table's minimum is the + only length answer. +- **Upstream's `ByteReader`.** meshcore-dev/MeshCore#2864 (OTA over LoRa, open, + 12,000+ lines) brings `mesh::ota::ByteReader` in `src/helpers/ota/OtaByteIO.h`: + sticky `ok`, `u8()`, `u32()`, `take(n)`, `skip(n)`, little endian by shifting. + Close to this design, which takes the same method names where they mean the same + (`u8`, `u32`, `skip`, `ok`). Not reused: it is not merged, lives in the OTA + namespace, has no in place terminator and decodes by shifting (the same bytes on + the five platforms, all little endian, but a different expression than the + handlers' `memcpy`). If both land, merging them is one rename. +- **Converting `updateContactFromFrame()` and `importContact()` too.** They parse + frames as well, but they are shared with the contact store and the advert path; + out of the scope of the handlers, a candidate for later. + +### Upstream overlap for 10c + +`upstream/dev` is `dec8ad6e` on 21 October 2026; since `428a95c6` it only adds WiFi +to two variants and changes nothing in `examples/companion_radio`. Of 499 open +pull requests, 74 change `examples/companion_radio/MyMesh.cpp`; 10 of them change +lines that touch `cmd_frame`, and so do the handlers 10c rewrites: #2670 and #2843 +(each a new optional byte 5 in `SET_OTHER_PARAMS`), #2682 (`SEND_RAW_DATA`, +`RUN_CLI_COMMAND`), #2717 and #1374 (new commands), #1923 (`SEND_TRACE_PATH`), +#1551 (the timestamp of `SEND_TXT_MSG` and `SEND_CHANNEL_TXT_MSG`), #3523 +(`ADD_UPDATE_CONTACT`), #3643 (this fork's channel text fix, already in +`integration`). Each would be re-applied as reader calls; the two optional byte 5 +proposals become one `if (r.has(1))`. None proposes a reader for the companion +frames; #2864 is the only reader-like change and is discussed above. + ## Verification of step 10a ### What the harness is diff --git a/tools/refactoring/README.md b/tools/refactoring/README.md index 43fc8d23c46..ac720fd1bf6 100644 --- a/tools/refactoring/README.md +++ b/tools/refactoring/README.md @@ -56,6 +56,29 @@ python3 companion_session.py > b.txt # second image diff a.txt b.txt ``` +## Frame reads past the end (step 10c) + +`cmd_frame_offsets.py` lists every access to `cmd_frame` in the handlers of the +companion command table, with the row's minimum, and marks constant offsets that +are read without a length test of their own. + +`companion_asan_probe.patch` measures the same at run time: it builds the +`native_companion` harness with AddressSanitizer, poisons the bytes of `cmd_frame` +above each frame and sends every code 0 to 69 at every length with ten fills. Apply +it, run, summarise, revert: + +```sh +git apply tools/refactoring/companion_asan_probe.patch +ASAN_OPTIONS=halt_on_error=0:detect_leaks=0:detect_container_overflow=0:suppress_equal_pcs=0:malloc_context_size=0 \ + PROBE_SKIP=1 pio test -e native_companion -v -a '--gtest_filter=AsanProbe.*' > probe.log 2>&1 +python3 tools/refactoring/companion_asan_probe_summary.py probe.log +git apply -R tools/refactoring/companion_asan_probe.patch +``` + +`PROBE_SKIP=1` poisons from `len + 1` (the terminator some handlers write at `len` +is allowed); `PROBE_SKIP=0` from `len`, which also lists those terminators. About 30 +seconds with `PROBE_SKIP=1`, 15 minutes with `0` (every terminator is a report). + ## Diagnostic build for step 2 `diag-tuning.patch` adds a `diag tuning` command to the room server and the diff --git a/tools/refactoring/asan_link.py b/tools/refactoring/asan_link.py new file mode 100644 index 00000000000..6f6a3bcd751 --- /dev/null +++ b/tools/refactoring/asan_link.py @@ -0,0 +1,4 @@ +# Links the native_companion test program against the AddressSanitizer runtime +# (companion_asan_probe.patch); build_flags only reach the compiler. +Import("env") +env.Append(LINKFLAGS=["-fsanitize=address"]) diff --git a/tools/refactoring/cmd_frame_offsets.py b/tools/refactoring/cmd_frame_offsets.py new file mode 100644 index 00000000000..4a7dc969d25 --- /dev/null +++ b/tools/refactoring/cmd_frame_offsets.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Lists every access to cmd_frame in the companion command handlers. + +For each row of MyMesh::CMD_TABLE (examples/companion_radio/MyMesh.cpp) it prints +the row's minimum, then every line of the handler that touches cmd_frame, sorted +into four kinds: + + fixed cmd_frame[N] or &cmd_frame[N] with a constant N (the hand counted offsets) + cursor cmd_frame[i] / &cmd_frame[i] with a running index + write an assignment into cmd_frame (the in place string terminators) + len a length derived from len that is handed on with a pointer into the frame + +and, for the fixed offsets, the highest byte the handler reads without a further +length test, against the row's minimum. A constant offset at or above the minimum +is only safe behind a len test, which the script marks as "guarded" when the line +or an enclosing if names len. + +Usage: tools/refactoring/cmd_frame_offsets.py [path/to/MyMesh.cpp] +""" + +import re +import sys + +PUB_KEY_SIZE = 32 +MAX_PATH_SIZE = 64 +CONSTS = {"PUB_KEY_SIZE": PUB_KEY_SIZE, "MAX_PATH_SIZE": MAX_PATH_SIZE} + +# widths of the reads a line does at a fixed offset, by the call around it +WIDTH_HINTS = [ + (re.compile(r"memcpy\(\s*&\w+\s*,\s*&cmd_frame\[([^\]]+)\]\s*,\s*(\d+)\s*\)"), None), + (re.compile(r"memcmp\(\s*&cmd_frame\[([^\]]+)\]\s*,\s*\"[^\"]*\"\s*,\s*(\d+)\s*\)"), None), +] + + +def ev(expr): + e = expr + for k, v in CONSTS.items(): + e = e.replace(k, str(v)) + if not re.fullmatch(r"[\d\s+\-*()]+", e): + return None + return eval(e) # digits and + - * only + + +def handlers(src): + table = re.search(r"const MyMesh::CmdHandler MyMesh::CMD_TABLE\[\] = \{(.*?)\n\};", src, re.S).group(1) + rows = re.findall(r"\{\s*(CMD_\w+),\s*([^,]+?),\s*&MyMesh::(\w+)\s*\}", table) + for code, minimum, fn in rows: + m = re.search(r"\nbool MyMesh::" + fn + r"\(size_t[^)]*\) \{\n(.*?)\n\}\n", src, re.S) + yield code, ev(minimum.strip()), fn, m.group(1).split("\n") + + +def classify(line): + kinds = [] + if re.search(r"cmd_frame\[[^\]]+\]\s*=[^=]", line) or re.search(r"\*np\+\+\s*=", line): + kinds.append("write") + for m in re.finditer(r"(&?)cmd_frame\[([^\]]+)\]", line): + off = ev(m.group(2)) + kinds.append(("fixed", off, m.group(1) == "&") if off is not None else ("cursor", m.group(2))) + if re.search(r"len\s*-\s*[\w(]", line) and "cmd_frame" in line or re.search(r"\blen - \(", line): + kinds.append("len") + return kinds + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "examples/companion_radio/MyMesh.cpp" + src = open(path).read() + total = {"fixed": 0, "cursor": 0, "write": 0, "len": 0} + nhandlers = 0 + for code, minimum, fn, body in handlers(src): + nhandlers += 1 + print(f"{code} (min {minimum}) {fn}") + guards = [] + top = 0 + for line in body: + s = line.strip() + if re.match(r"if \(.*\blen\b", s) or re.match(r"\} else if \(.*\blen\b", s): + guards.append(line) + kinds = classify(line) + if not kinds and "cmd_frame" not in line and not re.search(r"\blen\b", line): + continue + guarded = bool(re.search(r"\blen\b", line)) or any(len(g) - len(g.lstrip()) < len(line) - len(line.lstrip()) for g in guards) + tags = [] + for k in kinds: + if isinstance(k, tuple) and k[0] == "fixed": + total["fixed"] += 1 + width = 1 + mm = re.search(r"&cmd_frame\[[^\]]+\][^,]*,\s*(\d+)\)", line) + if k[2] and mm: + width = int(mm.group(1)) + elif k[2]: + width = 0 # a pointer handed on; its extent is the callee's + hi = k[1] + width + if not guarded and width: + top = max(top, hi) + tags.append(f"fixed[{k[1]}..{hi})" + (" guarded" if guarded else "")) + elif isinstance(k, tuple): + total["cursor"] += 1 + tags.append(f"cursor[{k[1]}]") + else: + total[k] += 1 + tags.append(k) + if tags or re.search(r"\blen\b", line): + print(f" {', '.join(tags) or 'len test':40s} {s}") + if top: + mark = "ok" if minimum is not None and top <= minimum else "ABOVE MIN" + print(f" -> unguarded fixed reads end at {top}, minimum {minimum}: {mark}") + print() + print(f"{nhandlers} handlers; fixed offsets {total['fixed']}, cursor {total['cursor']}, " + f"writes {total['write']}, len-derived lengths {total['len']}") + + +if __name__ == "__main__": + main() diff --git a/tools/refactoring/companion_asan_probe.patch b/tools/refactoring/companion_asan_probe.patch new file mode 100644 index 00000000000..44a5a80226d --- /dev/null +++ b/tools/refactoring/companion_asan_probe.patch @@ -0,0 +1,88 @@ +diff --git a/platformio.ini b/platformio.ini +index 5a57d9fa..d6c0867d 100644 +--- a/platformio.ini ++++ b/platformio.ini +@@ -340,6 +340,7 @@ lib_deps = + google/googletest @ 1.17.0 + + [env:native_companion] ++extra_scripts = post:tools/refactoring/asan_link.py + ; The companion on the host: the real MyMesh.cpp and DataStore.cpp against the mocks in + ; test/mocks, so the frame protocol (handleCmdFrame) can be characterized before it + ; is restructured (docs/refactoring/0010-companion-command-table.md). RP2040_PLATFORM +@@ -348,7 +349,7 @@ lib_deps = + ; sides of those two branches are reachable. + platform = native + test_framework = googletest +-build_flags = -std=c++17 ++build_flags = -std=c++17 -g -fsanitize=address -fsanitize-recover=address -fno-omit-frame-pointer + -D RP2040_PLATFORM + -D ENABLE_PRIVATE_KEY_EXPORT=1 + -D ENABLE_PRIVATE_KEY_IMPORT=1 +diff --git a/test/test_companion_frames/CompanionHarness.h b/test/test_companion_frames/CompanionHarness.h +index 07bb4fa7..4508500c 100644 +--- a/test/test_companion_frames/CompanionHarness.h ++++ b/test/test_companion_frames/CompanionHarness.h +@@ -1,4 +1,7 @@ + #pragma once ++#include ++#include ++inline size_t g_poison_skip = 1; + + // The companion on the host, driven through its frame protocol. A test hands a + // command frame to a fake serial interface, runs one MyMesh::loop() (which is where +@@ -45,6 +48,7 @@ public: + std::vector written; + bool enabled = false; + int disable_calls = 0; ++ uint8_t* asan_buf = nullptr; + + void enable() override { enabled = true; } + void disable() override { enabled = false; disable_calls++; } +@@ -62,6 +66,8 @@ public: + Bytes f = inbound.front(); + inbound.pop_front(); + memcpy(dest, f.data(), f.size()); ++ asan_buf = dest; ++ if (g_poison_skip <= MAX_FRAME_SIZE + 1 - f.size()) __asan_poison_memory_region(dest + f.size() + g_poison_skip, MAX_FRAME_SIZE + 1 - f.size() - g_poison_skip); + return f.size(); + } + }; +@@ -148,6 +154,7 @@ public: + serial.written.clear(); + serial.inbound.push_back(frame); + node->loop(); ++ if (serial.asan_buf) __asan_unpoison_memory_region(serial.asan_buf, MAX_FRAME_SIZE + 1); + return serial.written; + } + +diff --git a/test/test_companion_frames/test_asan_probe.cpp b/test/test_companion_frames/test_asan_probe.cpp +new file mode 100644 +index 00000000..d315f549 +--- /dev/null ++++ b/test/test_companion_frames/test_asan_probe.cpp +@@ -0,0 +1,24 @@ ++// ASan probe for reads past len: every code, every length, ten fills. Bytes above ++// the frame are poisoned from len + PROBE_SKIP (0: the byte at len too). ++#include "CompanionHarness.h" ++#include ++using namespace companion; ++TEST(AsanProbe, AllCodesAllLengths) { ++ const char* e = getenv("PROBE_SKIP"); ++ if (!e) GTEST_SKIP(); ++ g_poison_skip = atoi(e); ++ const uint8_t fills[] = {0x00, 0x01, 0x02, 0x3F, 0x40, 0x7F, 0x81, 0xBF, 0xFF, 0x5A}; ++ Harness h; ++ for (int code = 0; code < 70; code++) { ++ for (uint8_t fill : fills) { ++ for (size_t len = 1; len <= MAX_FRAME_SIZE; len++) { ++ h.reset(); ++ h.boot(); ++ Bytes f; putU8(f, (uint8_t)code); putFill(f, len - 1, fill); ++ fprintf(stderr, "PROBE code=%d fill=%02x len=%zu\n", code, fill, len); ++ h.send(f); ++ } ++ } ++ } ++ g_poison_skip = 1; ++} diff --git a/tools/refactoring/companion_asan_probe_summary.py b/tools/refactoring/companion_asan_probe_summary.py new file mode 100644 index 00000000000..50788e479a4 --- /dev/null +++ b/tools/refactoring/companion_asan_probe_summary.py @@ -0,0 +1,24 @@ +# Reads the log of the ASan probe (companion_asan_probe.patch) and prints one line per +# handler, first non-libc frame and access kind, with the codes, fills and lengths that +# hit it. +import sys,re,collections +c=collections.defaultdict(lambda:[0,set(),set(),set()]) +cur=None +lines=open(sys.argv[1],errors='replace').read().split('\n') +i=0 +while i Date: Wed, 21 Oct 2026 08:09:24 +0200 Subject: [PATCH 120/136] tools(refactoring): count the terminators written through a text pointer --- tools/refactoring/cmd_frame_offsets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/refactoring/cmd_frame_offsets.py b/tools/refactoring/cmd_frame_offsets.py index 4a7dc969d25..2dc638a0bb3 100644 --- a/tools/refactoring/cmd_frame_offsets.py +++ b/tools/refactoring/cmd_frame_offsets.py @@ -51,7 +51,7 @@ def handlers(src): def classify(line): kinds = [] - if re.search(r"cmd_frame\[[^\]]+\]\s*=[^=]", line) or re.search(r"\*np\+\+\s*=", line): + if re.search(r"cmd_frame\[[^\]]+\]\s*=[^=]", line) or re.search(r"\*np\+\+\s*=", line) or re.search(r"\btext\[tlen\]\s*=", line): kinds.append("write") for m in re.finditer(r"(&?)cmd_frame\[([^\]]+)\]", line): off = ev(m.group(2)) From ef78d5e47196c48bfbe81771321d6953b9db9f84 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 08:09:38 +0200 Subject: [PATCH 121/136] docs(refactoring): six writes into the frame, not five --- docs/refactoring/0010-companion-command-table.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/refactoring/0010-companion-command-table.md b/docs/refactoring/0010-companion-command-table.md index 4783fb32a70..4f7f1162a1e 100644 --- a/docs/refactoring/0010-companion-command-table.md +++ b/docs/refactoring/0010-companion-command-table.md @@ -261,7 +261,8 @@ Measured on `integration` at `68e0fcdc` in two ways. code byte. Between them they use 68 constant offsets (`cmd_frame[1]`, `&cmd_frame[1 + PUB_KEY_SIZE]`), 30 accesses through a running index `i`, 7 lengths derived from `len` that go to a callee together with a pointer into the -frame, and 5 writes into the frame: `cmd_frame[len] = 0` in `APP_START`, +frame, and 6 writes into the frame: the terminator at `len` (`cmd_frame[len] = 0` +or `text[tlen] = 0`) in `APP_START`, `RUN_CLI_COMMAND`, `SEND_TXT_MSG`, `SEND_LOGIN` and `SET_CUSTOM_VAR` (the in place terminator; `cmd_frame` is `MAX_FRAME_SIZE + 1` bytes, so index `len` is always inside it) and the `':'` that `SET_CUSTOM_VAR` replaces. Every constant offset that From 29e35f06842b384f233709c2a0319258460e4dde Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 08:21:03 +0200 Subject: [PATCH 122/136] test(companion): pin where each command's frame ends A catalog of 98 real frames (every command at its minimum, both sides of every optional field, the measured reads past the end) goes to a fresh node once as it is and once behind each of four tails a previous frame leaves in cmd_frame. Replies, queued packets, files, collaborator calls and what later loops and a flood show must be identical. A second test pins the answer one byte below each of the 44 minimums. Characterization for step 10c, on the unchanged handlers. --- .../test_companion_frames.cpp | 2 + .../test_frame_bounds.cpp | 318 ++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 test/test_companion_frames/test_frame_bounds.cpp diff --git a/test/test_companion_frames/test_companion_frames.cpp b/test/test_companion_frames/test_companion_frames.cpp index 7d9f231e378..11db6a9ad04 100644 --- a/test/test_companion_frames/test_companion_frames.cpp +++ b/test/test_companion_frames/test_companion_frames.cpp @@ -6,6 +6,8 @@ // - test_frame_sweep.cpp: every code at every length, against a recorded transcript // - test_commands.cpp: what each command does with a valid frame, and where its // minimum length is +// - test_frame_bounds.cpp: that no command reads the bytes a previous frame left +// above the end of the current one (step 10c) #include diff --git a/test/test_companion_frames/test_frame_bounds.cpp b/test/test_companion_frames/test_frame_bounds.cpp new file mode 100644 index 00000000000..3d67ae9109b --- /dev/null +++ b/test/test_companion_frames/test_frame_bounds.cpp @@ -0,0 +1,318 @@ +// Where each command's frame ends, with frames an app sends. +// +// The handlers read their fields at offsets into cmd_frame. On a board the bytes +// above a frame's length are left over from the previous, longer frame, so a +// handler that reads past `len` acts on them. The frame sweep shows that for +// filler frames behind one tail; these tests do it for a catalog of real frames: +// for each command at its minimum and one byte below it, at both sides of every +// optional field, and at the places where a handler, or a function it calls, is +// known to read past the frame (docs/refactoring/0010-companion-command-table.md, +// step 10c). Each frame goes to a freshly booted node once as it is and once +// behind each of four tails, and everything the node did must be the same: the +// replies byte for byte, the packets queued for the air, the files stored, the +// calls to the board, radio, clock and sensors. +// +// A second test pins the answer one byte below each minimum, the case step 10d +// would change and step 10c must not. + +#include "CompanionHarness.h" + +#include +#include + +using namespace companion; + +namespace { + +enum : uint8_t { + APP_START = 1, SEND_TXT_MSG = 2, SEND_CHANNEL_TXT_MSG = 3, GET_CONTACTS = 4, + GET_DEVICE_TIME = 5, SET_DEVICE_TIME = 6, SEND_SELF_ADVERT = 7, SET_ADVERT_NAME = 8, + ADD_UPDATE_CONTACT = 9, SET_RADIO_PARAMS = 11, SET_RADIO_TX_POWER = 12, + RESET_PATH = 13, SET_ADVERT_LATLON = 14, REMOVE_CONTACT = 15, SHARE_CONTACT = 16, + EXPORT_CONTACT = 17, IMPORT_CONTACT = 18, REBOOT = 19, SET_TUNING_PARAMS = 21, + DEVICE_QUERY = 22, IMPORT_PRIVATE_KEY = 24, SEND_RAW_DATA = 25, SEND_LOGIN = 26, + SEND_STATUS_REQ = 27, HAS_CONNECTION = 28, LOGOUT = 29, GET_CONTACT_BY_KEY = 30, + GET_CHANNEL = 31, SET_CHANNEL = 32, SIGN_START = 33, SIGN_DATA = 34, + SEND_TRACE_PATH = 36, SET_DEVICE_PIN = 37, SET_OTHER_PARAMS = 38, + SEND_TELEMETRY_REQ = 39, SET_CUSTOM_VAR = 41, GET_ADVERT_PATH = 42, + SEND_BINARY_REQ = 50, FACTORY_RESET = 51, SEND_PATH_DISCOVERY_REQ = 52, + SET_FLOOD_SCOPE_KEY = 54, SEND_CONTROL_DATA = 55, GET_STATS = 56, SEND_ANON_REQ = 57, + SET_AUTOADD_CONFIG = 58, SET_PATH_HASH_MODE = 61, SEND_CHANNEL_DATA = 62, + SET_DEFAULT_FLOOD_SCOPE = 63, SEND_RAW_PACKET = 65, RUN_CLI_COMMAND = 66, +}; + +constexpr uint8_t ERR_UNSUPPORTED_CMD = 1, ERR_ILLEGAL_ARG = 6; + +Bytes peerKey() { + Bytes k; + for (int i = 0; i < PUB_KEY_SIZE; i++) k.push_back((uint8_t)(0x31 + i)); + return k; +} + +Bytes contactFrame(size_t optional_bytes) { + Bytes f = {ADD_UPDATE_CONTACT}; + Bytes key = peerKey(); + put(f, key.data(), key.size()); + putU8(f, 1); // ADV_TYPE_CHAT + putU8(f, 0); // flags + putU8(f, 0xFF); // no path known + putFill(f, MAX_PATH_SIZE, 0); + putField(f, "peer", 32); + putU32(f, RTC0 - 100); // last_advert_timestamp + Bytes opt; + putI32(opt, 52500000); + putI32(opt, 13400000); + putU32(opt, RTC0 - 50); + put(f, opt.data(), optional_bytes); + return f; +} + +// One entry: a name for the failure message, and the frame. +struct Case { + std::string name; + Bytes frame; +}; + +Bytes cat(std::initializer_list parts) { + Bytes f; + for (const Bytes& p : parts) put(f, p.data(), p.size()); + return f; +} +Bytes u32le(uint32_t v) { Bytes b; putU32(b, v); return b; } +Bytes fill(size_t n, uint8_t v) { return Bytes(n, v); } +Bytes str(const char* s) { return Bytes(s, s + strlen(s)); } +Bytes key() { return peerKey(); } +Bytes prefixN(size_t n) { Bytes k = peerKey(); return Bytes(k.begin(), k.begin() + n); } +Bytes prefix6() { return prefixN(6); } +Bytes fieldOf(const char* s, size_t width) { Bytes f; putField(f, s, width); return f; } + +std::vector catalog() { + std::vector c; + auto add = [&](const char* name, Bytes f) { c.push_back({name, f}); }; + + add("DEVICE_QUERY min", {DEVICE_QUERY, 3}); + add("APP_START min", cat({{APP_START, 3}, fill(6, 0)})); + add("APP_START name", cat({{APP_START, 3}, fill(6, 0), str("app")})); + add("RUN_CLI_COMMAND min", cat({{RUN_CLI_COMMAND}, str("ve")})); + add("RUN_CLI_COMMAND ver", cat({{RUN_CLI_COMMAND}, str("ver")})); + add("RUN_CLI_COMMAND get radio", cat({{RUN_CLI_COMMAND}, str("get radio")})); + add("SEND_TXT_MSG min", cat({{SEND_TXT_MSG, 0, 0}, u32le(RTC0), prefix6(), str("h")})); + add("SEND_TXT_MSG text", cat({{SEND_TXT_MSG, 0, 0}, u32le(RTC0), prefix6(), str("hello")})); + add("SEND_TXT_MSG cli", cat({{SEND_TXT_MSG, 1, 0}, u32le(RTC0), prefix6(), str("ver")})); + add("SEND_CHANNEL_TXT_MSG min", cat({{SEND_CHANNEL_TXT_MSG, 0, 0}, u32le(RTC0)})); + add("SEND_CHANNEL_TXT_MSG text", cat({{SEND_CHANNEL_TXT_MSG, 0, 0}, u32le(RTC0), str("hi")})); + add("SEND_CHANNEL_DATA min", {SEND_CHANNEL_DATA, 0, 0xFF, 0x34}); + add("SEND_CHANNEL_DATA flood", cat({{SEND_CHANNEL_DATA, 0, 0xFF, 0x34, 0x12}, str("ab")})); + add("SEND_CHANNEL_DATA path", cat({{SEND_CHANNEL_DATA, 0, 2, 7, 8, 0x34, 0x12}, str("ab")})); + add("SEND_CHANNEL_DATA no data_type", {SEND_CHANNEL_DATA, 0, 2, 7, 8, 0x34}); + // the path length byte announces 8 bytes, the frame holds 2 + add("SEND_CHANNEL_DATA path past end", {SEND_CHANNEL_DATA, 0, 8, 7, 8}); + add("SEND_CHANNEL_DATA 3 byte hashes past end", {SEND_CHANNEL_DATA, 0, 0x85, 7, 8, 9}); + add("GET_CONTACTS min", {GET_CONTACTS}); + add("GET_CONTACTS since short", {GET_CONTACTS, 1, 2, 3}); + add("GET_CONTACTS since", cat({{GET_CONTACTS}, u32le(RTC0 - 200)})); + add("SET_ADVERT_NAME min", {SET_ADVERT_NAME, 'a'}); + add("SET_ADVERT_NAME", cat({{SET_ADVERT_NAME}, str("node")})); + add("SET_ADVERT_LATLON min", cat({{SET_ADVERT_LATLON}, u32le(52500000), u32le(13400000)})); + add("SET_ADVERT_LATLON alt short", cat({{SET_ADVERT_LATLON}, u32le(52500000), u32le(13400000), fill(3, 1)})); + add("SET_ADVERT_LATLON alt", cat({{SET_ADVERT_LATLON}, u32le(52500000), u32le(13400000), u32le(30)})); + add("SET_DEVICE_TIME min", cat({{SET_DEVICE_TIME}, u32le(RTC0 + 10)})); + add("SEND_SELF_ADVERT min", {SEND_SELF_ADVERT}); + add("SEND_SELF_ADVERT flood", {SEND_SELF_ADVERT, 1}); + add("RESET_PATH min", cat({{RESET_PATH}, key()})); + add("ADD_UPDATE_CONTACT min", contactFrame(0)); + add("ADD_UPDATE_CONTACT latlon", contactFrame(8)); + add("ADD_UPDATE_CONTACT lastmod", contactFrame(12)); + add("REMOVE_CONTACT min", cat({{REMOVE_CONTACT}, key()})); + add("SHARE_CONTACT min", cat({{SHARE_CONTACT}, key()})); + add("GET_CONTACT_BY_KEY min", cat({{GET_CONTACT_BY_KEY}, key()})); + add("EXPORT_CONTACT self", {EXPORT_CONTACT}); + add("EXPORT_CONTACT self, 32", cat({{EXPORT_CONTACT}, fill(31, 0x31)})); + add("EXPORT_CONTACT key", cat({{EXPORT_CONTACT}, key()})); + add("IMPORT_CONTACT min", cat({{IMPORT_CONTACT}, fill(98, 0x11)})); + add("SET_RADIO_PARAMS min", cat({{SET_RADIO_PARAMS}, u32le(869525), u32le(250000), {11, 5}})); + add("SET_RADIO_PARAMS repeat", cat({{SET_RADIO_PARAMS}, u32le(869525), u32le(250000), {11, 5, 1}})); + add("SET_RADIO_TX_POWER min", {SET_RADIO_TX_POWER, 20}); + add("SET_TUNING_PARAMS min", cat({{SET_TUNING_PARAMS}, u32le(1500), u32le(2000)})); + add("SET_OTHER_PARAMS min", {SET_OTHER_PARAMS, 1}); + add("SET_OTHER_PARAMS 3", {SET_OTHER_PARAMS, 1, 0x15}); + add("SET_OTHER_PARAMS 4", {SET_OTHER_PARAMS, 1, 0x15, 2}); + add("SET_OTHER_PARAMS 5", {SET_OTHER_PARAMS, 1, 0x15, 2, 1}); + add("SET_PATH_HASH_MODE min", {SET_PATH_HASH_MODE, 0, 1}); + add("REBOOT min", cat({{REBOOT}, str("reboot")})); + add("IMPORT_PRIVATE_KEY min", cat({{IMPORT_PRIVATE_KEY}, fill(64, 0)})); + add("SEND_RAW_DATA min", {SEND_RAW_DATA, 0, 1, 2, 3, 4}); + add("SEND_RAW_DATA path", {SEND_RAW_DATA, 1, 0x44, 1, 2, 3, 4}); + add("SEND_RAW_DATA payload short", {SEND_RAW_DATA, 1, 0x44, 1, 2, 3}); + // the path length byte announces 8 bytes, the frame holds 4 + add("SEND_RAW_DATA path past end", {SEND_RAW_DATA, 8, 1, 2, 3, 4}); + add("SEND_RAW_DATA 2 byte hashes past end", {SEND_RAW_DATA, 0x43, 1, 2, 3, 4, 5}); + add("SEND_LOGIN min", cat({{SEND_LOGIN}, key()})); + add("SEND_LOGIN password", cat({{SEND_LOGIN}, key(), str("pw")})); + add("SEND_ANON_REQ min", cat({{SEND_ANON_REQ}, key(), {7}})); + add("SEND_STATUS_REQ min", cat({{SEND_STATUS_REQ}, key()})); + add("SEND_PATH_DISCOVERY_REQ min", cat({{SEND_PATH_DISCOVERY_REQ, 0}, key()})); + add("SEND_TELEMETRY_REQ self", {SEND_TELEMETRY_REQ, 0, 0, 0}); + add("SEND_TELEMETRY_REQ 5", {SEND_TELEMETRY_REQ, 0, 0, 0, 0x31}); + add("SEND_TELEMETRY_REQ 35", cat({{SEND_TELEMETRY_REQ, 0, 0, 0}, prefixN(31)})); + add("SEND_TELEMETRY_REQ peer", cat({{SEND_TELEMETRY_REQ, 0, 0, 0}, key()})); + add("SEND_BINARY_REQ min", cat({{SEND_BINARY_REQ}, key(), {7}})); + add("HAS_CONNECTION min", cat({{HAS_CONNECTION}, key()})); + add("LOGOUT min", cat({{LOGOUT}, key()})); + add("GET_CHANNEL min", {GET_CHANNEL, 0}); + add("SET_CHANNEL min", cat({{SET_CHANNEL, 1}, fieldOf("#test", 32), fill(16, 0x21)})); + add("SET_CHANNEL 65", cat({{SET_CHANNEL, 1}, fieldOf("#test", 32), fill(31, 0x21)})); + add("SET_CHANNEL 256 bit", cat({{SET_CHANNEL, 1}, fieldOf("#test", 32), fill(32, 0x21)})); + add("SIGN_START then DATA", {SIGN_DATA, 1}); + add("SEND_TRACE_PATH min", cat({{SEND_TRACE_PATH}, u32le(7), u32le(9), {0, 0x44}})); + add("SEND_TRACE_PATH longest", cat({{SEND_TRACE_PATH}, u32le(7), u32le(9), {0}, fill(MAX_PATH_SIZE, 0x44)})); + // the longest frame; the handler's own upper bound (len - 10 >= MAX_PACKET_PAYLOAD - 5) + // lies above MAX_FRAME_SIZE and is never reached + add("SEND_TRACE_PATH longest frame", cat({{SEND_TRACE_PATH}, u32le(7), u32le(9), {0}, fill(MAX_FRAME_SIZE - 10, 0x44)})); + add("SET_DEVICE_PIN min", cat({{SET_DEVICE_PIN}, u32le(123456)})); + add("SET_CUSTOM_VAR min", cat({{SET_CUSTOM_VAR}, str("a:1")})); + add("SET_CUSTOM_VAR gps", cat({{SET_CUSTOM_VAR}, str("gps:1")})); + add("SET_CUSTOM_VAR no separator", cat({{SET_CUSTOM_VAR}, str("abc")})); + add("GET_ADVERT_PATH min", cat({{GET_ADVERT_PATH, 0}, key()})); + add("GET_STATS core", {GET_STATS, 0}); + add("GET_STATS radio", {GET_STATS, 1}); + add("GET_STATS packets", {GET_STATS, 2}); + add("GET_STATS other", {GET_STATS, 3}); + add("FACTORY_RESET min", cat({{FACTORY_RESET}, str("reset")})); + add("SET_FLOOD_SCOPE_KEY min", {SET_FLOOD_SCOPE_KEY, 0}); + // a key read one byte early would not show here: the mock HMAC behind the + // transport code folds only two bytes of its state into the code + add("SET_FLOOD_SCOPE_KEY 17", cat({{SET_FLOOD_SCOPE_KEY, 0}, fill(15, 0x42)})); + add("SET_FLOOD_SCOPE_KEY key", cat({{SET_FLOOD_SCOPE_KEY, 0}, fill(16, 0x42)})); + add("SET_FLOOD_SCOPE_KEY unscoped", {SET_FLOOD_SCOPE_KEY, 1}); + add("SEND_CONTROL_DATA min", {SEND_CONTROL_DATA, 0x80}); + add("SET_AUTOADD_CONFIG min", {SET_AUTOADD_CONFIG, 1}); + add("SET_AUTOADD_CONFIG hops", {SET_AUTOADD_CONFIG, 1, 3}); + add("SET_DEFAULT_FLOOD_SCOPE min", {SET_DEFAULT_FLOOD_SCOPE}); + add("SET_DEFAULT_FLOOD_SCOPE 47", cat({{SET_DEFAULT_FLOOD_SCOPE}, fieldOf("scope", 31), fill(15, 0x24)})); + add("SET_DEFAULT_FLOOD_SCOPE", cat({{SET_DEFAULT_FLOOD_SCOPE}, fieldOf("scope", 31), fill(16, 0x24)})); + // no zero anywhere in the frame: the name runs into the key and past the end + add("SET_DEFAULT_FLOOD_SCOPE unterminated", cat({{SET_DEFAULT_FLOOD_SCOPE}, fill(47, 0x61)})); + add("SEND_RAW_PACKET flood", {SEND_RAW_PACKET, 0, 0x11, 0, 0x55}); + // route type 0 carries 4 transport code bytes, which tryParsePacket() reads + // before it tests the length + add("SEND_RAW_PACKET transport past end", {SEND_RAW_PACKET, 0, 0x00, 0}); + return c; +} + +// What one frame did, as text: replies, packets, files, and the counters of the +// collaborators, then what a few later steps show. /channels2 is left out, because CMD_SET_CHANNEL writes stack +// bytes behind the channel name there (see test_frame_sweep.cpp); the channel's +// content is read back through GET_CHANNEL below instead. +std::string outcome(Harness& h, const Bytes& f) { + g_events.clear(); + int reboots = board.reboot_calls; + int params = radio_driver.set_params_calls, power = radio_driver.tx_power_calls; + uint32_t rtc = rtc_clock.now; + std::ostringstream o; + for (const Bytes& r : h.send(f)) o << "reply " << hex(r) << "\n"; + for (const Bytes& p : h.mesh().outbound()) o << "packet " << hex(p) << "\n"; + for (auto& file : h.fs.files) + if (file.first != "/channels2") o << "file " << file.first << " " << hex(file.second) << "\n"; + // State that only shows later: a contact listing still running (it writes one + // contact per loop), the channel a setter stored, and the scope the next flood + // goes out with. + for (int i = 0; i < 12; i++) { + h.serial.written.clear(); + h.mesh().loop(); + for (const Bytes& r : h.serial.written) o << "later " << hex(r) << "\n"; + } + for (const Bytes& r : h.send({GET_CHANNEL, 1})) o << "channel " << hex(r) << "\n"; + Bytes chat = {SEND_CHANNEL_TXT_MSG, 0, 0}; + putU32(chat, RTC0); + putStr(chat, "x"); + for (const Bytes& r : h.send(chat)) o << "flood " << hex(r) << "\n"; + for (const Bytes& p : h.mesh().outbound()) o << "queued " << hex(p) << "\n"; + for (auto& e : g_events) o << "event " << e << "\n"; + o << "reboot " << board.reboot_calls - reboots << " params " << radio_driver.set_params_calls - params + << " power " << radio_driver.tx_power_calls - power << " rtc " << rtc_clock.now - rtc + << " disable " << h.serial.disable_calls << "\n"; + return o.str(); +} + +// A node with the peer as a contact and a signing round started, which is what +// the catalog's frames expect to find. +void prepare(Harness& h) { + h.reset(); + h.boot(); + h.send(contactFrame(12)); + h.send({SIGN_START}); +} + +// The previous frame: a CMD_GET_DEVICE_TIME (which reads nothing past its code) of +// full length, so every byte above the next frame's length is one of these. +Bytes staleFrame(const Bytes& tail) { + Bytes f = {GET_DEVICE_TIME}; + for (size_t i = 1; i < MAX_FRAME_SIZE; i++) f.push_back(tail[i % tail.size()]); + return f; +} + +// Tails that would change an outcome if a handler used them: zeros, a byte that +// is no terminator and no valid path length, all ones, and a pattern with a zero +// at every eighth byte, which ends an unterminated string at a different place. +const std::vector TAILS = { + fill(1, 0x00), fill(1, 0x5A), fill(1, 0xFF), + {0x00, 0x41, 0x42, 0x02, 0x01, 0x30, 0x3A, 0x31}, +}; + +} // namespace + +TEST(FrameBounds, NoCommandReadsThePreviousFrame) { + Harness h; + for (const Case& c : catalog()) { + prepare(h); + std::string fresh = outcome(h, c.frame); + for (const Bytes& tail : TAILS) { + prepare(h); + h.send(staleFrame(tail)); + EXPECT_EQ(outcome(h, c.frame), fresh) << c.name << ", tail " << hex(tail); + } + } +} + +// One byte below each command's minimum the answer is UNSUPPORTED_CMD, as for a +// code that is not a command (SEND_CHANNEL_DATA, without a minimum in the table, +// answers ILLEGAL_ARG itself); the minimum itself is accepted, which the catalog +// above shows with a real frame of exactly that length for each of these codes. +TEST(FrameBounds, OneByteBelowEachMinimum) { + const struct { uint8_t code; size_t min; } MINIMUMS[] = { + {APP_START, 8}, {SEND_TXT_MSG, 14}, {SEND_CHANNEL_TXT_MSG, 7}, {SET_DEVICE_TIME, 5}, + {SET_ADVERT_NAME, 2}, {ADD_UPDATE_CONTACT, 136}, {SET_RADIO_PARAMS, 11}, + {SET_RADIO_TX_POWER, 2}, {RESET_PATH, 33}, {SET_ADVERT_LATLON, 9}, + {REMOVE_CONTACT, 33}, {SHARE_CONTACT, 33}, {IMPORT_CONTACT, 99}, {REBOOT, 7}, + {SET_TUNING_PARAMS, 9}, {DEVICE_QUERY, 2}, {IMPORT_PRIVATE_KEY, 65}, + {SEND_RAW_DATA, 6}, {SEND_LOGIN, 33}, {SEND_STATUS_REQ, 33}, {HAS_CONNECTION, 33}, + {LOGOUT, 33}, {GET_CONTACT_BY_KEY, 33}, {GET_CHANNEL, 2}, {SET_CHANNEL, 50}, + {SIGN_DATA, 2}, {SEND_TRACE_PATH, 11}, {SET_DEVICE_PIN, 5}, {SET_OTHER_PARAMS, 2}, + {SEND_TELEMETRY_REQ, 4}, {SET_CUSTOM_VAR, 4}, {GET_ADVERT_PATH, 34}, + {SEND_BINARY_REQ, 34}, {FACTORY_RESET, 6}, {SEND_PATH_DISCOVERY_REQ, 34}, + {SET_FLOOD_SCOPE_KEY, 2}, {SEND_CONTROL_DATA, 2}, {GET_STATS, 2}, {SEND_ANON_REQ, 34}, + {SET_AUTOADD_CONFIG, 2}, {SET_PATH_HASH_MODE, 3}, {SEND_RAW_PACKET, 4}, + {RUN_CLI_COMMAND, 3}, {SEND_CHANNEL_DATA, 4}, + }; + Harness h; + for (const Case& c : catalog()) { + for (auto m : MINIMUMS) { + if (c.frame[0] != m.code || c.frame.size() != m.min) continue; + for (const Bytes& tail : TAILS) { + prepare(h); + h.send(staleFrame(tail)); + Bytes shorter(c.frame.begin(), c.frame.end() - 1); + uint8_t expected = m.code == SEND_CHANNEL_DATA ? ERR_ILLEGAL_ARG : ERR_UNSUPPORTED_CMD; + EXPECT_EQ(h.reply(shorter), Bytes({1, expected})) << c.name << " minus one, tail " << hex(tail); + EXPECT_EQ(h.mesh().outboundTotal(), 0) << c.name << " minus one"; + } + } + } + // every minimum has a catalog frame of exactly that length + for (auto m : MINIMUMS) { + bool found = false; + for (const Case& c : catalog()) found |= c.frame[0] == m.code && c.frame.size() == m.min; + EXPECT_TRUE(found) << "no catalog frame at the minimum of code " << (int)m.code; + } +} From b1adc9a145c1e460f10729af573082b9c7ae23b1 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 08:23:09 +0200 Subject: [PATCH 123/136] refactor(helpers): FrameReader, reads a frame's fields in order and never past its end Header only, not used by firmware code yet. u8/u16/u32/i32 copy with memcpy as the handlers do, bytes(n) returns a pointer into the frame, has(n) tests an optional field, restAsCString() writes the terminator at buf[len] as five companion handlers do today. A read past the end returns 0 or nullptr and clears ok() for good. Host builds can count such reads (FRAME_READER_COUNT_OVERRUNS); native_companion does, and a new test allows them only for an announced path in SEND_RAW_DATA and SEND_CHANNEL_DATA. --- platformio.ini | 2 + src/helpers/FrameReader.h | 79 ++++++++++++++ .../test_companion_frames.cpp | 4 + .../test_frame_bounds.cpp | 36 ++++++ .../test_frame_reader.cpp | 103 ++++++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 src/helpers/FrameReader.h create mode 100644 test/test_companion_frames/test_frame_reader.cpp diff --git a/platformio.ini b/platformio.ini index 5a57d9fa55d..c38434246d7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -350,6 +350,8 @@ platform = native test_framework = googletest build_flags = -std=c++17 -D RP2040_PLATFORM + ; the frame reader counts reads that ran out, for test_frame_bounds.cpp + -D FRAME_READER_COUNT_OVERRUNS -D ENABLE_PRIVATE_KEY_EXPORT=1 -D ENABLE_PRIVATE_KEY_IMPORT=1 -D MAX_LORA_TX_POWER=22 diff --git a/src/helpers/FrameReader.h b/src/helpers/FrameReader.h new file mode 100644 index 00000000000..807e7b4f392 --- /dev/null +++ b/src/helpers/FrameReader.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +// Reads the fields of a frame in order, and never past its end. +// +// FrameReader r(cmd_frame, len, 1); // the fields start behind the code byte +// uint32_t secs = r.u32(); +// uint8_t* key = r.bytes(PUB_KEY_SIZE); +// if (r.has(4)) alt = r.i32(); // an optional field +// +// A read that would go past `len` returns 0 (or nullptr), leaves the cursor where it +// is and clears ok(), which stays false. Values of more than one byte are copied +// with memcpy, so they come out in the target's byte order, as the code that read +// cmd_frame at fixed offsets got them. +// +// The buffer must have room for len + 1 bytes when restAsCString() is used: it +// writes the terminator at buf[len]. The companion's cmd_frame is +// MAX_FRAME_SIZE + 1 bytes for that reason. + +#ifdef FRAME_READER_COUNT_OVERRUNS +extern int frame_reader_overruns; // host tests count the reads that ran out +#endif + +class FrameReader { +public: + FrameReader(uint8_t* buf, size_t len, size_t pos = 0) + : _buf(buf), _len(len), _pos(pos <= len ? pos : len), _ok(pos <= len) { } + + uint8_t u8() { return fits(1) ? _buf[_pos++] : 0; } + uint16_t u16() { uint16_t v = 0; copy(&v, sizeof(v)); return v; } + uint32_t u32() { uint32_t v = 0; copy(&v, sizeof(v)); return v; } + int32_t i32() { int32_t v = 0; copy(&v, sizeof(v)); return v; } + + // n bytes at the cursor, inside the frame; nullptr if they are not all there. + uint8_t* bytes(size_t n) { + if (!fits(n)) return nullptr; + uint8_t* p = &_buf[_pos]; + _pos += n; + return p; + } + void skip(size_t n) { if (fits(n)) _pos += n; } + + // The rest of the frame as a C string: writes 0 at buf[len] and consumes it all. + char* restAsCString() { + _buf[_len] = 0; + char* p = (char*)&_buf[_pos]; + _pos = _len; + return p; + } + + bool has(size_t n) const { return _ok && n <= _len - _pos; } + size_t remaining() const { return _len - _pos; } + size_t pos() const { return _pos; } + bool ok() const { return _ok; } + +private: + uint8_t* _buf; + size_t _len; + size_t _pos; + bool _ok; + + bool fits(size_t n) { + if (has(n)) return true; + _ok = false; +#ifdef FRAME_READER_COUNT_OVERRUNS + frame_reader_overruns++; +#endif + return false; + } + void copy(void* dest, size_t n) { + if (fits(n)) { + memcpy(dest, &_buf[_pos], n); + _pos += n; + } + } +}; diff --git a/test/test_companion_frames/test_companion_frames.cpp b/test/test_companion_frames/test_companion_frames.cpp index 11db6a9ad04..1e7909ebcb5 100644 --- a/test/test_companion_frames/test_companion_frames.cpp +++ b/test/test_companion_frames/test_companion_frames.cpp @@ -8,6 +8,7 @@ // minimum length is // - test_frame_bounds.cpp: that no command reads the bytes a previous frame left // above the end of the current one (step 10c) +// - test_frame_reader.cpp: the bounded reader the handlers use since step 10c #include @@ -26,6 +27,9 @@ mesh::LocalIdentity radio_new_identity() { return id; } +// FrameReader counts the reads that ran out (FRAME_READER_COUNT_OVERRUNS). +int frame_reader_overruns = 0; + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_companion_frames/test_frame_bounds.cpp b/test/test_companion_frames/test_frame_bounds.cpp index 3d67ae9109b..6ffea4395db 100644 --- a/test/test_companion_frames/test_frame_bounds.cpp +++ b/test/test_companion_frames/test_frame_bounds.cpp @@ -16,6 +16,7 @@ // would change and step 10c must not. #include "CompanionHarness.h" +#include #include #include @@ -316,3 +317,38 @@ TEST(FrameBounds, OneByteBelowEachMinimum) { EXPECT_TRUE(found) << "no catalog frame at the minimum of code " << (int)m.code; } } + +// Since step 10c the handlers read through FrameReader, which counts every read +// that would have gone past the frame. Only the two commands whose path length +// byte can announce more than the frame holds may run out, and they refuse the +// frame when they do; every other command reads inside its frame at every length +// and fill, and every catalog frame except those two kinds reads inside it. +TEST(FrameBounds, OnlyAnAnnouncedPathRunsOut) { + Harness h; + const uint8_t fills[] = {0x00, 0x01, 0x3F, 0x43, 0x81, 0xFF}; + for (int code = 0; code < 70; code++) { + int overruns = 0; + for (uint8_t fill : fills) { + for (size_t len = 1; len <= MAX_FRAME_SIZE; len++) { + h.reset(); + h.boot(); + Bytes f(1, (uint8_t)code); + putFill(f, len - 1, fill); + int before = frame_reader_overruns; + h.send(f); + overruns += frame_reader_overruns - before; + } + } + if (code != SEND_RAW_DATA && code != SEND_CHANNEL_DATA) { + EXPECT_EQ(overruns, 0) << "code " << code; + } + } + for (const Case& c : catalog()) { + prepare(h); + int before = frame_reader_overruns; + h.send(c.frame); + bool may_run_out = c.name.find("past end") != std::string::npos && + (c.frame[0] == SEND_RAW_DATA || c.frame[0] == SEND_CHANNEL_DATA); + if (!may_run_out) EXPECT_EQ(frame_reader_overruns, before) << c.name; + } +} diff --git a/test/test_companion_frames/test_frame_reader.cpp b/test/test_companion_frames/test_frame_reader.cpp new file mode 100644 index 00000000000..0822cb99078 --- /dev/null +++ b/test/test_companion_frames/test_frame_reader.cpp @@ -0,0 +1,103 @@ +// FrameReader (src/helpers/FrameReader.h) on its own: each read at the end of a +// frame and past it, the cursor, ok() staying false, and the terminator. + +#include + +#include + +namespace { + +TEST(FrameReader, ReadsFieldsInOrder) { + uint8_t buf[16] = {0x16, 0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0xFE, 0xFF, 0xFF, 0xFF, 0x77}; + FrameReader r(buf, 13, 1); + EXPECT_EQ(r.u8(), 0xAA); + uint32_t expected32; + memcpy(&expected32, &buf[2], 4); + EXPECT_EQ(r.u32(), expected32); + uint16_t expected16; + memcpy(&expected16, &buf[6], 2); + EXPECT_EQ(r.u16(), expected16); + int32_t expected_i; + memcpy(&expected_i, &buf[8], 4); + EXPECT_EQ(r.i32(), expected_i); + EXPECT_EQ(r.pos(), 12u); + EXPECT_EQ(r.remaining(), 1u); + EXPECT_EQ(r.bytes(1), &buf[12]); + EXPECT_EQ(r.remaining(), 0u); + EXPECT_TRUE(r.ok()); +} + +TEST(FrameReader, AReadAtTheEndFitsExactly) { + uint8_t buf[8] = {1, 2, 3, 4, 5}; + FrameReader r(buf, 5, 1); + EXPECT_TRUE(r.has(4)); + EXPECT_FALSE(r.has(5)); + r.u32(); + EXPECT_TRUE(r.ok()); + EXPECT_FALSE(r.has(1)); + EXPECT_TRUE(r.has(0)); + EXPECT_NE(r.bytes(0), nullptr); +} + +TEST(FrameReader, AReadPastTheEndYieldsZeroAndStops) { + uint8_t buf[8] = {9, 0x11, 0x22, 0x33, 0x5A, 0x5A, 0x5A, 0x5A}; // a stale tail behind len 4 + { + FrameReader r(buf, 4, 1); + EXPECT_EQ(r.u32(), 0u); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.pos(), 1u); // the cursor did not move + EXPECT_EQ(r.u8(), 0); // and stays failed, even for a byte that is there + EXPECT_FALSE(r.ok()); + } + { + FrameReader r(buf, 4, 1); + EXPECT_EQ(r.bytes(4), nullptr); + EXPECT_FALSE(r.has(0)); + } + { + FrameReader r(buf, 4, 1); + r.skip(4); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.remaining(), 3u); + } + { + FrameReader r(buf, 4, 3); + EXPECT_EQ(r.u16(), 0); + EXPECT_EQ(r.i32(), 0); + EXPECT_FALSE(r.ok()); + } +} + +TEST(FrameReader, AStartBeyondTheFrameIsAnOverrun) { + uint8_t buf[4] = {1, 2, 3, 4}; + FrameReader r(buf, 2, 3); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.remaining(), 0u); +} + +TEST(FrameReader, RestAsCStringWritesTheTerminatorAtLen) { + uint8_t buf[8] = {66, 'v', 'e', 'r', 'X', 'Y', 'Z', 'W'}; + FrameReader r(buf, 4, 1); + char* s = r.restAsCString(); + EXPECT_STREQ(s, "ver"); + EXPECT_EQ(buf[4], 0); + EXPECT_EQ(buf[5], 'Y'); // nothing behind it is touched + EXPECT_EQ(r.remaining(), 0u); + EXPECT_TRUE(r.ok()); + + FrameReader empty(buf, 1, 1); + EXPECT_STREQ(empty.restAsCString(), ""); + EXPECT_EQ(buf[1], 0); +} + +TEST(FrameReader, CountsOverruns) { + uint8_t buf[4] = {1, 2, 3, 4}; + int before = frame_reader_overruns; + FrameReader r(buf, 2, 1); + r.u8(); + EXPECT_EQ(frame_reader_overruns, before); + r.u8(); + EXPECT_EQ(frame_reader_overruns, before + 1); +} + +} // namespace From 2db0e8e58bca50ceab08b0b6c5e296edea77245b Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 08:24:53 +0200 Subject: [PATCH 124/136] refactor(companion): 34 handlers read their fields through FrameReader The handlers that read one field after the other without a length test of their own: offsets counted by hand become u8(), u32(), bytes(n), the pointer plus len - n handed to a callee becomes remaining() and bytes(remaining()), and the five in place terminators become restAsCString(), which writes the same byte. Every read lies inside the row's minimum, so the reader never runs out here. Frame sweep, command tests and the frame bounds tests pass unchanged. --- examples/companion_radio/MyMesh.cpp | 224 +++++++++++++++------------- 1 file changed, 124 insertions(+), 100 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a823b19623c..237aae5753e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include "MyMesh.h" +#include #include // needed for PlatformIO #ifdef ENABLE_WIFI_INTERFACE @@ -1102,8 +1103,9 @@ void MyMesh::handleCmdFrame(size_t len) { } // sent when app establishes connection -bool MyMesh::cmdDeviceQuery(size_t) { - app_target_ver = cmd_frame[1]; // which version of protocol does app understand +bool MyMesh::cmdDeviceQuery(size_t len) { + FrameReader r(cmd_frame, len, 1); + app_target_ver = r.u8(); // which version of protocol does app understand int i = 0; out_frame[i++] = RESP_CODE_DEVICE_INFO; @@ -1127,9 +1129,9 @@ bool MyMesh::cmdDeviceQuery(size_t) { // sent when app establishes connection, respond with node ID bool MyMesh::cmdAppStart(size_t len) { - // cmd_frame[1..7] reserved future - char *app_name = (char *)&cmd_frame[8]; - cmd_frame[len] = 0; // make app_name null terminated + FrameReader r(cmd_frame, len, 1); + r.skip(7); // reserved future + char *app_name = r.restAsCString(); MESH_DEBUG_PRINTLN("App %s connected", app_name); _iter_started = false; // stop any left-over ContactsIterator @@ -1172,10 +1174,8 @@ bool MyMesh::cmdAppStart(size_t len) { // V14+ bool MyMesh::cmdRunCliCommand(size_t len) { - int i = 1; - char *text = (char *)&cmd_frame[i]; - int tlen = len - i; - text[tlen] = 0; // ensure null + FrameReader r(cmd_frame, len, 1); + char *text = r.restAsCString(); reply_buf[0] = 0; if (!handleCommand(text, 0, reply_buf)) { @@ -1189,20 +1189,15 @@ bool MyMesh::cmdRunCliCommand(size_t len) { } bool MyMesh::cmdSendTxtMsg(size_t len) { - int i = 1; - uint8_t txt_type = cmd_frame[i++]; - uint8_t attempt = cmd_frame[i++]; - uint32_t msg_timestamp; - memcpy(&msg_timestamp, &cmd_frame[i], 4); - i += 4; - uint8_t *pub_key_prefix = &cmd_frame[i]; - i += 6; + FrameReader r(cmd_frame, len, 1); + uint8_t txt_type = r.u8(); + uint8_t attempt = r.u8(); + uint32_t msg_timestamp = r.u32(); + uint8_t *pub_key_prefix = r.bytes(6); ContactInfo *recipient = lookupContactByPubKey(pub_key_prefix, 6); if (recipient && (txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND)) { - char *text = (char *)&cmd_frame[i]; - int tlen = len - i; + char *text = r.restAsCString(); uint32_t est_timeout; - text[tlen] = 0; // ensure null int result; uint32_t expected_ack; if (txt_type == TXT_TYPE_CLI_DATA || txt_type == TXT_TYPE_CLI_COMMAND) { @@ -1241,20 +1236,19 @@ bool MyMesh::cmdSendTxtMsg(size_t len) { bool MyMesh::cmdSendChannelTxtMsg(size_t len) { // 7 = code, txt_type, channel_idx and the 4 byte timestamp; the text may be empty. // Without this the text length below is computed as len - 7 and turns negative. - int i = 1; - uint8_t txt_type = cmd_frame[i++]; // should be TXT_TYPE_PLAIN - uint8_t channel_idx = cmd_frame[i++]; - uint32_t msg_timestamp; - memcpy(&msg_timestamp, &cmd_frame[i], 4); - i += 4; - const char *text = (char *)&cmd_frame[i]; + FrameReader r(cmd_frame, len, 1); + uint8_t txt_type = r.u8(); // should be TXT_TYPE_PLAIN + uint8_t channel_idx = r.u8(); + uint32_t msg_timestamp = r.u32(); + int text_len = r.remaining(); + const char *text = (char *)r.bytes(text_len); if (txt_type != TXT_TYPE_PLAIN) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); } else { ChannelDetails channel; bool success = getChannel(channel_idx, channel); - if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) { + if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, text_len)) { writeOKFrame(); } else { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx @@ -1339,9 +1333,10 @@ bool MyMesh::cmdGetContacts(size_t len) { } bool MyMesh::cmdSetAdvertName(size_t len) { - int nlen = len - 1; + FrameReader r(cmd_frame, len, 1); + int nlen = r.remaining(); if (nlen > sizeof(_prefs.node_name) - 1) nlen = sizeof(_prefs.node_name) - 1; // max len - memcpy(_prefs.node_name, &cmd_frame[1], nlen); + memcpy(_prefs.node_name, r.bytes(nlen), nlen); _prefs.node_name[nlen] = 0; // null terminator savePrefs(); writeOKFrame(); @@ -1375,9 +1370,9 @@ bool MyMesh::cmdGetDeviceTime(size_t) { return true; } -bool MyMesh::cmdSetDeviceTime(size_t) { - uint32_t secs; - memcpy(&secs, &cmd_frame[1], 4); +bool MyMesh::cmdSetDeviceTime(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint32_t secs = r.u32(); uint32_t curr = getRTCClock()->getCurrentTime(); if (secs >= curr) { getRTCClock()->setCurrentTime(secs); @@ -1411,8 +1406,9 @@ bool MyMesh::cmdSendSelfAdvert(size_t len) { return true; } -bool MyMesh::cmdResetPath(size_t) { - uint8_t *pub_key = &cmd_frame[1]; +bool MyMesh::cmdResetPath(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { recipient->out_path_len = OUT_PATH_UNKNOWN; @@ -1429,7 +1425,8 @@ bool MyMesh::cmdAddUpdateContact(size_t len) { // updateContactFromFrame() reads every field up to last_advert_timestamp without // testing len; only the trailing lat/lon and lastmod are optional. The reverse // direction (writeContactRespFrame) writes the same fields in the same order. - uint8_t *pub_key = &cmd_frame[1]; + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame if (recipient) { @@ -1452,8 +1449,9 @@ bool MyMesh::cmdAddUpdateContact(size_t len) { return true; } -bool MyMesh::cmdRemoveContact(size_t) { - uint8_t *pub_key = &cmd_frame[1]; +bool MyMesh::cmdRemoveContact(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient && removeContact(*recipient)) { _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); @@ -1465,8 +1463,9 @@ bool MyMesh::cmdRemoveContact(size_t) { return true; } -bool MyMesh::cmdShareContact(size_t) { - uint8_t *pub_key = &cmd_frame[1]; +bool MyMesh::cmdShareContact(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { if (shareContactZeroHop(*recipient)) { @@ -1480,8 +1479,9 @@ bool MyMesh::cmdShareContact(size_t) { return true; } -bool MyMesh::cmdGetContactByKey(size_t) { - uint8_t *pub_key = &cmd_frame[1]; +bool MyMesh::cmdGetContactByKey(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (contact) { writeContactRespFrame(RESP_CODE_CONTACT, *contact); @@ -1525,7 +1525,9 @@ bool MyMesh::cmdExportContact(size_t len) { } bool MyMesh::cmdImportContact(size_t len) { - if (importContact(&cmd_frame[1], len - 1)) { + FrameReader r(cmd_frame, len, 1); + size_t n = r.remaining(); + if (importContact(r.bytes(n), n)) { writeOKFrame(); } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -1585,8 +1587,9 @@ bool MyMesh::cmdSetRadioParams(size_t len) { return true; } -bool MyMesh::cmdSetRadioTxPower(size_t) { - int8_t power = (int8_t)cmd_frame[1]; +bool MyMesh::cmdSetRadioTxPower(size_t len) { + FrameReader r(cmd_frame, len, 1); + int8_t power = (int8_t)r.u8(); if (power < -9 || power > MAX_LORA_TX_POWER) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { @@ -1599,13 +1602,10 @@ bool MyMesh::cmdSetRadioTxPower(size_t) { } // two 32 bit values, neither range checked -bool MyMesh::cmdSetTuningParams(size_t) { - int i = 1; - uint32_t rx, af; - memcpy(&rx, &cmd_frame[i], 4); - i += 4; - memcpy(&af, &cmd_frame[i], 4); - i += 4; +bool MyMesh::cmdSetTuningParams(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint32_t rx = r.u32(); + uint32_t af = r.u32(); _prefs.rx_delay_base = ((float)rx) / 1000.0f; _prefs.airtime_factor = ((float)af) / 1000.0f; savePrefs(); @@ -1643,20 +1643,23 @@ bool MyMesh::cmdSetOtherParams(size_t len) { return true; } -bool MyMesh::cmdSetPathHashMode(size_t) { - if (cmd_frame[1] != 0) return false; - if (cmd_frame[2] >= 3) { +bool MyMesh::cmdSetPathHashMode(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (r.u8() != 0) return false; + uint8_t mode = r.u8(); + if (mode >= 3) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { - _prefs.path_hash_mode = cmd_frame[2]; + _prefs.path_hash_mode = mode; savePrefs(); writeOKFrame(); } return true; } -bool MyMesh::cmdReboot(size_t) { - if (memcmp(&cmd_frame[1], "reboot", 6) != 0) return false; +bool MyMesh::cmdReboot(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (memcmp(r.bytes(6), "reboot", 6) != 0) return false; if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? saveContacts(); } @@ -1690,13 +1693,15 @@ bool MyMesh::cmdExportPrivateKey(size_t) { return true; } -bool MyMesh::cmdImportPrivateKey(size_t) { +bool MyMesh::cmdImportPrivateKey(size_t len) { #if ENABLE_PRIVATE_KEY_IMPORT - if (!mesh::LocalIdentity::validatePrivateKey(&cmd_frame[1])) { + FrameReader r(cmd_frame, len, 1); + uint8_t *key = r.bytes(64); + if (!mesh::LocalIdentity::validatePrivateKey(key)) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid key } else { mesh::LocalIdentity identity; - identity.readFrom(&cmd_frame[1], 64); + identity.readFrom(key, 64); if (_store->saveMainIdentity(identity)) { self_id = identity; writeOKFrame(); @@ -1737,10 +1742,10 @@ bool MyMesh::cmdSendRawData(size_t len) { } bool MyMesh::cmdSendLogin(size_t len) { - uint8_t *pub_key = &cmd_frame[1]; + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); - char *password = (char *)&cmd_frame[1 + PUB_KEY_SIZE]; - cmd_frame[len] = 0; // ensure null terminator in password + char *password = r.restAsCString(); if (recipient) { uint32_t est_timeout; int result = sendLogin(*recipient, password, est_timeout); @@ -1762,7 +1767,8 @@ bool MyMesh::cmdSendLogin(size_t len) { } bool MyMesh::cmdSendAnonReq(size_t len) { - uint8_t *pub_key = &cmd_frame[1]; + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); ContactInfo anon; if (recipient == NULL) { // FIRMWARE_VER_CODE 13+, allow non-contact requests @@ -1774,10 +1780,11 @@ bool MyMesh::cmdSendAnonReq(size_t len) { if (addContact(anon)) recipient = &anon; } - uint8_t *data = &cmd_frame[1 + PUB_KEY_SIZE]; + size_t data_len = r.remaining(); + uint8_t *data = r.bytes(data_len); if (recipient) { uint32_t tag, est_timeout; - int result = sendAnonReq(*recipient, data, len - (1 + PUB_KEY_SIZE), tag, est_timeout); + int result = sendAnonReq(*recipient, data, data_len, tag, est_timeout); if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { @@ -1795,8 +1802,9 @@ bool MyMesh::cmdSendAnonReq(size_t len) { return true; } -bool MyMesh::cmdSendStatusReq(size_t) { - uint8_t *pub_key = &cmd_frame[1]; +bool MyMesh::cmdSendStatusReq(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { uint32_t tag, est_timeout; @@ -1819,9 +1827,10 @@ bool MyMesh::cmdSendStatusReq(size_t) { return true; } -bool MyMesh::cmdSendPathDiscoveryReq(size_t) { - if (cmd_frame[1] != 0) return false; - uint8_t *pub_key = &cmd_frame[2]; +bool MyMesh::cmdSendPathDiscoveryReq(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (r.u8() != 0) return false; + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { uint32_t tag, est_timeout; @@ -1901,12 +1910,14 @@ bool MyMesh::cmdSendTelemetryReq(size_t len) { } bool MyMesh::cmdSendBinaryReq(size_t len) { - uint8_t *pub_key = &cmd_frame[1]; + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { - uint8_t *req_data = &cmd_frame[1 + PUB_KEY_SIZE]; + size_t req_len = r.remaining(); + uint8_t *req_data = r.bytes(req_len); uint32_t tag, est_timeout; - int result = sendRequest(*recipient, req_data, len - (1 + PUB_KEY_SIZE), tag, est_timeout); + int result = sendRequest(*recipient, req_data, req_len, tag, est_timeout); if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { @@ -1924,8 +1935,9 @@ bool MyMesh::cmdSendBinaryReq(size_t len) { return true; } -bool MyMesh::cmdHasConnection(size_t) { - uint8_t *pub_key = &cmd_frame[1]; +bool MyMesh::cmdHasConnection(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); if (hasConnectionTo(pub_key)) { writeOKFrame(); } else { @@ -1934,15 +1946,17 @@ bool MyMesh::cmdHasConnection(size_t) { return true; } -bool MyMesh::cmdLogout(size_t) { - uint8_t *pub_key = &cmd_frame[1]; +bool MyMesh::cmdLogout(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); stopConnection(pub_key); writeOKFrame(); return true; } -bool MyMesh::cmdGetChannel(size_t) { - uint8_t channel_idx = cmd_frame[1]; +bool MyMesh::cmdGetChannel(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t channel_idx = r.u8(); ChannelDetails channel; if (getChannel(channel_idx, channel)) { int i = 0; @@ -1997,11 +2011,13 @@ bool MyMesh::cmdSignStart(size_t) { } bool MyMesh::cmdSignData(size_t len) { - if (sign_data == NULL || sign_data_len + (len - 1) > MAX_SIGN_DATA_LEN) { + FrameReader r(cmd_frame, len, 1); + size_t n = r.remaining(); + if (sign_data == NULL || sign_data_len + n > MAX_SIGN_DATA_LEN) { writeErrFrame(sign_data == NULL ? ERR_CODE_BAD_STATE : ERR_CODE_TABLE_FULL); // error: too long } else { - memcpy(&sign_data[sign_data_len], &cmd_frame[1], len - 1); - sign_data_len += (len - 1); + memcpy(&sign_data[sign_data_len], r.bytes(n), n); + sign_data_len += n; writeOKFrame(); } return true; @@ -2052,11 +2068,11 @@ bool MyMesh::cmdSendTracePath(size_t len) { return true; } -bool MyMesh::cmdSetDevicePin(size_t) { +bool MyMesh::cmdSetDevicePin(size_t len) { // get pin from command frame - uint32_t pin; - memcpy(&pin, &cmd_frame[1], 4); + FrameReader r(cmd_frame, len, 1); + uint32_t pin = r.u32(); // ensure pin is zero, or a valid 6 digit pin if (pin == 0 || (pin >= 100000 && pin <= 999999)) { @@ -2087,8 +2103,8 @@ bool MyMesh::cmdGetCustomVars(size_t) { } bool MyMesh::cmdSetCustomVar(size_t len) { - cmd_frame[len] = 0; - char *sp = (char *)&cmd_frame[1]; + FrameReader r(cmd_frame, len, 1); + char *sp = r.restAsCString(); char *np = strchr(sp, ':'); // look for separator char if (np) { *np++ = 0; // modify 'cmd_frame', replace ':' with null @@ -2115,9 +2131,10 @@ bool MyMesh::cmdSetCustomVar(size_t len) { return true; } -bool MyMesh::cmdGetAdvertPath(size_t) { - // FUTURE use: uint8_t reserved = cmd_frame[1]; - uint8_t *pub_key = &cmd_frame[2]; +bool MyMesh::cmdGetAdvertPath(size_t len) { + FrameReader r(cmd_frame, len, 1); + r.skip(1); // FUTURE use: reserved + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); AdvertPath* found = NULL; for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) { auto p = &advert_paths[i]; @@ -2139,8 +2156,9 @@ bool MyMesh::cmdGetAdvertPath(size_t) { return true; } -bool MyMesh::cmdGetStats(size_t) { - uint8_t stats_type = cmd_frame[1]; +bool MyMesh::cmdGetStats(size_t len) { + FrameReader r(cmd_frame, len, 1); + uint8_t stats_type = r.u8(); if (stats_type == STATS_TYPE_CORE) { int i = 0; out_frame[i++] = RESP_CODE_STATS; @@ -2193,8 +2211,9 @@ bool MyMesh::cmdGetStats(size_t) { return true; } -bool MyMesh::cmdFactoryReset(size_t) { - if (memcmp(&cmd_frame[1], "reset", 5) != 0) return false; +bool MyMesh::cmdFactoryReset(size_t len) { + FrameReader r(cmd_frame, len, 1); + if (memcmp(r.bytes(5), "reset", 5) != 0) return false; if (_serial) { 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 @@ -2262,8 +2281,11 @@ bool MyMesh::cmdGetDefaultFloodScope(size_t) { } bool MyMesh::cmdSendControlData(size_t len) { - if ((cmd_frame[1] & 0x80) == 0) return false; - auto resp = createControlData(&cmd_frame[1], len - 1); + FrameReader r(cmd_frame, len, 1); + size_t n = r.remaining(); + uint8_t *data = r.bytes(n); + if ((data[0] & 0x80) == 0) return false; + auto resp = createControlData(data, n); if (resp) { sendZeroHop(resp); writeOKFrame(); @@ -2308,8 +2330,10 @@ bool MyMesh::cmdGetAllowedRepeatFreq(size_t) { bool MyMesh::cmdSendRawPacket(size_t len) { auto pkt = obtainNewPacket(); if (pkt) { - uint8_t priority = cmd_frame[1]; - if (tryParsePacket(pkt, &cmd_frame[2], len - 2)) { + FrameReader r(cmd_frame, len, 1); + uint8_t priority = r.u8(); + int raw_len = r.remaining(); + if (tryParsePacket(pkt, r.bytes(raw_len), raw_len)) { sendPacket(pkt, priority, 0); writeOKFrame(); } else { From 90b5da2e1d5f865a0df4e7d7a08e240e8d49fed7 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 08:26:43 +0200 Subject: [PATCH 125/136] refactor(companion): the 14 handlers with optional fields, paths or length tests read through FrameReader Optional fields test r.has(n) for the bytes they need, which is the old len test with the same value. The path of SEND_RAW_DATA and SEND_CHANNEL_DATA is taken with bytes() before it is copied, so a path length byte that announces more than the frame holds no longer copies the previous frame's bytes; it fails the length test that follows, as it did before, with the same ILLEGAL_ARG. SET_DEFAULT_FLOOD_SCOPE bounds its strlen() to the frame: a name without terminator was refused before and still is. SET_AUTOADD_CONFIG reads the hop count into a local first, since min() may be a macro. --- examples/companion_radio/MyMesh.cpp | 164 ++++++++++++++++------------ 1 file changed, 93 insertions(+), 71 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 237aae5753e..a566a5374d3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1026,6 +1026,11 @@ void MyMesh::startInterface(BaseSerialInterface &serial) { serial.enable(); } +// The bytes a path length byte announces; Packet::writePath() copies as many. +static size_t pathByteLen(uint8_t path_len) { + return (path_len & 63) * ((path_len >> 6) + 1); +} + // One row per command: the code, the shortest frame the command accepts (code byte // included), and its handler. A frame below the minimum, a code without a row, and // a handler that returns false ("not this command") all get the answer the if chain @@ -1263,9 +1268,9 @@ bool MyMesh::cmdSendChannelData(size_t len) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); return true; } - int i = 1; - uint8_t channel_idx = cmd_frame[i++]; - uint8_t path_len = cmd_frame[i++]; + FrameReader r(cmd_frame, len, 1); + uint8_t channel_idx = r.u8(); + uint8_t path_len = r.u8(); // validate path len, allowing 0xFF for flood if (!mesh::Packet::isValidPathLen(path_len) && path_len != OUT_PATH_UNKNOWN) { @@ -1277,19 +1282,20 @@ bool MyMesh::cmdSendChannelData(size_t len) { // parse provided path if not flood uint8_t path[MAX_PATH_SIZE]; if (path_len != OUT_PATH_UNKNOWN) { - i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); + const uint8_t *src = r.bytes(pathByteLen(path_len)); + if (src) mesh::Packet::writePath(path, src, path_len); } - if ((size_t)(i + 2) > len) { // the 2 byte data_type must be inside the frame + if (!r.ok() || !r.has(2)) { // the path and the 2 byte data_type must be inside the frame MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA frame too short for data_type: %d", (uint32_t)len); writeErrFrame(ERR_CODE_ILLEGAL_ARG); return true; } - uint16_t data_type = ((uint16_t)cmd_frame[i]) | (((uint16_t)cmd_frame[i + 1]) << 8); - i += 2; - const uint8_t *payload = &cmd_frame[i]; - int payload_len = (len > (size_t)i) ? (int)(len - i) : 0; + uint16_t data_type = r.u8(); + data_type |= ((uint16_t)r.u8()) << 8; + int payload_len = r.remaining(); + const uint8_t *payload = r.bytes(payload_len); ChannelDetails channel; if (!getChannel(channel_idx, channel)) { @@ -1312,8 +1318,9 @@ bool MyMesh::cmdGetContacts(size_t len) { if (_iter_started) { writeErrFrame(ERR_CODE_BAD_STATE); // iterator is currently busy } else { - if (len >= 5) { // has optional 'since' param - memcpy(&_iter_filter_since, &cmd_frame[1], 4); + FrameReader r(cmd_frame, len, 1); + if (r.has(4)) { // has optional 'since' param + _iter_filter_since = r.u32(); } else { _iter_filter_since = 0; } @@ -1344,11 +1351,12 @@ bool MyMesh::cmdSetAdvertName(size_t len) { } bool MyMesh::cmdSetAdvertLatlon(size_t len) { - int32_t lat, lon, alt = 0; - memcpy(&lat, &cmd_frame[1], 4); - memcpy(&lon, &cmd_frame[5], 4); - if (len >= 13) { - memcpy(&alt, &cmd_frame[9], 4); // for FUTURE support + FrameReader r(cmd_frame, len, 1); + int32_t lat = r.i32(); + int32_t lon = r.i32(); + int32_t alt = 0; + if (r.has(4)) { + alt = r.i32(); // for FUTURE support } if (lat <= 90 * 1E6 && lat >= -90 * 1E6 && lon <= 180 * 1E6 && lon >= -180 * 1E6) { sensors.node_lat = ((double)lat) / 1000000.0; @@ -1384,6 +1392,7 @@ bool MyMesh::cmdSetDeviceTime(size_t len) { } bool MyMesh::cmdSendSelfAdvert(size_t len) { + FrameReader r(cmd_frame, len, 1); mesh::Packet* pkt; if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { pkt = createSelfAdvert(_prefs.node_name); @@ -1391,7 +1400,7 @@ bool MyMesh::cmdSendSelfAdvert(size_t len) { pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); } if (pkt) { - if (len >= 2 && cmd_frame[1] == 1) { // optional param (1 = flood, 0 = zero hop) + if (r.has(1) && r.u8() == 1) { // optional param (1 = flood, 0 = zero hop) unsigned long delay_millis = 0; TransportKey default_scope; memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); @@ -1492,7 +1501,8 @@ bool MyMesh::cmdGetContactByKey(size_t len) { } bool MyMesh::cmdExportContact(size_t len) { - if (len < 1 + PUB_KEY_SIZE) { + FrameReader r(cmd_frame, len, 1); + if (!r.has(PUB_KEY_SIZE)) { // export SELF mesh::Packet* pkt; if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { @@ -1511,7 +1521,7 @@ bool MyMesh::cmdExportContact(size_t len) { writeErrFrame(ERR_CODE_TABLE_FULL); // Error } } else { - uint8_t *pub_key = &cmd_frame[1]; + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); uint8_t out_len; if (recipient && (out_len = exportContact(*recipient, &out_frame[1])) > 0) { @@ -1549,18 +1559,14 @@ bool MyMesh::cmdSyncNextMessage(size_t) { // freq, bw, sf, cr; the repeat flag stays optional bool MyMesh::cmdSetRadioParams(size_t len) { - int i = 1; - uint32_t freq; - memcpy(&freq, &cmd_frame[i], 4); - i += 4; - uint32_t bw; - memcpy(&bw, &cmd_frame[i], 4); - i += 4; - uint8_t sf = cmd_frame[i++]; - uint8_t cr = cmd_frame[i++]; + FrameReader r(cmd_frame, len, 1); + uint32_t freq = r.u32(); + uint32_t bw = r.u32(); + uint8_t sf = r.u8(); + uint8_t cr = r.u8(); uint8_t repeat = 0; // default - false - if (len > i) { - repeat = cmd_frame[i++]; // FIRMWARE_VER_CODE 9+ + if (r.has(1)) { + repeat = r.u8(); // FIRMWARE_VER_CODE 9+ } if (repeat && !isValidClientRepeatFreq(freq)) { @@ -1625,16 +1631,18 @@ bool MyMesh::cmdGetTuningParams(size_t) { // the fields after manual_add stay optional bool MyMesh::cmdSetOtherParams(size_t len) { - _prefs.manual_add_contacts = cmd_frame[1]; - if (len >= 3) { - _prefs.telemetry_mode_base = cmd_frame[2] & 0x03; // v5+ - _prefs.telemetry_mode_loc = (cmd_frame[2] >> 2) & 0x03; - _prefs.telemetry_mode_env = (cmd_frame[2] >> 4) & 0x03; - - if (len >= 4) { - _prefs.advert_loc_policy = cmd_frame[3]; - if (len >= 5) { - _prefs.multi_acks = cmd_frame[4]; + FrameReader r(cmd_frame, len, 1); + _prefs.manual_add_contacts = r.u8(); + if (r.has(1)) { + uint8_t modes = r.u8(); + _prefs.telemetry_mode_base = modes & 0x03; // v5+ + _prefs.telemetry_mode_loc = (modes >> 2) & 0x03; + _prefs.telemetry_mode_env = (modes >> 4) & 0x03; + + if (r.has(1)) { + _prefs.advert_loc_policy = r.u8(); + if (r.has(1)) { + _prefs.multi_acks = r.u8(); } } } @@ -1719,15 +1727,17 @@ bool MyMesh::cmdImportPrivateKey(size_t len) { } bool MyMesh::cmdSendRawData(size_t len) { - int i = 1; - uint8_t path_len = cmd_frame[i++]; + FrameReader r(cmd_frame, len, 1); + uint8_t path_len = r.u8(); if (path_len >= 0 && mesh::Packet::isValidPathLen(path_len)) { uint8_t path[MAX_PATH_SIZE]; - i += mesh::Packet::writePath(path, &cmd_frame[i], path_len); - if (i + 4 > len) { // min payload 4 bytes + const uint8_t *src = r.bytes(pathByteLen(path_len)); + if (src) mesh::Packet::writePath(path, src, path_len); + if (!r.ok() || !r.has(4)) { // the path and a payload of at least 4 bytes writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { - auto pkt = createRawData(&cmd_frame[i], len - i); + size_t n = r.remaining(); + auto pkt = createRawData(r.bytes(n), n); if (pkt) { sendDirect(pkt, path, path_len); writeOKFrame(); @@ -1862,8 +1872,10 @@ bool MyMesh::cmdSendPathDiscoveryReq(size_t len) { } bool MyMesh::cmdSendTelemetryReq(size_t len) { - if (len >= 4 + PUB_KEY_SIZE) { // can deprecate, in favour of CMD_SEND_BINARY_REQ - uint8_t *pub_key = &cmd_frame[4]; + FrameReader r(cmd_frame, len, 1); + r.skip(3); // reserved + if (r.has(PUB_KEY_SIZE)) { // can deprecate, in favour of CMD_SEND_BINARY_REQ + uint8_t *pub_key = r.bytes(PUB_KEY_SIZE); ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient) { uint32_t tag, est_timeout; @@ -1884,7 +1896,7 @@ bool MyMesh::cmdSendTelemetryReq(size_t len) { } return true; } - if (len == 4) { // 'self' telemetry request + if (r.remaining() == 0) { // 'self' telemetry request telemetry.reset(); telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); float temperature = board.getMCUTemperature(); @@ -1974,16 +1986,17 @@ bool MyMesh::cmdGetChannel(size_t len) { } bool MyMesh::cmdSetChannel(size_t len) { - if (len >= 2 + 32 + 32) { + FrameReader r(cmd_frame, len, 1); + if (r.has(1 + 32 + 32)) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // not supported (yet) return true; } - if (len >= 2 + 32 + 16) { - uint8_t channel_idx = cmd_frame[1]; + if (r.has(1 + 32 + 16)) { + uint8_t channel_idx = r.u8(); ChannelDetails channel; - StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); + StrHelper::strncpy(channel.name, (char *)r.bytes(32), 32); memset(channel.channel.secret, 0, sizeof(channel.channel.secret)); - memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported + memcpy(channel.channel.secret, r.bytes(16), 16); // NOTE: only 128-bit supported if (setChannel(channel_idx, channel)) { saveChannels(); writeOKFrame(); @@ -2039,19 +2052,20 @@ bool MyMesh::cmdSignFinish(size_t) { } bool MyMesh::cmdSendTracePath(size_t len) { - if (len - 10 >= MAX_PACKET_PAYLOAD-5) return false; - uint8_t path_len = len - 10; - uint8_t flags = cmd_frame[9]; + FrameReader r(cmd_frame, len, 1); + uint32_t tag = r.u32(); + uint32_t auth = r.u32(); + uint8_t flags = r.u8(); + if (r.remaining() >= MAX_PACKET_PAYLOAD-5) return false; + uint8_t path_len = r.remaining(); + uint8_t *path = r.bytes(path_len); uint8_t path_sz = flags & 0x03; // NEW v1.11+ if ((path_len >> path_sz) > MAX_PATH_SIZE || (path_len % (1 << path_sz)) != 0) { // make sure is multiple of path_sz writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { - uint32_t tag, auth; - memcpy(&tag, &cmd_frame[1], 4); - memcpy(&auth, &cmd_frame[5], 4); auto pkt = createTrace(tag, auth, flags); if (pkt) { - sendDirect(pkt, &cmd_frame[10], path_len); + sendDirect(pkt, path, path_len); uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2); uint32_t est_timeout = calcDirectTimeoutMillisFor(t, path_len >> path_sz); @@ -2230,9 +2244,11 @@ bool MyMesh::cmdFactoryReset(size_t len) { } bool MyMesh::cmdSetFloodScopeKey(size_t len) { - if (len >= 2 && cmd_frame[1] == 0) { - if (len >= 2 + 16) { - memcpy(send_scope.key, &cmd_frame[2], sizeof(send_scope.key)); // set scope override TransportKey + FrameReader r(cmd_frame, len, 1); + uint8_t mode = r.u8(); + if (mode == 0) { + if (r.has(16)) { + memcpy(send_scope.key, r.bytes(sizeof(send_scope.key)), sizeof(send_scope.key)); // set scope override TransportKey } else { memset(send_scope.key, 0, sizeof(send_scope.key)); // reset scope override } @@ -2240,7 +2256,7 @@ bool MyMesh::cmdSetFloodScopeKey(size_t len) { writeOKFrame(); return true; } - if (len >= 2 && cmd_frame[1] == 1) { // ver 12+ + if (mode == 1) { // ver 12+ send_unscoped = true; writeOKFrame(); return true; @@ -2249,11 +2265,15 @@ bool MyMesh::cmdSetFloodScopeKey(size_t len) { } bool MyMesh::cmdSetDefaultFloodScope(size_t len) { - if (len >= 1+31+16) { - int n = strlen((char *) &cmd_frame[1]); + FrameReader r(cmd_frame, len, 1); + if (r.has(31 + 16)) { + size_t avail = r.remaining(); + const char *name = (const char *) r.bytes(31); + const uint8_t *key = r.bytes(16); + int n = strnlen(name, avail); // may run on into the key, never past the frame if (n > 0 && n < 31) { - strcpy(_prefs.default_scope_name, (char *) &cmd_frame[1]); - memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16); + strcpy(_prefs.default_scope_name, name); + memcpy(_prefs.default_scope_key, key, 16); savePrefs(); writeOKFrame(); } else { @@ -2297,9 +2317,11 @@ bool MyMesh::cmdSendControlData(size_t len) { // autoadd_max_hops stays optional bool MyMesh::cmdSetAutoaddConfig(size_t len) { - _prefs.autoadd_config = cmd_frame[1]; - if (len >= 3) { - _prefs.autoadd_max_hops = min(cmd_frame[2], (uint8_t)64); + FrameReader r(cmd_frame, len, 1); + _prefs.autoadd_config = r.u8(); + if (r.has(1)) { + uint8_t hops = r.u8(); // read once: min() may be a macro + _prefs.autoadd_max_hops = min(hops, (uint8_t)64); } savePrefs(); writeOKFrame(); From f4072b57a9edcd767dcd3b947c78270035de3d0d Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 08:38:49 +0200 Subject: [PATCH 126/136] test(companion): the flood scope key needs all 16 bytes Found by a mutation of step 10c (has(17) instead of has(16)) that no test caught: the key's effect only shows in the next flood, whose route type carries a transport code or not. Passes on the handlers before and after 10c. --- .../test_frame_bounds.cpp | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/test_companion_frames/test_frame_bounds.cpp b/test/test_companion_frames/test_frame_bounds.cpp index 6ffea4395db..23a7cde2072 100644 --- a/test/test_companion_frames/test_frame_bounds.cpp +++ b/test/test_companion_frames/test_frame_bounds.cpp @@ -352,3 +352,25 @@ TEST(FrameBounds, OnlyAnAnnouncedPathRunsOut) { if (!may_run_out) EXPECT_EQ(frame_reader_overruns, before) << c.name; } } + +// The scope key of SET_FLOOD_SCOPE_KEY is set only by a frame that carries all 16 +// bytes; one byte less clears it. Which of the two happened shows in the next +// flood: with a key it carries a transport code (route type TRANSPORT_FLOOD, 0), +// without one it does not (FLOOD, 1). +TEST(FrameBounds, FloodScopeKeyNeedsAllSixteenBytes) { + Harness h; + for (size_t key_bytes : {15, 16}) { + h.reset(); + h.boot(); + Bytes f = {SET_FLOOD_SCOPE_KEY, 0}; + putFill(f, key_bytes, 0x42); + ASSERT_EQ(h.reply(f), Bytes({0})); + Bytes chat = {SEND_CHANNEL_TXT_MSG, 0, 0}; + putU32(chat, RTC0); + putStr(chat, "x"); + ASSERT_EQ(h.reply(chat), Bytes({0})); + auto queued = h.mesh().outbound(); + ASSERT_EQ(queued.size(), 1u); + EXPECT_EQ(queued[0][0] & 0x03, key_bytes == 16 ? 0 : 1) << key_bytes << " key bytes"; + } +} From 6ba581a7468cfe6c1e585ea5db05b114d8d4ad80 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 09:16:14 +0200 Subject: [PATCH 127/136] docs(refactoring): record step 10c of note 0010, a bounded frame reader --- .../0010-companion-command-table.md | 205 +++++++++++++++++- 1 file changed, 195 insertions(+), 10 deletions(-) diff --git a/docs/refactoring/0010-companion-command-table.md b/docs/refactoring/0010-companion-command-table.md index 4f7f1162a1e..1942f525262 100644 --- a/docs/refactoring/0010-companion-command-table.md +++ b/docs/refactoring/0010-companion-command-table.md @@ -1,7 +1,7 @@ # 0010: A command table for the companion protocol -Status: steps 10a (host harness and characterization) and 10b (the table) ready -for review; 10c (a bounded frame reader) designed below; 10d is a proposal. +Status: steps 10a (host harness and characterization), 10b (the table) and 10c +(a bounded frame reader) ready for review; 10d is a proposal. Behaviour change: none. ## Context @@ -373,7 +373,13 @@ place after that. comparisons, on `len` or on `r.remaining()` with the same value. Where a test follows a path copy today (`SEND_CHANNEL_DATA`, `SEND_RAW_DATA`), the copy becomes `r.bytes(n)`, and "did not fit" (`!r.ok()`) joins the existing test with `||`; - both lead to the `ILLEGAL_ARG` the over-read case answers today. + both lead to the `ILLEGAL_ARG` the over-read case answers today. The number of + bytes a path length byte announces comes from a small helper next to the table, + `pathByteLen()`, the same product `Packet::writePath()` copies. +- **Byte order stays as written.** Where a handler composed a value by shifting + (the `data_type` of `SEND_CHANNEL_DATA`, low byte first), it still does, from two + `u8()` reads; everything that used `memcpy` now uses `u16()`/`u32()`/`i32()`, + which use `memcpy`. - **The unbounded `strlen()`** of `SET_DEFAULT_FLOOD_SCOPE` becomes a length bounded to the frame (`strnlen(p, r.remaining())`); argued above, the answer is the same for every frame. @@ -382,12 +388,12 @@ place after that. the same pointer and length as today (`r.bytes()`/`r.remaining()` instead of `&cmd_frame[n]`/`len - n`). -The reader has a hook for the host, `FRAME_READER_OVERRUN()`, empty on the -targets. The harness counts it, so a test can assert that no frame of the sweep -makes a handler run out, apart from the two path cases where running out is the -refusal. +On the host the reader counts every read that runs out (`-D +FRAME_READER_COUNT_OVERRUNS`, set only in `native_companion`; on the targets the +counter does not exist), so a test can assert that no frame makes a handler run +out, apart from the two path cases where running out is the refusal. -### Tests and evidence planned +### Tests and evidence (plan; results under "Verification of step 10c") - The sweep and the 49 command tests of 10a, unchanged, before and after. - New tests first, on the unchanged handlers: for every row, the frame one byte @@ -418,8 +424,8 @@ for the optional fields. ### One pull request or several One. The reader is useless without users, and the handlers are independent of one -another, so the step is cut into commits instead: the reader with its unit tests; -the new boundary and stale tail tests on the unchanged handlers; the 34 handlers +another, so the step is cut into commits instead: the new boundary and stale tail +tests on the unchanged handlers; the reader with its unit tests; the 34 handlers that read their fields one after the other without a length test of their own; the 14 with optional fields, sub commands, a path or their own length tests, where a reviewer has to compare each test. Each commit passes all suites on its @@ -705,3 +711,182 @@ Not covered on hardware: the setters with new values, sends on air, `REBOOT`, `FACTORY_RESET`, private key import, contact and channel changes. Their handlers are moved text, pinned on the host by the sweep and the command tests. Only nRF52 ran on hardware. + +## Verification of step 10c + +### What changed + +48 handlers start with `FrameReader r(cmd_frame, len, 1);` and read through it; +the 11 that read nothing past the code byte are untouched. In `MyMesh.cpp` the +file changes by 217 lines added and 171 removed (34 handlers in one commit, 14 in +the next, the include and `pathByteLen()` among them). After the step, +`tools/refactoring/cmd_frame_offsets.py` finds no constant offset, no running +index and no length derived from `len` in any handler; the one remaining write +into the frame is the `':'` that `SET_CUSTOM_VAR` replaces in its own text. The +dispatcher, the table and every callee are unchanged. + +`SET_AUTOADD_CONFIG` reads its hop count into a local before `min()`, because +`min()` is a macro on some cores and would read twice. + +### New tests + +All written and run on the unchanged handlers first (commit `29e35f06`), and run +again against the handlers of `68e0fcdc` after the step (60 of 60 pass there too). + +- `test_frame_bounds.cpp`, `NoCommandReadsThePreviousFrame`: a catalog of 98 + frames as an app sends them, each command at its minimum, both sides of every + optional field, the measured reads past the end (an announced path longer than + the frame in two hash sizes for `SEND_RAW_DATA` and `SEND_CHANNEL_DATA`, an + unterminated scope name, transport codes past the end in `SEND_RAW_PACKET`), and + the text commands. Each goes to a fresh node with a contact and a started + signing round, once as it is and once behind each of four tails a previous + frame leaves in `cmd_frame` (zeros, `0x5A`, `0xFF`, and a pattern with a zero + every eighth byte). Replies, queued packets, stored files (except the name tails + in `/channels2`, see 10a), calls to board, radio, clock and sensors, and what 12 + more loops, a `GET_CHANNEL` and a channel flood show afterwards, must be + identical. +- `OneByteBelowEachMinimum`: for each of the 44 rows with a minimum above 1, the + catalog frame at the minimum minus one byte answers `UNSUPPORTED` + (`SEND_CHANNEL_DATA`: `ILLEGAL_ARG`) behind every tail and queues nothing; and the + catalog has a frame of exactly each minimum. +- `OnlyAnAnnouncedPathRunsOut`: every code 0 to 69 at every length with six + fills, and every catalog frame; the reader may run out only for an announced + path in `SEND_RAW_DATA` and `SEND_CHANNEL_DATA`. +- `FloodScopeKeyNeedsAllSixteenBytes`: added after a mutation survived (below). + The key's effect only shows in the next flood, which carries a transport code + with 16 key bytes and none with 15. +- `test_frame_reader.cpp`: six tests of the reader alone. + +One finding of writing the catalog: the upper bound of `SEND_TRACE_PATH` +(`len - 10 >= MAX_PACKET_PAYLOAD - 5`, so 189 bytes) lies above `MAX_FRAME_SIZE` +(176) and is never reached; its `return false` is dead code today. Kept as it is. + +### Mutation tests + +23 faults, each on its own against the converted code, the whole +`native_companion` suite run, the file restored; 19 caught: + +| Mutation | Caught by | +|---|---| +| `SET_TUNING_PARAMS` reads `af` with `u16()` | sweep | +| `SET_TUNING_PARAMS` reads `af` before `rx` | `TuningParamsRoundTrip` | +| `RUN_CLI_COMMAND` text starts one byte late | `RunCliCommand` | +| `SET_OTHER_PARAMS` reads the location policy without `has(1)` | `OnlyAnAnnouncedPathRunsOut` | +| `SET_AUTOADD_CONFIG` hop count behind `has(0)` | `AutoAddConfig`, `OnlyAnAnnouncedPathRunsOut` | +| `SET_CHANNEL` 256 bit test `has(64)` instead of `has(65)` | sweep | +| `SET_FLOOD_SCOPE_KEY` key behind `has(17)` | `FloodScopeKeyNeedsAllSixteenBytes` (not caught before that test existed) | +| `SET_FLOOD_SCOPE_KEY` key behind `has(15)` | crash (null pointer from `bytes(16)`) | +| `SET_DEFAULT_FLOOD_SCOPE` behind `has(46)` | crash (null pointer from `bytes(16)`) | +| `SEND_TELEMETRY_REQ` self form also at 5 bytes | `TelemetryHasTwoForms`, sweep | +| `SEND_RAW_DATA` payload test `has(3)` | `SendRawData`, sweep | +| `pathByteLen()` ignores the hash size | sweep | +| `SEND_TRACE_PATH` reads `flags` before `auth` | `TracePath` | +| `SET_ADVERT_LATLON` takes `lon` from `lat` | `SetAdvertLatLon` | +| reader: `has()` off by one (`<` for `<=`) | crash | +| reader: `has()` allows one byte past the end | 10 tests, among them the sweep and `NoCommandReadsThePreviousFrame` | +| reader: terminator one byte late | `NoCommandReadsThePreviousFrame`, `RestAsCStringWritesTheTerminatorAtLen` | +| reader: terminator not written | the same and the sweep | +| reader: `ok()` not sticky | `AReadPastTheEndYieldsZeroAndStops`, sweep | + +Not caught (4), and why: + +- `SET_CHANNEL` with `has(48)` instead of `has(49)`: equivalent. The row's + minimum is 50, so the handler never sees 49 bytes; the handler's own test is + redundant with the table. +- `SEND_RAW_DATA` and `SEND_CHANNEL_DATA` without `!r.ok()`: equivalent. Once a + read ran out, `has()` is false as well, so the `has()` test alone refuses the + frame. The `!r.ok()` stays because it says what is meant. +- `SEND_CHANNEL_DATA` with the two `data_type` bytes swapped: not observable on the + host. The mock cipher leaves no plaintext in the queued packet, and + `sendGroupData()` is not virtual, so the harness cannot see the value. The code + reads low byte, then high byte, as the shift expression it replaces did; checked + by review only. + +### Host suites + +All 17 host environments on the committed tree, the environment list passed as a +bash array: + +``` +native_companion test_companion_frames PASSED +... (the other 26 suites in 16 environments) PASSED +================ 587 test cases: 587 succeeded ================ +``` + +577 before the step, 10 new: four in `test_frame_bounds.cpp`, six in +`test_frame_reader.cpp`. + +### Builds + +19 environments built from `68e0fcdc` and from the code commit `90b5da2e`, each in +its own worktree with the same toolchain. All build on both sides. + +| Environment | Flash before | Flash after | Delta | RAM delta | +|---|---:|---:|---:|---:| +| `Heltec_v3_companion_radio_ble` | 1,670,229 | 1,671,653 | +1,424 | +0 | +| `Heltec_v3_companion_radio_wifi` | 1,106,381 | 1,107,821 | +1,440 | +0 | +| `Heltec_v3_companion_radio_usb` | 656,829 | 658,193 | +1,364 | +0 | +| `RAK_4631_companion_radio_ble` | 464,212 | 465,172 | +960 | +0 | +| `RAK_4631_companion_radio_usb` | 455,188 | 456,148 | +960 | +0 | +| `ProMicro_companion_radio_usb` | 358,836 | 359,796 | +960 | +0 | +| `LilyGo_T-Echo_companion_radio_ble` | 375,680 | 376,640 | +960 | +0 | +| `LilyGo_T-Echo_companion_radio_usb` | 368,288 | 369,248 | +960 | +0 | +| `PicoW_companion_radio_ble` | 633,604 | 635,332 | +1,728 | +0 | +| `PicoW_companion_radio_wifi` | 528,732 | 530,428 | +1,696 | +0 | +| `PicoW_companion_radio_usb` | 495,284 | 497,012 | +1,728 | +0 | +| `LilyGo_Tlora_C6_companion_radio_ble_` | 1,444,915 | 1,446,667 | +1,752 | +0 | +| `M5Stack_Unit_C6L_companion_radio_usb` | 544,593 | 546,345 | +1,752 | +0 | +| `wio-e5-mini_companion_radio_usb` | 209,016 | 211,296 | +2,280 | +0 | +| `Xiao_C3_companion_radio_ble` | 1,255,482 | 1,256,962 | +1,480 | +0 | +| `Xiao_C3_companion_radio_usb` | 543,422 | 544,902 | +1,480 | +0 | +| `Xiao_C3_companion_radio_wifi` | 1,010,858 | 1,012,330 | +1,472 | +0 | +| `Heltec_v3_repeater` | 1,175,021 | 1,175,021 | +0 | +0 | +| `RAK_4631_repeater` | 441,944 | 441,944 | +0 | +0 | + +The seventeen companions cover the five platform packages that compile +`MyMesh.cpp` and every interface each of them builds (ESP32-S3 BLE, USB and WiFi; +ESP32-C3 BLE, USB and WiFi; ESP32-C6 BLE and USB; nRF52 BLE and USB; RP2040 BLE, +USB and WiFi; STM32 USB). The repeaters do not compile the file and are the +control. + +The design's bound, 1 kB on nRF52, holds: +960 bytes. The other cores pay more +for the inlined bounds tests: about 1.4 kB on ESP32-S3 and C3, 1.7 kB on RP2040 +and C6, 2.3 kB on STM32, where `wio-e5-mini` goes from 79.7 % to 80.6 % of its +256 kB. Making the reader's two checking functions `noinline` was tried and does +not help (T-Echo +2,240 instead of +960, `wio-e5-mini` unchanged), so the reader +stays inline. An unchecked variant for the reads inside the minimum, which the +design kept in reserve, was not needed for the bound and would give back the +guarantee the step is for. + +### Hardware + +LilyGo T-Echo `techo-3dcf` (claimed with `kb device claim`), a companion with its +contacts and channels, normally on the official 1.18.0 BLE image. Two USB +companion images built from `68e0fcdc` and from `90b5da2e`, marked `c10c-base` and +`c10c-head` and confirmed by `ver` before each run. + +- `companion_session.py` once per image (39 accepted frames: every setter with the + value the board holds, declining sub commands, out of range values, stats, self + telemetry, a signing round, channels, CLI, the wrong reboot and reset words) and + compared: the only two differing lines are the build marker inside + `DEVICE_QUERY` and the reply to `ver`. +- `companion_sweep.py` against the 10c image: + + ``` + 6502 frames in 751 s: 6502 as on the host, 0 different, 0 without reply + (compared byte for byte: 4083, by reply code: 2419) + codes covered: 251 + ``` + +- State read through the protocol (`nodetool.py compinfo`) before the first run and + after the sweep: identical. +- Afterwards the official image went back on + (`LilyGo_T-Echo_companion_radio_ble-v1.18.0-81dbae5.zip`, SHA-256 prefix + `6d602a54`); the DFU reported success and the board enumerated again. The phone + this board serves was not connected, so the BLE reconnect was not checked. + +Not covered on hardware: the setters with new values, sends on air, `REBOOT`, +`FACTORY_RESET`, private key import, contact and channel changes, and the stale +tail cases (a board cannot be made to hold a chosen tail without sending that +frame first, which the host tests do). For those the host tests are the evidence. +Only nRF52 ran on hardware. From 97187868b8c56681b662cbad2cfa9aefacae91ab Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 09:20:45 +0200 Subject: [PATCH 128/136] test(companion): pin the three reads past the end that step 10c removed SEND_CHANNEL_DATA and SEND_RAW_DATA with a path longer than the frame, and SET_DEFAULT_FLOOD_SCOPE with an unterminated name, each behind four tails that would complete the path or end the name: ILLEGAL_ARG, nothing queued, no file changed. Passes on the handlers before and after 10c. The note lists the open findings outside the handlers with a way to reproduce each. --- .../0010-companion-command-table.md | 40 ++++++++++++++++++- .../test_frame_bounds.cpp | 32 +++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/docs/refactoring/0010-companion-command-table.md b/docs/refactoring/0010-companion-command-table.md index 1942f525262..d39892b0aa8 100644 --- a/docs/refactoring/0010-companion-command-table.md +++ b/docs/refactoring/0010-companion-command-table.md @@ -752,6 +752,14 @@ again against the handlers of `68e0fcdc` after the step (60 of 60 pass there too - `OnlyAnAnnouncedPathRunsOut`: every code 0 to 69 at every length with six fills, and every catalog frame; the reader may run out only for an announced path in `SEND_RAW_DATA` and `SEND_CHANNEL_DATA`. +- `TheReadsPastTheEndThatStep10cRemoved`: the three handler cases of the + inventory (`SEND_CHANNEL_DATA` and `SEND_RAW_DATA` with a path length byte that + announces 8 bytes, `SET_DEFAULT_FLOOD_SCOPE` with 47 bytes and no zero), each + behind four tails chosen so that the stale bytes would complete the path and + payload or end the name early. Every time: `ILLEGAL_ARG`, nothing queued, no file + changed (the prefs file holds the default scope). Passes on the handlers of + `68e0fcdc` and on the converted ones; a mutation that accepts the 47 byte name + fails it. - `FloodScopeKeyNeedsAllSixteenBytes`: added after a mutation survived (below). The key's effect only shows in the next flood, which carries a transport code with 16 key bytes and none with 15. @@ -810,10 +818,10 @@ bash array: ``` native_companion test_companion_frames PASSED ... (the other 26 suites in 16 environments) PASSED -================ 587 test cases: 587 succeeded ================ +================ 588 test cases: 588 succeeded ================ ``` -577 before the step, 10 new: four in `test_frame_bounds.cpp`, six in +577 before the step, 11 new: five in `test_frame_bounds.cpp`, six in `test_frame_reader.cpp`. ### Builds @@ -890,3 +898,31 @@ Not covered on hardware: the setters with new values, sends on air, `REBOOT`, tail cases (a board cannot be made to hold a chosen tail without sending that frame first, which the host tests do). For those the host tests are the evidence. Only nRF52 ran on hardware. + +### Open findings, not changed in 10c + +Each needs its own fix and proposal; 10c leaves the code as it is. + +1. **`Dispatcher::tryParsePacket()` reads before its length test.** For a route + type with transport codes it reads the 4 code bytes and the path length byte + before it compares with `len`. Reached from `SEND_RAW_PACKET` with frames of 4 + to 6 bytes, and on every received radio packet. The answer is unchanged + (`ILLEGAL_ARG`, the later test fails). Reproduce: apply + `tools/refactoring/companion_asan_probe.patch`, run it with `PROBE_SKIP=1`, and + `companion_asan_probe_summary.py` lists `MyMesh::cmdSendRawPacket` with + `mesh::Dispatcher::tryParsePacket` for code 65, fills `00` and `3f`, lengths 4 + to 6 (see `tools/refactoring/README.md`). +2. **The CLI parser compares prefixes past the terminator.** + `CommonRadioPrefs::handleCommand()`, `MyMesh::handleCommand()` and `isGet()` use + `memcmp()` with prefixes longer than the command text. The result is decided at + the terminator, so no reply changes. Reproduce: the same probe lists + `MyMesh::cmdRunCliCommand` with those three callers for code 66, lengths 3 to 22. +3. **Packet digests under ASan (unchecked).** Built with ASan, the frame sweep's + packet digests differ for codes 2, 3, 26, 27 and 39. Guess, not verified: the encryption pads from an uninitialised stack + buffer that the sweep's stack scrub reaches only without ASan. Reproduce: apply + the probe patch and run `pio test -e native_companion` without a filter; + `FrameSweep.MatchesTheRecordedTranscript` fails; the differences it printed (it + prints at most ten) were all `packets` lines of those codes, with equal `files` + digests. +4. **`SEND_TRACE_PATH`'s upper bound is dead code.** `len - 10 >= MAX_PACKET_PAYLOAD - 5` + needs 189 bytes; a frame has at most 176. diff --git a/test/test_companion_frames/test_frame_bounds.cpp b/test/test_companion_frames/test_frame_bounds.cpp index 23a7cde2072..dccd6589c1c 100644 --- a/test/test_companion_frames/test_frame_bounds.cpp +++ b/test/test_companion_frames/test_frame_bounds.cpp @@ -374,3 +374,35 @@ TEST(FrameBounds, FloodScopeKeyNeedsAllSixteenBytes) { EXPECT_EQ(queued[0][0] & 0x03, key_bytes == 16 ? 0 : 1) << key_bytes << " key bytes"; } } + +// The three handlers that read past the frame before step 10c, each with a frame +// that makes them do it and behind tails that would matter if the bytes were used: +// a previous frame whose bytes complete the announced path and the payload (or end +// the unterminated scope name early). The answer is ILLEGAL_ARG every time, +// nothing is queued for the air, and no file changes (the prefs hold the default +// flood scope). +TEST(FrameBounds, TheReadsPastTheEndThatStep10cRemoved) { + const struct { const char* name; Bytes frame; } CASES[] = { + // path length byte 8: eight 1 byte hashes, the frame holds 2 of them + {"SEND_CHANNEL_DATA", {SEND_CHANNEL_DATA, 0, 8, 7, 8}}, + // path length byte 8 again, the frame holds 4 bytes after it + {"SEND_RAW_DATA", {SEND_RAW_DATA, 8, 1, 2, 3, 4}}, + // 47 bytes without a zero: name and key run together + {"SET_DEFAULT_FLOOD_SCOPE", cat({{SET_DEFAULT_FLOOD_SCOPE}, fill(47, 0x61)})}, + }; + const std::vector tails = { + fill(1, 0x00), fill(1, 0x34), fill(1, 0x61), + {0x61, 0x62, 0x00, 0x34, 0x12, 0x01, 0x02, 0x03}, + }; + Harness h; + for (const auto& c : CASES) { + for (const Bytes& tail : tails) { + prepare(h); + h.send(staleFrame(tail)); + auto files_before = h.fs.files; + EXPECT_EQ(h.reply(c.frame), Bytes({1, ERR_ILLEGAL_ARG})) << c.name << ", tail " << hex(tail); + EXPECT_EQ(h.mesh().outboundTotal(), 0) << c.name << ", tail " << hex(tail); + EXPECT_TRUE(h.fs.files == files_before) << c.name << ", tail " << hex(tail); + } + } +} From 51b0fdc244bc57a1b490a6152b2d8a86b0265fa2 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 09:39:53 +0200 Subject: [PATCH 129/136] docs(refactoring): list step 10c in the step table --- docs/refactoring/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index d9852995f0b..41b0e2efd30 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -52,7 +52,7 @@ The full analysis, with file and line references for every finding, is in | 3 | Drive the radio settings (JSON, CLI, key/value store) from one table | [0003](0003-radio-settings-table.md) | ready for review | | 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4g; the boot sequence and the forwarding checks are the last two) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md), [0008](0008-forward-policy.md) | ready for review | | 9 | One RadioLib adapter per chip family, part one: the receive detection the seven chip classes shared as copies (step 9a; 9b to 9d planned in the note) | [0009](0009-radio-chip-adapter.md) | 9a ready for review | -| 10 | A command table for the companion protocol: a host harness that pins every frame (10a), then one row per command code instead of the if chain (10b); 10c and 10d proposed in the note | [0010](0010-companion-command-table.md) | 10a and 10b ready for review | +| 10 | A command table for the companion protocol: a host harness that pins every frame (10a), one row per command code instead of the if chain (10b), then a bounded frame reader in the handlers instead of hand counted offsets (10c); 10d proposed in the note | [0010](0010-companion-command-table.md) | 10a to 10c ready for review | | 11 | One generic variant target built from a board description, cut A: one shared `radio_new_identity()`; cut B: one FEM RX gain handler for four Heltec boards; cut C: one default `radio_init()` for 31 nRF52 boards (cuts D and E planned in the note) | [0011](0011-variant-target.md) | cuts A, B and C ready for review | Steps are numbered after their design note, so the numbers have gaps: 5 to 8 From 102c5e77e50bfe15b26964b2b8d4110e70b0720e Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 13:02:35 +0200 Subject: [PATCH 130/136] docs(refactoring): record step 9b, one template for the SX126x chip classes --- docs/refactoring/0009-radio-chip-adapter.md | 675 +++++++++++++++++++- 1 file changed, 646 insertions(+), 29 deletions(-) diff --git a/docs/refactoring/0009-radio-chip-adapter.md b/docs/refactoring/0009-radio-chip-adapter.md index dfc5d27794a..d0cf81394b9 100644 --- a/docs/refactoring/0009-radio-chip-adapter.md +++ b/docs/refactoring/0009-radio-chip-adapter.md @@ -1,7 +1,7 @@ # 0009: One RadioLib adapter per chip family, part one: the receive detection -Status: step 9a implemented. Behaviour change: none. Steps 9b to 9d are planned -below and need their own branches. +Status: steps 9a and 9b implemented. Behaviour change: none. Steps 9c and 9d are +planned below and need their own branches. ## Context @@ -274,7 +274,7 @@ links exactly one chip class; the gain is in the source, not in the image. ## Step 9b: one template for SX1262, SX1268 and LLCC68 -Status: designed, not implemented yet. Behaviour change: none intended. +Status: implemented. Behaviour change: none. ### Measured inventory @@ -300,9 +300,12 @@ they differ in two places only, both in or for `std_init()`: 2. In `std_init()`, SX1262 alone has - the `SX126X_USE_REGULATOR_LDO` option, passed as the last argument of both `begin()` calls; SX1268 and LLCC68 call `begin()` with eight arguments, so - RadioLib's default `useRegulatorLDO = false` applies (checked in RadioLib - at the pinned commit 6d89348: `SX1262::begin`, `SX1268::begin` and - `LLCC68::begin` all default it to `false`); + RadioLib's default `useRegulatorLDO = false` applies. At the commit + `platformio.ini` pins (6d8934836678d8894e3d556550475b37dce3e2b6, version + 7.7.1), the eight argument `begin()` of all three ends in + `float tcxoVoltage = ..., bool useRegulatorLDO = false)`: + `src/modules/SX126x/SX1262.h:58`, `src/modules/SX126x/SX1268.h:56`, + `src/modules/LLCC68/LLCC68.h:52`; - the `SX126X_REGISTER_PATCH` block (read register 0x8B5, set bit 0, write it back); - three `MESH_DEBUG_PRINTLN` lines: regulator requested, the retry with the @@ -342,24 +345,24 @@ public: The three chip headers keep their class, now a thin subclass: ```cpp -struct SX1262InitTraits { +struct CustomSX1262Traits { static constexpr bool regulatorOption = true; // honour SX126X_USE_REGULATOR_LDO static constexpr bool registerPatch = true; // honour SX126X_REGISTER_PATCH static constexpr bool initDebug = true; // the three MESH_DEBUG lines static const char* name() { return "SX1262"; } }; -class CustomSX1262 : public CustomSX126x { +class CustomSX1262 : public CustomSX126x { public: CustomSX1262(Module* mod) : CustomSX126x(mod) { } }; ``` -`SX1268InitTraits` and `LLCC68InitTraits` set all three flags to `false`. In +`CustomSX1268Traits` and `CustomLLCC68Traits` set all three flags to `false`. In `std_init()` the flags are plain `if (Traits::flag)` on compile time constants -(C++11, no `if constexpr`; the firmware toolchains do not all build C++17), so -the compiler drops the dead branches: +(C++11, no `if constexpr`: the firmware environments set no `-std` flag, so each +toolchain's default applies), so the compiler drops the dead branches: -- regulator: `bool useRegulatorLDO = Traits::regulatorOption ? : false;` +- regulator: `constexpr bool useRegulatorLDO = Traits::regulatorOption && SX126X_USE_REGULATOR_LDO;` and `begin()` always gets it as the ninth argument. For SX1268 and LLCC68 that is `false`, the value RadioLib's default gave them before. - register patch: the `#ifdef SX126X_REGISTER_PATCH` block stays, its body under @@ -416,20 +419,634 @@ additions are SX1262 only, the collision is a hunk move, not a redesign. This does not make 9b pointless; four of the seven PRs edit three copies identically and would get smaller with it. -### Planned verification - -- Characterization tests first, on the host against the stubs in - `test/test_radio_chips/stubs` (extended to record `begin()` arguments, register - reads and writes and the setter calls): `std_init()` without any option macro - for all three chips, the retry with TCXO 0 V, the failure path; and in a second - suite (`test/test_radio_chips_init`, its own program, so the macros differ - without an ODR clash) with `SX126X_USE_REGULATOR_LDO`, `SX126X_REGISTER_PATCH`, - `MESH_DEBUG` and the other option macros set, pinning per chip that only SX1262 - passes LDO, patches 0x8B5 and prints the three lines. -- Mutation tests, among them each trait flipped for SX1268. -- Build matrix: every environment with SX1268 (`Generic_E22_sx1268_*`, - `meshadventurer` SX1268, `tenstar_c3` SX1268), the five boards that set - `SX126X_REGISTER_PATCH`, the standard 18, STM32WLx; flash and RAM against - 68e0fcdc. No LLCC68 firmware environment exists; LLCC68 is covered by the host - suites only. -- All 17 native environments. +### What changed + +`src/helpers/radiolib/CustomSX126x.h` holds the template as designed above. +`CustomSX1262.h`, `CustomSX1268.h` and `CustomLLCC68.h` are each a traits struct +and a subclass with the constructor. In `src`, 339 lines go and 188 come in. +Callers, the wrappers, `SX126xReset.h` and `CustomSTM32WLx.h` are unchanged. + +### Characterization tests + +Committed first (61d3a4f4), passing unchanged before and after the move: + +- `test/test_radio_chips` gets a second typed suite over the three classes, + built without any option macro: the `begin()` arguments (coding rate 5, sync + word 0x12, preamble 16, TCXO 1.6 V, LDO off), `setCRC(1)` as the only further + call, a passed SPI left alone without `P_LORA_SCLK`, the retry with TCXO 0 V on + -707 and on -706, the error line after a failed retry and after another error + (no retry), and `getRxBoostedGainMode()` reading register 0x8AC. 21 cases. +- `test/test_radio_chips_init` is a separate test program in the same + environment (the classes are header only, so a build with other macro values + must not be linked into the same binary). It sets `MESH_DEBUG`, + `SX126X_USE_REGULATOR_LDO`, `SX126X_REGISTER_PATCH`, the TCXO voltage, `LORA_CR`, + current limit, DIO2, boosted gain, RX/TX enable pins and the SPI pins, and pins + the full ordered call log, the register 0x8B5 content and the debug output per + chip: SX1262 passes LDO, patches 0x8B5 and prints its three lines; SX1268 and + LLCC68 pass `false`, leave 0x8B5 alone and print nothing. 7 cases. + +The stubs in `test/test_radio_chips/stubs/RadioLib.h` record the calls for this: +a `begin()` with RadioLib's parameters and defaults for the SX126x family, a +register map, and a call log; `getStatus()` and `getDeviceErrors()` return +distinct values so the status line is pinned with them. + +All 17 host environments pass, 605 cases, on the test commit 61d3a4f4 and on the +code commit (577 before, 28 new): + +```sh +pio test -e native_radio_chips +``` + +### Mutation tests + +Each mutation was applied to the code commit, `native_radio_chips` run, and the +change reverted. + +| # | Mutation | Failing cases | +|---|----------|---------------| +| M1 | SX1268 `regulatorOption` true | 2, SX1268 only | +| M2 | LLCC68 `registerPatch` true | 2, LLCC68 only | +| M3 | SX1268 `initDebug` true | 2, SX1268 only | +| M4 | SX1262 `regulatorOption` false | 3, SX1262 only | +| M5 | SX1262 `registerPatch` false | 2, SX1262 only | +| M6 | SX1262 `initDebug` false | 3, SX1262 only | +| M7 | no retry on -706 | 3, all three chips | +| M8 | retry keeps the TCXO voltage | 12, all three chips | +| M9 | `setCRC(2)` instead of `setCRC(1)` | 15, all three chips | +| M10 | register patch sets bit 1 instead of bit 0 | 2, SX1262 only | +| M11 | boosted gain check inverted | 3, all three chips | +| M12 | SPI pins passed in the wrong order | 3, all three chips | +| M13 | LLCC68 `regulatorOption` true | 2, LLCC68 only | +| M14 | SX1268 `registerPatch` true | 2, SX1268 only | + +### Build matrix + +Every firmware environment whose `RADIO_CLASS` is `CustomSX1262` (493) or +`CustomSX1268` (12), plus 22 controls that do not use the three classes: all 16 +`CustomSTM32WLx` environments (STM32), `t1000e_repeater` (nRF52, LR1110), +`MeshTracker_X1_repeater` (nRF52, LR2021), `Waveshare_S3_LR1121_repeater` and +`meshnology_w12_repeater` (ESP32, LR1121 and LR2021), `Heltec_v2_repeater` and +`Heltec_v2_repeater_bridge_espnow` (ESP32, SX1276). No environment uses +`CustomLLCC68`. 527 environments, each built in the same worktree path on +`integration` 68e0fcdc and on the code commit 2228a5d6, same toolchain. + +512 build on both sides. The same 15 fail on both sides, none in a radio header: +the `setTxBufferSize` error of the ESP32 `kiss_modem` builds (`Heltec_E213`, +`Heltec_E290`, `Heltec_T190`, `LilyGo_TBeam_1W`, `Station_G3_ESP32`, +`heltec_tracker_v2`), the ESP-NOW callback signature on ESP32-C3 +(`Heltec_ct62`, `Tenstar_C3_sx1262`, `Tenstar_C3_sx1268`, each +`_repeater_bridge_espnow`), image too large (`Station_G2_companion_radio_ble`, +`Station_G3_ESP32_companion_radio_ble`), `D7` undeclared +(`ThinkNode_M5_companion_radio_serial`) and the `EnvironmentSensorManager` link +error (`ThinkNode_M8_repeater`, `_room_server`, `_kiss_modem`). + +RAM is unchanged in all 512. Flash, after minus before, in bytes: + +| Platform | Radio class | Environments | Flash difference (environments) | +|----------|-------------|--------------|---------------------------------| +| ESP32 | SX1262 | 252 | +0 (92), +4 (144), +8 (8), -2 (6), -16 (1), -40 (1) | +| ESP32 | SX1268 | 11 | +0 (2), +4 (9) | +| nRF52 | SX1262 | 202 | +0 (197), +16 (5) | +| RP2040 | SX1262 | 25 | +0 (25) | +| STM32 | STM32WLx (control) | 16 | +0 (16) | +| ESP32, nRF52 | LR1110, LR1121, LR2021, SX1276 (controls) | 6 | +0 (6) | + +Measured for `Heltec_v3_repeater` (+4) with `nm` and `size -A` on both ELF files: +`.flash.text` grows by 4 bytes, and the only code symbol that changes size is +`_GLOBAL__sub_I_board`, the static initializer of the variant's globals, from 0x194 +to 0x198 bytes, where the radio object is now built through one more base class +constructor. Every chip and wrapper method has the same size as before (compared +with the template name folded into `CustomSX1262`). The -2 builds are the ESP32-C3 +and C6 ones; the outliers (-40, -16, +16) were not examined one by one. + +The image hashes differ between the two sides in all 512, but they also differ +for every control environment, whose code the step does not touch, at equal +size, so the builds are not byte reproducible across commits and the hashes +prove nothing here. Why was not examined. + +
+Flash and RAM per environment (512) + +| Environment | Platform | Radio class | Flash before | Flash after | Flash | RAM | +|---|---|---|---|---|---|---| +| Ebyte_EoRa-S3_Repeater | ESP32 | SX1262 | 1108433 | 1108433 | +0 | +0 | +| Ebyte_EoRa-S3_companion_radio_ble | ESP32 | SX1262 | 1215629 | 1215629 | +0 | +0 | +| Ebyte_EoRa-S3_companion_radio_usb | ESP32 | SX1262 | 590197 | 590197 | +0 | +0 | +| Ebyte_EoRa-S3_kiss_modem | ESP32 | SX1262 | 507821 | 507821 | +0 | +0 | +| Ebyte_EoRa-S3_room_server | ESP32 | SX1262 | 1103745 | 1103745 | +0 | +0 | +| Ebyte_EoRa-S3_terminal_chat | ESP32 | SX1262 | 535737 | 535737 | +0 | +0 | +| GAT562_30S_Mesh_Kit_companion_radio_ble | nRF52 | SX1262 | 436052 | 436052 | +0 | +0 | +| GAT562_30S_Mesh_Kit_companion_radio_usb | nRF52 | SX1262 | 427028 | 427028 | +0 | +0 | +| GAT562_30S_Mesh_Kit_kiss_modem | nRF52 | SX1262 | 341280 | 341280 | +0 | +0 | +| GAT562_30S_Mesh_Kit_repeater | nRF52 | SX1262 | 411568 | 411568 | +0 | +0 | +| GAT562_30S_Mesh_Kit_room_server | nRF52 | SX1262 | 404656 | 404656 | +0 | +0 | +| GAT562_Mesh_EVB_Pro_kiss_modem | nRF52 | SX1262 | 310916 | 310916 | +0 | +0 | +| GAT562_Mesh_EVB_Pro_repeater | nRF52 | SX1262 | 365764 | 365764 | +0 | +0 | +| GAT562_Mesh_EVB_Pro_room_server | nRF52 | SX1262 | 359260 | 359260 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_companion_radio_ble | nRF52 | SX1262 | 403224 | 403224 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_companion_radio_usb | nRF52 | SX1262 | 394216 | 394216 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_kiss_modem | nRF52 | SX1262 | 310900 | 310900 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_repeater | nRF52 | SX1262 | 381276 | 381276 | +0 | +0 | +| GAT562_Mesh_Tracker_Pro_room_server | nRF52 | SX1262 | 374364 | 374364 | +0 | +0 | +| GAT562_Mesh_Watch13_companion_radio_ble | nRF52 | SX1262 | 399608 | 399608 | +0 | +0 | +| GAT562_Mesh_Watch13_kiss_modem | nRF52 | SX1262 | 304048 | 304048 | +0 | +0 | +| Generic_E22_sx1262_repeater | ESP32 | SX1262 | 1143573 | 1143573 | +0 | +0 | +| Generic_E22_sx1262_repeater_bridge_espnow | ESP32 | SX1262 | 1154277 | 1154277 | +0 | +0 | +| Generic_E22_sx1268_repeater | ESP32 | SX1268 | 1143357 | 1143361 | +4 | +0 | +| Generic_E22_sx1268_repeater_bridge_espnow | ESP32 | SX1268 | 1154053 | 1154057 | +4 | +0 | +| Heltec_E213_companion_radio_ble | ESP32 | SX1262 | 1272953 | 1272957 | +4 | +0 | +| Heltec_E213_companion_radio_usb | ESP32 | SX1262 | 668529 | 668533 | +4 | +0 | +| Heltec_E213_repeater | ESP32 | SX1262 | 1162229 | 1162233 | +4 | +0 | +| Heltec_E213_repeater_bridge_espnow | ESP32 | SX1262 | 1172673 | 1172677 | +4 | +0 | +| Heltec_E213_room_server | ESP32 | SX1262 | 1157473 | 1157477 | +4 | +0 | +| Heltec_E290_companion_ble | ESP32 | SX1262 | 1270781 | 1270789 | +8 | +0 | +| Heltec_E290_companion_usb | ESP32 | SX1262 | 666265 | 666269 | +4 | +0 | +| Heltec_E290_repeater | ESP32 | SX1262 | 1160181 | 1160185 | +4 | +0 | +| Heltec_E290_repeater_bridge_espnow | ESP32 | SX1262 | 1170609 | 1170613 | +4 | +0 | +| Heltec_E290_room_server | ESP32 | SX1262 | 1155397 | 1155401 | +4 | +0 | +| Heltec_T190_companion_radio_ble_ | ESP32 | SX1262 | 1250085 | 1250085 | +0 | +0 | +| Heltec_T190_companion_radio_usb_ | ESP32 | SX1262 | 646181 | 646181 | +0 | +0 | +| Heltec_T190_repeater_ | ESP32 | SX1262 | 1139397 | 1139397 | +0 | +0 | +| Heltec_T190_repeater_bridge_espnow_ | ESP32 | SX1262 | 1149833 | 1149833 | +0 | +0 | +| Heltec_T190_room_server_ | ESP32 | SX1262 | 1134653 | 1134653 | +0 | +0 | +| Heltec_WSL3_companion_radio_ble | ESP32 | SX1262 | 1647705 | 1647709 | +4 | +0 | +| Heltec_WSL3_companion_radio_usb | ESP32 | SX1262 | 636669 | 636673 | +4 | +0 | +| Heltec_WSL3_companion_radio_wifi | ESP32 | SX1262 | 1086181 | 1086185 | +4 | +0 | +| Heltec_WSL3_repeater | ESP32 | SX1262 | 1161333 | 1161337 | +4 | +0 | +| Heltec_WSL3_repeater_bridge_espnow | ESP32 | SX1262 | 1171761 | 1171765 | +4 | +0 | +| Heltec_WSL3_repeater_bridge_rs232 | ESP32 | SX1262 | 1163781 | 1163785 | +4 | +0 | +| Heltec_WSL3_room_server | ESP32 | SX1262 | 1156817 | 1156821 | +4 | +0 | +| Heltec_WSL3_sensor | ESP32 | SX1262 | 1168229 | 1168233 | +4 | +0 | +| Heltec_Wireless_Paper_companion_radio_ble | ESP32 | SX1262 | 1614793 | 1614793 | +0 | +0 | +| Heltec_Wireless_Paper_companion_radio_usb | ESP32 | SX1262 | 625189 | 625189 | +0 | +0 | +| Heltec_Wireless_Paper_kiss_modem | ESP32 | SX1262 | 517461 | 517461 | +0 | +0 | +| Heltec_Wireless_Paper_repeater | ESP32 | SX1262 | 1121873 | 1121873 | +0 | +0 | +| Heltec_Wireless_Paper_repeater_bridge_espnow | ESP32 | SX1262 | 1132325 | 1132325 | +0 | +0 | +| Heltec_Wireless_Paper_room_server | ESP32 | SX1262 | 1117125 | 1117125 | +0 | +0 | +| Heltec_Wireless_Tracker_companion_radio_ble | ESP32 | SX1262 | 1264633 | 1264637 | +4 | +0 | +| Heltec_Wireless_Tracker_companion_radio_usb | ESP32 | SX1262 | 661373 | 661377 | +4 | +0 | +| Heltec_Wireless_Tracker_kiss_modem | ESP32 | SX1262 | 526705 | 526709 | +4 | +0 | +| Heltec_Wireless_Tracker_repeater | ESP32 | SX1262 | 1157617 | 1157621 | +4 | +0 | +| Heltec_Wireless_Tracker_repeater_bridge_espnow | ESP32 | SX1262 | 1168105 | 1168109 | +4 | +0 | +| Heltec_Wireless_Tracker_room_server | ESP32 | SX1262 | 1152889 | 1152893 | +4 | +0 | +| Heltec_ct62_companion_radio_ble | ESP32 | SX1262 | 1275586 | 1275586 | +0 | +0 | +| Heltec_ct62_companion_radio_usb | ESP32 | SX1262 | 563516 | 563516 | +0 | +0 | +| Heltec_ct62_kiss_modem | ESP32 | SX1262 | 497028 | 497028 | +0 | +0 | +| Heltec_ct62_repeater | ESP32 | SX1262 | 1151874 | 1151874 | +0 | +0 | +| Heltec_ct62_sensor | ESP32 | SX1262 | 1157740 | 1157740 | +0 | +0 | +| Heltec_mesh_solar_companion_radio_ble | nRF52 | SX1262 | 374060 | 374060 | +0 | +0 | +| Heltec_mesh_solar_companion_radio_usb | nRF52 | SX1262 | 369060 | 369060 | +0 | +0 | +| Heltec_mesh_solar_kiss_modem | nRF52 | SX1262 | 309024 | 309024 | +0 | +0 | +| Heltec_mesh_solar_repeater | nRF52 | SX1262 | 365600 | 365600 | +0 | +0 | +| Heltec_mesh_solar_room_server | nRF52 | SX1262 | 359072 | 359072 | +0 | +0 | +| Heltec_t096_companion_radio_ble | nRF52 | SX1262 | 433556 | 433556 | +0 | +0 | +| Heltec_t096_companion_radio_usb | nRF52 | SX1262 | 426820 | 426820 | +0 | +0 | +| Heltec_t096_kiss_modem | nRF52 | SX1262 | 340776 | 340776 | +0 | +0 | +| Heltec_t096_repeater | nRF52 | SX1262 | 414464 | 414464 | +0 | +0 | +| Heltec_t096_repeater_bridge_rs232 | nRF52 | SX1262 | 416736 | 416736 | +0 | +0 | +| Heltec_t096_room_server | nRF52 | SX1262 | 407584 | 407584 | +0 | +0 | +| Heltec_t096_sensor | nRF52 | SX1262 | 411348 | 411348 | +0 | +0 | +| Heltec_t114_companion_radio_ble | nRF52 | SX1262 | 417624 | 417624 | +0 | +0 | +| Heltec_t114_companion_radio_usb | nRF52 | SX1262 | 410888 | 410888 | +0 | +0 | +| Heltec_t114_kiss_modem | nRF52 | SX1262 | 311268 | 311268 | +0 | +0 | +| Heltec_t114_repeater | nRF52 | SX1262 | 398460 | 398460 | +0 | +0 | +| Heltec_t114_repeater_bridge_rs232 | nRF52 | SX1262 | 368532 | 368532 | +0 | +0 | +| Heltec_t114_room_server | nRF52 | SX1262 | 391580 | 391580 | +0 | +0 | +| Heltec_t114_without_display_companion_radio_ble | nRF52 | SX1262 | 391872 | 391872 | +0 | +0 | +| Heltec_t114_without_display_companion_radio_usb | nRF52 | SX1262 | 385040 | 385040 | +0 | +0 | +| Heltec_t114_without_display_repeater | nRF52 | SX1262 | 366260 | 366260 | +0 | +0 | +| Heltec_t114_without_display_repeater_bridge_rs232 | nRF52 | SX1262 | 368532 | 368532 | +0 | +0 | +| Heltec_t114_without_display_room_server | nRF52 | SX1262 | 359740 | 359740 | +0 | +0 | +| Heltec_t1_companion_radio_ble | nRF52 | SX1262 | 435056 | 435056 | +0 | +0 | +| Heltec_t1_companion_radio_usb | nRF52 | SX1262 | 428352 | 428352 | +0 | +0 | +| Heltec_t1_kiss_modem | nRF52 | SX1262 | 339884 | 339884 | +0 | +0 | +| Heltec_t1_repeater | nRF52 | SX1262 | 413508 | 413508 | +0 | +0 | +| Heltec_t1_room_server | nRF52 | SX1262 | 406644 | 406644 | +0 | +0 | +| Heltec_tower_v2_companion_radio_ble | nRF52 | SX1262 | 334924 | 334924 | +0 | +0 | +| Heltec_tower_v2_companion_radio_usb | nRF52 | SX1262 | 328084 | 328084 | +0 | +0 | +| Heltec_tower_v2_kiss_modem | nRF52 | SX1262 | 251864 | 251864 | +0 | +0 | +| Heltec_tower_v2_repeater | nRF52 | SX1262 | 309528 | 309528 | +0 | +0 | +| Heltec_tower_v2_room_server | nRF52 | SX1262 | 303000 | 303000 | +0 | +0 | +| Heltec_v2_repeater | ESP32 | SX1276 | 1151913 | 1151913 | +0 | +0 | +| Heltec_v2_repeater_bridge_espnow | ESP32 | SX1276 | 1162757 | 1162757 | +0 | +0 | +| Heltec_v3_companion_radio_ble | ESP32 | SX1262 | 1670229 | 1670233 | +4 | +0 | +| Heltec_v3_companion_radio_usb | ESP32 | SX1262 | 656829 | 656833 | +4 | +0 | +| Heltec_v3_companion_radio_wifi | ESP32 | SX1262 | 1106381 | 1106385 | +4 | +0 | +| Heltec_v3_kiss_modem | ESP32 | SX1262 | 581249 | 581253 | +4 | +0 | +| Heltec_v3_repeater | ESP32 | SX1262 | 1175021 | 1175025 | +4 | +0 | +| Heltec_v3_repeater_bridge_espnow | ESP32 | SX1262 | 1185489 | 1185493 | +4 | +0 | +| Heltec_v3_repeater_bridge_rs232 | ESP32 | SX1262 | 1177489 | 1177493 | +4 | +0 | +| Heltec_v3_room_server | ESP32 | SX1262 | 1170169 | 1170173 | +4 | +0 | +| Heltec_v3_sensor | ESP32 | SX1262 | 1181557 | 1181561 | +4 | +0 | +| Heltec_v3_terminal_chat | ESP32 | SX1262 | 602377 | 602381 | +4 | +0 | +| KeepteenLT1_companion_radio_ble | nRF52 | SX1262 | 352656 | 352672 | +16 | +0 | +| KeepteenLT1_companion_radio_usb | nRF52 | SX1262 | 334760 | 334760 | +0 | +0 | +| KeepteenLT1_kiss_modem | nRF52 | SX1262 | 248868 | 248868 | +0 | +0 | +| KeepteenLT1_repeater | nRF52 | SX1262 | 322028 | 322028 | +0 | +0 | +| KeepteenLT1_room_server | nRF52 | SX1262 | 315108 | 315108 | +0 | +0 | +| LilyGo_T-Echo-Lite_companion_radio_ble | nRF52 | SX1262 | 356456 | 356456 | +0 | +0 | +| LilyGo_T-Echo-Lite_companion_radio_usb | nRF52 | SX1262 | 351552 | 351552 | +0 | +0 | +| LilyGo_T-Echo-Lite_kiss_modem | nRF52 | SX1262 | 276892 | 276892 | +0 | +0 | +| LilyGo_T-Echo-Lite_non_shell_companion_radio_ble | nRF52 | SX1262 | 315544 | 315544 | +0 | +0 | +| LilyGo_T-Echo-Lite_non_shell_companion_radio_usb | nRF52 | SX1262 | 310416 | 310416 | +0 | +0 | +| LilyGo_T-Echo-Lite_repeater | nRF52 | SX1262 | 335772 | 335772 | +0 | +0 | +| LilyGo_T-Echo-Lite_room_server | nRF52 | SX1262 | 328964 | 328964 | +0 | +0 | +| LilyGo_T-Echo_Card_companion_radio_ble | nRF52 | SX1262 | 349248 | 349248 | +0 | +0 | +| LilyGo_T-Echo_Card_companion_radio_usb | nRF52 | SX1262 | 343488 | 343488 | +0 | +0 | +| LilyGo_T-Echo_Card_kiss_modem | nRF52 | SX1262 | 261536 | 261536 | +0 | +0 | +| LilyGo_T-Echo_Card_repeater | nRF52 | SX1262 | 322368 | 322368 | +0 | +0 | +| LilyGo_T-Echo_Card_room_server | nRF52 | SX1262 | 314112 | 314112 | +0 | +0 | +| LilyGo_T-Echo_companion_radio_ble | nRF52 | SX1262 | 375680 | 375680 | +0 | +0 | +| LilyGo_T-Echo_companion_radio_usb | nRF52 | SX1262 | 368288 | 368288 | +0 | +0 | +| LilyGo_T-Echo_kiss_modem | nRF52 | SX1262 | 291496 | 291496 | +0 | +0 | +| LilyGo_T-Echo_repeater | nRF52 | SX1262 | 350096 | 350096 | +0 | +0 | +| LilyGo_T-Echo_room_server | nRF52 | SX1262 | 343288 | 343288 | +0 | +0 | +| LilyGo_T3S3_sx1262_companion_radio_ble | ESP32 | SX1262 | 1215677 | 1215677 | +0 | +0 | +| LilyGo_T3S3_sx1262_companion_radio_usb | ESP32 | SX1262 | 590245 | 590245 | +0 | +0 | +| LilyGo_T3S3_sx1262_kiss_modem | ESP32 | SX1262 | 507869 | 507869 | +0 | +0 | +| LilyGo_T3S3_sx1262_repeater | ESP32 | SX1262 | 1108533 | 1108533 | +0 | +0 | +| LilyGo_T3S3_sx1262_repeater_bridge_espnow | ESP32 | SX1262 | 1118825 | 1118825 | +0 | +0 | +| LilyGo_T3S3_sx1262_room_server | ESP32 | SX1262 | 1103801 | 1103801 | +0 | +0 | +| LilyGo_T3S3_sx1262_terminal_chat | ESP32 | SX1262 | 535785 | 535785 | +0 | +0 | +| LilyGo_TBeam_1W_companion_radio_ble | ESP32 | SX1262 | 1266201 | 1266205 | +4 | +0 | +| LilyGo_TBeam_1W_companion_radio_usb | ESP32 | SX1262 | 641217 | 641221 | +4 | +0 | +| LilyGo_TBeam_1W_companion_radio_wifi | ESP32 | SX1262 | 1087349 | 1087353 | +4 | +0 | +| LilyGo_TBeam_1W_repeater | ESP32 | SX1262 | 1154969 | 1154973 | +4 | +0 | +| LilyGo_TBeam_1W_repeater_bridge_espnow | ESP32 | SX1262 | 1165457 | 1165461 | +4 | +0 | +| LilyGo_TBeam_1W_room_server | ESP32 | SX1262 | 1150137 | 1150141 | +4 | +0 | +| LilyGo_TDeck_companion_radio_ble | ESP32 | SX1262 | 1251765 | 1251769 | +4 | +0 | +| LilyGo_TDeck_companion_radio_usb | ESP32 | SX1262 | 648409 | 648413 | +4 | +0 | +| LilyGo_TDeck_kiss_modem | ESP32 | SX1262 | 563321 | 563325 | +4 | +0 | +| LilyGo_TDeck_repeater | ESP32 | SX1262 | 1144729 | 1144733 | +4 | +0 | +| LilyGo_TETH_Elite_sx1262_companion_radio_ble | ESP32 | SX1262 | 1177209 | 1177209 | +0 | +0 | +| LilyGo_TETH_Elite_sx1262_companion_radio_usb | ESP32 | SX1262 | 552553 | 552553 | +0 | +0 | +| LilyGo_TETH_Elite_sx1262_kiss_modem | ESP32 | SX1262 | 489269 | 489269 | +0 | +0 | +| LilyGo_TETH_Elite_sx1262_repeater | ESP32 | SX1262 | 1076289 | 1076289 | +0 | +0 | +| LilyGo_TETH_Elite_sx1262_room_server | ESP32 | SX1262 | 1071653 | 1071653 | +0 | +0 | +| LilyGo_T_Impulse_Plus_companion_radio_ble | nRF52 | SX1262 | 405988 | 406004 | +16 | +0 | +| LilyGo_T_Impulse_Plus_kiss_modem | nRF52 | SX1262 | 311364 | 311364 | +0 | +0 | +| LilyGo_T_Impulse_Plus_repeater | nRF52 | SX1262 | 366508 | 366508 | +0 | +0 | +| LilyGo_Tlora_C6_companion_radio_ble_ | ESP32 | SX1262 | 1444915 | 1444915 | +0 | +0 | +| LilyGo_Tlora_C6_kiss_modem | ESP32 | SX1262 | 472809 | 472809 | +0 | +0 | +| LilyGo_Tlora_C6_repeater_ | ESP32 | SX1262 | 542449 | 542447 | -2 | +0 | +| LilyGo_Tlora_C6_room_server_ | ESP32 | SX1262 | 536981 | 536981 | +0 | +0 | +| M5Stack_Unit_C6L_companion_radio_ble | ESP32 | SX1262 | 1452475 | 1452475 | +0 | +0 | +| M5Stack_Unit_C6L_companion_radio_usb | ESP32 | SX1262 | 544593 | 544593 | +0 | +0 | +| M5Stack_Unit_C6L_kiss_modem | ESP32 | SX1262 | 480175 | 480175 | +0 | +0 | +| M5Stack_Unit_C6L_repeater | ESP32 | SX1262 | 552823 | 552821 | -2 | +0 | +| M5Stack_Unit_C6L_room_server | ESP32 | SX1262 | 547355 | 547355 | +0 | +0 | +| MeshTracker_X1_repeater | nRF52 | LR2021 | 301152 | 301152 | +0 | +0 | +| Mesh_pocket_companion_radio_ble | nRF52 | SX1262 | 345036 | 345036 | +0 | +0 | +| Mesh_pocket_companion_radio_usb | nRF52 | SX1262 | 338220 | 338220 | +0 | +0 | +| Mesh_pocket_kiss_modem | nRF52 | SX1262 | 268408 | 268408 | +0 | +0 | +| Mesh_pocket_repeater | nRF52 | SX1262 | 326840 | 326840 | +0 | +0 | +| Mesh_pocket_room_server | nRF52 | SX1262 | 320096 | 320096 | +0 | +0 | +| Meshadventurer_sx1262_companion_radio_ble | ESP32 | SX1262 | 1440789 | 1440797 | +8 | +0 | +| Meshadventurer_sx1262_companion_radio_usb | ESP32 | SX1262 | 601921 | 601925 | +4 | +0 | +| Meshadventurer_sx1262_kiss_modem | ESP32 | SX1262 | 529925 | 529933 | +8 | +0 | +| Meshadventurer_sx1262_repeater | ESP32 | SX1262 | 1162093 | 1162097 | +4 | +0 | +| Meshadventurer_sx1262_repeater_bridge_espnow | ESP32 | SX1262 | 1172877 | 1172881 | +4 | +0 | +| Meshadventurer_sx1262_room_server | ESP32 | SX1262 | 1157349 | 1157353 | +4 | +0 | +| Meshadventurer_sx1262_terminal_chat | ESP32 | SX1262 | 558453 | 558457 | +4 | +0 | +| Meshadventurer_sx1268_companion_radio_ble | ESP32 | SX1268 | 1440541 | 1440545 | +4 | +0 | +| Meshadventurer_sx1268_companion_radio_usb | ESP32 | SX1268 | 601697 | 601701 | +4 | +0 | +| Meshadventurer_sx1268_kiss_modem | ESP32 | SX1268 | 529697 | 529701 | +4 | +0 | +| Meshadventurer_sx1268_repeater | ESP32 | SX1268 | 1161865 | 1161869 | +4 | +0 | +| Meshadventurer_sx1268_repeater_bridge_espnow | ESP32 | SX1268 | 1172657 | 1172661 | +4 | +0 | +| Meshadventurer_sx1268_room_server | ESP32 | SX1268 | 1157129 | 1157133 | +4 | +0 | +| Meshadventurer_sx1268_terminal_chat | ESP32 | SX1268 | 558229 | 558233 | +4 | +0 | +| Meshimi_companion_radio_ble_ | ESP32 | SX1262 | 1444987 | 1444987 | +0 | +0 | +| Meshimi_repeater_ | ESP32 | SX1262 | 542537 | 542535 | -2 | +0 | +| Meshtiny_companion_radio_ble | nRF52 | SX1262 | 337552 | 337568 | +16 | +0 | +| Meshtiny_companion_radio_usb | nRF52 | SX1262 | 330152 | 330168 | +16 | +0 | +| Meshtiny_kiss_modem | nRF52 | SX1262 | 243268 | 243268 | +0 | +0 | +| Nano_G2_Ultra_companion_radio_ble | nRF52 | SX1262 | 345724 | 345724 | +0 | +0 | +| Nano_G2_Ultra_companion_radio_usb | nRF52 | SX1262 | 338316 | 338316 | +0 | +0 | +| Nano_G2_Ultra_kiss_modem | nRF52 | SX1262 | 249476 | 249476 | +0 | +0 | +| Nano_G2_Ultra_repeater | nRF52 | SX1262 | 319500 | 319500 | +0 | +0 | +| Nano_G2_Ultra_room_server | nRF52 | SX1262 | 312580 | 312580 | +0 | +0 | +| PicoW_companion_radio | RP2040 | SX1262 | 665800 | 665800 | +0 | +0 | +| PicoW_companion_radio_ble | RP2040 | SX1262 | 633604 | 633604 | +0 | +0 | +| PicoW_companion_radio_usb | RP2040 | SX1262 | 495284 | 495284 | +0 | +0 | +| PicoW_companion_radio_wifi | RP2040 | SX1262 | 528732 | 528732 | +0 | +0 | +| PicoW_kiss_modem | RP2040 | SX1262 | 439572 | 439572 | +0 | +0 | +| PicoW_repeater | RP2040 | SX1262 | 492668 | 492668 | +0 | +0 | +| PicoW_room_server | RP2040 | SX1262 | 488612 | 488612 | +0 | +0 | +| PicoW_terminal_chat | RP2040 | SX1262 | 469148 | 469148 | +0 | +0 | +| ProMicro_companion_radio_ble | nRF52 | SX1262 | 367860 | 367860 | +0 | +0 | +| ProMicro_companion_radio_usb | nRF52 | SX1262 | 358836 | 358836 | +0 | +0 | +| ProMicro_kiss_modem | nRF52 | SX1262 | 273400 | 273400 | +0 | +0 | +| ProMicro_repeater | nRF52 | SX1262 | 346240 | 346240 | +0 | +0 | +| ProMicro_repeater_bridge_rs232_serial1 | nRF52 | SX1262 | 342544 | 342544 | +0 | +0 | +| ProMicro_room_server | nRF52 | SX1262 | 339288 | 339288 | +0 | +0 | +| ProMicro_sensor | nRF52 | SX1262 | 342956 | 342956 | +0 | +0 | +| ProMicro_terminal_chat | nRF52 | SX1262 | 298384 | 298384 | +0 | +0 | +| R1Neo_companion_radio_ble | nRF52 | SX1262 | 385336 | 385336 | +0 | +0 | +| R1Neo_companion_radio_usb | nRF52 | SX1262 | 377928 | 377928 | +0 | +0 | +| R1Neo_kiss_modem | nRF52 | SX1262 | 312020 | 312020 | +0 | +0 | +| R1Neo_repeater | nRF52 | SX1262 | 368916 | 368916 | +0 | +0 | +| R1Neo_room_server | nRF52 | SX1262 | 361420 | 361420 | +0 | +0 | +| R1Neo_sensor | nRF52 | SX1262 | 365104 | 365104 | +0 | +0 | +| R1Neo_terminal_chat | nRF52 | SX1262 | 334572 | 334572 | +0 | +0 | +| RAK_11310_companion_radio_usb | RP2040 | SX1262 | 238796 | 238796 | +0 | +0 | +| RAK_11310_kiss_modem | RP2040 | SX1262 | 183084 | 183084 | +0 | +0 | +| RAK_11310_repeater | RP2040 | SX1262 | 236172 | 236172 | +0 | +0 | +| RAK_11310_repeater_bridge_rs232 | RP2040 | SX1262 | 240664 | 240664 | +0 | +0 | +| RAK_11310_room_server | RP2040 | SX1262 | 232124 | 232124 | +0 | +0 | +| RAK_11310_terminal_chat | RP2040 | SX1262 | 212668 | 212668 | +0 | +0 | +| RAK_3112_companion_radio_ble | ESP32 | SX1262 | 1653089 | 1653093 | +4 | +0 | +| RAK_3112_companion_radio_usb | ESP32 | SX1262 | 642157 | 642161 | +4 | +0 | +| RAK_3112_companion_radio_wifi | ESP32 | SX1262 | 1091457 | 1091461 | +4 | +0 | +| RAK_3112_kiss_modem | ESP32 | SX1262 | 586069 | 586073 | +4 | +0 | +| RAK_3112_repeater | ESP32 | SX1262 | 1165825 | 1165829 | +4 | +0 | +| RAK_3112_repeater_bridge_espnow | ESP32 | SX1262 | 1176329 | 1176333 | +4 | +0 | +| RAK_3112_repeater_bridge_rs232 | ESP32 | SX1262 | 1168325 | 1168329 | +4 | +0 | +| RAK_3112_room_server | ESP32 | SX1262 | 1161381 | 1161341 | -40 | +0 | +| RAK_3112_sensor | ESP32 | SX1262 | 1172769 | 1172777 | +8 | +0 | +| RAK_3112_terminal_chat | ESP32 | SX1262 | 607189 | 607193 | +4 | +0 | +| RAK_3401_companion_radio_ble | nRF52 | SX1262 | 432956 | 432956 | +0 | +0 | +| RAK_3401_companion_radio_usb | nRF52 | SX1262 | 423948 | 423948 | +0 | +0 | +| RAK_3401_kiss_modem | nRF52 | SX1262 | 340928 | 340928 | +0 | +0 | +| RAK_3401_repeater | nRF52 | SX1262 | 411040 | 411040 | +0 | +0 | +| RAK_3401_room_server | nRF52 | SX1262 | 404192 | 404192 | +0 | +0 | +| RAK_3401_sensor | nRF52 | SX1262 | 407888 | 407888 | +0 | +0 | +| RAK_3401_terminal_chat | nRF52 | SX1262 | 363424 | 363424 | +0 | +0 | +| RAK_3x72_companion_radio_usb | STM32 | STM32WLx | 197544 | 197544 | +0 | +0 | +| RAK_3x72_kiss_modem | STM32 | STM32WLx | 143164 | 143164 | +0 | +0 | +| RAK_3x72_repeater | STM32 | STM32WLx | 196776 | 196776 | +0 | +0 | +| RAK_3x72_sensor | STM32 | STM32WLx | 196376 | 196376 | +0 | +0 | +| RAK_4631_companion_radio_ble | nRF52 | SX1262 | 464212 | 464212 | +0 | +0 | +| RAK_4631_companion_radio_ethernet | nRF52 | SX1262 | 469752 | 469752 | +0 | +0 | +| RAK_4631_companion_radio_usb | nRF52 | SX1262 | 455188 | 455188 | +0 | +0 | +| RAK_4631_kiss_modem | nRF52 | SX1262 | 371976 | 371976 | +0 | +0 | +| RAK_4631_repeater | nRF52 | SX1262 | 441944 | 441944 | +0 | +0 | +| RAK_4631_repeater_bridge_rs232_serial1 | nRF52 | SX1262 | 408052 | 408052 | +0 | +0 | +| RAK_4631_repeater_bridge_rs232_serial2 | nRF52 | SX1262 | 408052 | 408052 | +0 | +0 | +| RAK_4631_repeater_ethernet | nRF52 | SX1262 | 459588 | 459588 | +0 | +0 | +| RAK_4631_room_server | nRF52 | SX1262 | 435096 | 435096 | +0 | +0 | +| RAK_4631_room_server_ethernet | nRF52 | SX1262 | 452732 | 452732 | +0 | +0 | +| RAK_4631_sensor | nRF52 | SX1262 | 438776 | 438776 | +0 | +0 | +| RAK_4631_terminal_chat | nRF52 | SX1262 | 394376 | 394376 | +0 | +0 | +| RAK_WisMesh_Tag_companion_radio_ble | nRF52 | SX1262 | 385640 | 385640 | +0 | +0 | +| RAK_WisMesh_Tag_companion_radio_usb | nRF52 | SX1262 | 378216 | 378216 | +0 | +0 | +| RAK_WisMesh_Tag_kiss_modem | nRF52 | SX1262 | 312180 | 312180 | +0 | +0 | +| RAK_WisMesh_Tag_repeater | nRF52 | SX1262 | 369076 | 369076 | +0 | +0 | +| RAK_WisMesh_Tag_room_server | nRF52 | SX1262 | 361548 | 361548 | +0 | +0 | +| RAK_WisMesh_Tag_sensor | nRF52 | SX1262 | 365232 | 365232 | +0 | +0 | +| SenseCap_Solar_companion_radio_ble | nRF52 | SX1262 | 374080 | 374080 | +0 | +0 | +| SenseCap_Solar_companion_radio_usb | nRF52 | SX1262 | 368944 | 368944 | +0 | +0 | +| SenseCap_Solar_kiss_modem | nRF52 | SX1262 | 311028 | 311028 | +0 | +0 | +| SenseCap_Solar_repeater | nRF52 | SX1262 | 366108 | 366108 | +0 | +0 | +| SenseCap_Solar_room_server | nRF52 | SX1262 | 359372 | 359372 | +0 | +0 | +| Station_G2_companion_radio_usb | ESP32 | SX1262 | 714561 | 714565 | +4 | +0 | +| Station_G2_companion_radio_wifi | ESP32 | SX1262 | 1139593 | 1139597 | +4 | +0 | +| Station_G2_kiss_modem | ESP32 | SX1262 | 598645 | 598649 | +4 | +0 | +| Station_G2_logging_repeater | ESP32 | SX1262 | 1207905 | 1207909 | +4 | +0 | +| Station_G2_logging_repeater_bridge_espnow | ESP32 | SX1262 | 1218361 | 1218365 | +4 | +0 | +| Station_G2_repeater | ESP32 | SX1262 | 1207881 | 1207885 | +4 | +0 | +| Station_G2_repeater_bridge_espnow | ESP32 | SX1262 | 1218337 | 1218341 | +4 | +0 | +| Station_G2_room_server | ESP32 | SX1262 | 1203065 | 1203069 | +4 | +0 | +| Station_G3_ESP32_companion_radio_usb | ESP32 | SX1262 | 716137 | 716141 | +4 | +0 | +| Station_G3_ESP32_companion_radio_wifi | ESP32 | SX1262 | 1141217 | 1141221 | +4 | +0 | +| Station_G3_ESP32_logging_repeater | ESP32 | SX1262 | 1209505 | 1209509 | +4 | +0 | +| Station_G3_ESP32_repeater | ESP32 | SX1262 | 1209465 | 1209469 | +4 | +0 | +| Station_G3_ESP32_room_server | ESP32 | SX1262 | 1204677 | 1204681 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_companion_radio_ble | ESP32 | SX1262 | 1262721 | 1262725 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_companion_radio_wifi | ESP32 | SX1262 | 1084233 | 1084241 | +8 | +0 | +| T_Beam_S3_Supreme_SX1262_kiss_modem | ESP32 | SX1262 | 570317 | 570321 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_repeater | ESP32 | SX1262 | 1155513 | 1155517 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_repeater_bridge_espnow | ESP32 | SX1262 | 1165997 | 1166001 | +4 | +0 | +| T_Beam_S3_Supreme_SX1262_room_server | ESP32 | SX1262 | 1150713 | 1150717 | +4 | +0 | +| Tbeam_SX1262_companion_radio_ble | ESP32 | SX1262 | 1483501 | 1483501 | +0 | +0 | +| Tbeam_SX1262_kiss_modem | ESP32 | SX1262 | 567437 | 567437 | +0 | +0 | +| Tbeam_SX1262_repeater | ESP32 | SX1262 | 1205213 | 1205213 | +0 | +0 | +| Tbeam_SX1262_repeater_bridge_espnow | ESP32 | SX1262 | 1216165 | 1216165 | +0 | +0 | +| Tbeam_SX1262_room_server | ESP32 | SX1262 | 1200065 | 1200065 | +0 | +0 | +| Tenstar_C3_sx1262_kiss_modem | ESP32 | SX1262 | 497480 | 497480 | +0 | +0 | +| Tenstar_C3_sx1262_repeater | ESP32 | SX1262 | 1152392 | 1152392 | +0 | +0 | +| Tenstar_C3_sx1268_kiss_modem | ESP32 | SX1268 | 497412 | 497412 | +0 | +0 | +| Tenstar_C3_sx1268_repeater | ESP32 | SX1268 | 1152294 | 1152294 | +0 | +0 | +| ThinkNode_M1_companion_radio_ble | nRF52 | SX1262 | 364572 | 364572 | +0 | +0 | +| ThinkNode_M1_companion_radio_usb | nRF52 | SX1262 | 353788 | 353788 | +0 | +0 | +| ThinkNode_M1_kiss_modem | nRF52 | SX1262 | 249444 | 249444 | +0 | +0 | +| ThinkNode_M1_repeater | nRF52 | SX1262 | 306180 | 306180 | +0 | +0 | +| ThinkNode_M1_room_server | nRF52 | SX1262 | 299636 | 299636 | +0 | +0 | +| ThinkNode_M2_Repeater | ESP32 | SX1262 | 1110177 | 1110181 | +4 | +0 | +| ThinkNode_M2_Repeater_bridge_espnow | ESP32 | SX1262 | 1120505 | 1120509 | +4 | +0 | +| ThinkNode_M2_companion_radio_ble | ESP32 | SX1262 | 1218593 | 1218597 | +4 | +0 | +| ThinkNode_M2_companion_radio_serial | ESP32 | SX1262 | 615829 | 615833 | +4 | +0 | +| ThinkNode_M2_companion_radio_usb | ESP32 | SX1262 | 615753 | 615757 | +4 | +0 | +| ThinkNode_M2_companion_radio_wifi | ESP32 | SX1262 | 1043577 | 1043581 | +4 | +0 | +| ThinkNode_M2_kiss_modem | ESP32 | SX1262 | 520613 | 520617 | +4 | +0 | +| ThinkNode_M2_room_server | ESP32 | SX1262 | 1105469 | 1105477 | +8 | +0 | +| ThinkNode_M2_terminal_chat | ESP32 | SX1262 | 548565 | 548569 | +4 | +0 | +| ThinkNode_M5_Repeater | ESP32 | SX1262 | 1127461 | 1127465 | +4 | +0 | +| ThinkNode_M5_Repeater_bridge_espnow | ESP32 | SX1262 | 1137965 | 1137969 | +4 | +0 | +| ThinkNode_M5_companion_radio_ble | ESP32 | SX1262 | 1236461 | 1236465 | +4 | +0 | +| ThinkNode_M5_companion_radio_usb | ESP32 | SX1262 | 633545 | 633549 | +4 | +0 | +| ThinkNode_M5_companion_radio_wifi | ESP32 | SX1262 | 1061209 | 1061213 | +4 | +0 | +| ThinkNode_M5_kiss_modem | ESP32 | SX1262 | 538409 | 538413 | +4 | +0 | +| ThinkNode_M5_room_server | ESP32 | SX1262 | 1122809 | 1122813 | +4 | +0 | +| ThinkNode_M5_terminal_chat | ESP32 | SX1262 | 566681 | 566685 | +4 | +0 | +| ThinkNode_M6_companion_radio_ble | nRF52 | SX1262 | 376464 | 376464 | +0 | +0 | +| ThinkNode_M6_companion_radio_usb | nRF52 | SX1262 | 368432 | 368432 | +0 | +0 | +| ThinkNode_M6_kiss_modem | nRF52 | SX1262 | 311444 | 311444 | +0 | +0 | +| ThinkNode_M6_repeater | nRF52 | SX1262 | 366212 | 366212 | +0 | +0 | +| ThinkNode_M6_room_server | nRF52 | SX1262 | 359676 | 359676 | +0 | +0 | +| ThinkNode_M8_companion_radio_ble | nRF52 | SX1262 | 370108 | 370124 | +16 | +0 | +| ThinkNode_M8_companion_radio_usb | nRF52 | SX1262 | 345896 | 345896 | +0 | +0 | +| Tiny_Relay_companion_radio_usb | STM32 | STM32WLx | 197552 | 197552 | +0 | +0 | +| Tiny_Relay_kiss_modem | STM32 | STM32WLx | 143164 | 143164 | +0 | +0 | +| Tiny_Relay_repeater | STM32 | STM32WLx | 196784 | 196784 | +0 | +0 | +| Tiny_Relay_sensor | STM32 | STM32WLx | 196376 | 196376 | +0 | +0 | +| WHY2025_badge_companion_radio_ble_ | ESP32 | SX1262 | 1444887 | 1444887 | +0 | +0 | +| WHY2025_badge_repeater_ | ESP32 | SX1262 | 542437 | 542435 | -2 | +0 | +| Waveshare_S3_LR1121_repeater | ESP32 | LR1121 | 1084869 | 1084869 | +0 | +0 | +| WioTrackerL1-1W_companion_radio_ble | nRF52 | SX1262 | 407772 | 407772 | +0 | +0 | +| WioTrackerL1-1W_companion_radio_usb | nRF52 | SX1262 | 398092 | 398092 | +0 | +0 | +| WioTrackerL1-1W_kiss_modem | nRF52 | SX1262 | 312084 | 312084 | +0 | +0 | +| WioTrackerL1-1W_repeater | nRF52 | SX1262 | 378716 | 378716 | +0 | +0 | +| WioTrackerL1-1W_room_server | nRF52 | SX1262 | 371812 | 371812 | +0 | +0 | +| WioTrackerL1Eink_companion_radio_ble | nRF52 | SX1262 | 424180 | 424180 | +0 | +0 | +| WioTrackerL1Eink_kiss_modem | nRF52 | SX1262 | 311236 | 311236 | +0 | +0 | +| WioTrackerL1_companion_radio_ble | nRF52 | SX1262 | 406716 | 406716 | +0 | +0 | +| WioTrackerL1_companion_radio_usb | nRF52 | SX1262 | 397036 | 397036 | +0 | +0 | +| WioTrackerL1_kiss_modem | nRF52 | SX1262 | 311140 | 311140 | +0 | +0 | +| WioTrackerL1_repeater | nRF52 | SX1262 | 377724 | 377724 | +0 | +0 | +| WioTrackerL1_room_server | nRF52 | SX1262 | 370804 | 370804 | +0 | +0 | +| Xiao_C3_companion_radio_ble | ESP32 | SX1262 | 1255482 | 1255482 | +0 | +0 | +| Xiao_C3_companion_radio_usb | ESP32 | SX1262 | 543422 | 543422 | +0 | +0 | +| Xiao_C3_companion_radio_wifi | ESP32 | SX1262 | 1010858 | 1010858 | +0 | +0 | +| Xiao_C3_kiss_modem | ESP32 | SX1262 | 473692 | 473692 | +0 | +0 | +| Xiao_C3_repeater | ESP32 | SX1262 | 1158048 | 1158046 | -2 | +0 | +| Xiao_C3_room_server | ESP32 | SX1262 | 1152902 | 1152902 | +0 | +0 | +| Xiao_C6_companion_radio_ble_ | ESP32 | SX1262 | 1444931 | 1444931 | +0 | +0 | +| Xiao_C6_kiss_modem | ESP32 | SX1262 | 472835 | 472835 | +0 | +0 | +| Xiao_C6_repeater_ | ESP32 | SX1262 | 542481 | 542479 | -2 | +0 | +| Xiao_S3_WIO_companion_radio_ble | ESP32 | SX1262 | 1642437 | 1642437 | +0 | +0 | +| Xiao_S3_WIO_companion_radio_serial | ESP32 | SX1262 | 671473 | 671473 | +0 | +0 | +| Xiao_S3_WIO_companion_radio_usb | ESP32 | SX1262 | 652653 | 652653 | +0 | +0 | +| Xiao_S3_WIO_companion_radio_wifi | ESP32 | SX1262 | 1054393 | 1054393 | +0 | +0 | +| Xiao_S3_WIO_kiss_modem | ESP32 | SX1262 | 549837 | 549837 | +0 | +0 | +| Xiao_S3_WIO_repeater | ESP32 | SX1262 | 1127969 | 1127969 | +0 | +0 | +| Xiao_S3_WIO_repeater_bridge_espnow | ESP32 | SX1262 | 1138525 | 1138525 | +0 | +0 | +| Xiao_S3_WIO_room_server | ESP32 | SX1262 | 1123353 | 1123353 | +0 | +0 | +| Xiao_S3_WIO_sensor | ESP32 | SX1262 | 1134745 | 1134745 | +0 | +0 | +| Xiao_S3_WIO_terminal_chat | ESP32 | SX1262 | 571045 | 571045 | +0 | +0 | +| Xiao_S3_companion_radio_ble | ESP32 | SX1262 | 1645153 | 1645153 | +0 | +0 | +| Xiao_S3_companion_radio_usb | ESP32 | SX1262 | 655381 | 655381 | +0 | +0 | +| Xiao_S3_kiss_modem | ESP32 | SX1262 | 559569 | 559569 | +0 | +0 | +| Xiao_S3_repeater | ESP32 | SX1262 | 1137605 | 1137605 | +0 | +0 | +| Xiao_S3_repeater_bridge_espnow | ESP32 | SX1262 | 1148121 | 1148121 | +0 | +0 | +| Xiao_S3_room_server | ESP32 | SX1262 | 1132949 | 1132949 | +0 | +0 | +| Xiao_S3_sensor | ESP32 | SX1262 | 1144397 | 1144397 | +0 | +0 | +| Xiao_nrf52_companion_radio_ble | nRF52 | SX1262 | 379608 | 379608 | +0 | +0 | +| Xiao_nrf52_companion_radio_usb | nRF52 | SX1262 | 373912 | 373912 | +0 | +0 | +| Xiao_nrf52_kiss_modem | nRF52 | SX1262 | 305840 | 305840 | +0 | +0 | +| Xiao_nrf52_repeater | nRF52 | SX1262 | 361800 | 361800 | +0 | +0 | +| Xiao_nrf52_room_server | nRF52 | SX1262 | 354264 | 354264 | +0 | +0 | +| Xiao_rp2040_companion_radio_usb | RP2040 | SX1262 | 238412 | 238412 | +0 | +0 | +| Xiao_rp2040_kiss_modem | RP2040 | SX1262 | 182708 | 182708 | +0 | +0 | +| Xiao_rp2040_repeater | RP2040 | SX1262 | 235796 | 235796 | +0 | +0 | +| Xiao_rp2040_room_server | RP2040 | SX1262 | 231740 | 231740 | +0 | +0 | +| Xiao_rp2040_terminal_chat | RP2040 | SX1262 | 212284 | 212284 | +0 | +0 | +| heltec_rc32_companion_radio_ble | ESP32 | SX1262 | 1280369 | 1280373 | +4 | +0 | +| heltec_rc32_companion_radio_usb | ESP32 | SX1262 | 675521 | 675525 | +4 | +0 | +| heltec_rc32_companion_radio_wifi | ESP32 | SX1262 | 1103413 | 1103417 | +4 | +0 | +| heltec_rc32_kiss_modem | ESP32 | SX1262 | 582193 | 582197 | +4 | +0 | +| heltec_rc32_repeater | ESP32 | SX1262 | 1169065 | 1169069 | +4 | +0 | +| heltec_rc32_repeater_bridge_espnow | ESP32 | SX1262 | 1179561 | 1179565 | +4 | +0 | +| heltec_rc32_room_server | ESP32 | SX1262 | 1164397 | 1164401 | +4 | +0 | +| heltec_rc32_sensor | ESP32 | SX1262 | 1175809 | 1175813 | +4 | +0 | +| heltec_rc32_terminal_chat | ESP32 | SX1262 | 603281 | 603285 | +4 | +0 | +| heltec_rc32_without_display_companion_radio_ble | ESP32 | SX1262 | 1275521 | 1275525 | +4 | +0 | +| heltec_rc32_without_display_companion_radio_usb | ESP32 | SX1262 | 670313 | 670317 | +4 | +0 | +| heltec_rc32_without_display_companion_radio_wifi | ESP32 | SX1262 | 1098265 | 1098269 | +4 | +0 | +| heltec_rc32_without_display_repeater | ESP32 | SX1262 | 1162165 | 1162169 | +4 | +0 | +| heltec_rc32_without_display_repeater_bridge_espnow | ESP32 | SX1262 | 1172541 | 1172545 | +4 | +0 | +| heltec_rc32_without_display_room_server | ESP32 | SX1262 | 1157581 | 1157585 | +4 | +0 | +| heltec_rc32_without_display_sensor | ESP32 | SX1262 | 1169053 | 1169053 | +0 | +0 | +| heltec_tracker_v2_companion_radio_ble | ESP32 | SX1262 | 1337821 | 1337825 | +4 | +0 | +| heltec_tracker_v2_companion_radio_usb | ESP32 | SX1262 | 711273 | 711277 | +4 | +0 | +| heltec_tracker_v2_companion_radio_wifi | ESP32 | SX1262 | 1157789 | 1157793 | +4 | +0 | +| heltec_tracker_v2_repeater | ESP32 | SX1262 | 1226253 | 1226257 | +4 | +0 | +| heltec_tracker_v2_repeater_bridge_espnow | ESP32 | SX1262 | 1236709 | 1236713 | +4 | +0 | +| heltec_tracker_v2_room_server | ESP32 | SX1262 | 1221501 | 1221505 | +4 | +0 | +| heltec_tracker_v2_sensor | ESP32 | SX1262 | 1232873 | 1232881 | +8 | +0 | +| heltec_tracker_v2_terminal_chat | ESP32 | SX1262 | 628189 | 628193 | +4 | +0 | +| heltec_v4_companion_radio_ble | ESP32 | SX1262 | 1668529 | 1668533 | +4 | +0 | +| heltec_v4_companion_radio_usb | ESP32 | SX1262 | 655233 | 655237 | +4 | +0 | +| heltec_v4_companion_radio_wifi | ESP32 | SX1262 | 1104737 | 1104741 | +4 | +0 | +| heltec_v4_expansionkit_repeater | ESP32 | SX1262 | 1173185 | 1173189 | +4 | +0 | +| heltec_v4_kiss_modem | ESP32 | SX1262 | 579641 | 579645 | +4 | +0 | +| heltec_v4_r8_companion_radio_ble | ESP32 | SX1262 | 1672049 | 1672053 | +4 | +0 | +| heltec_v4_r8_companion_radio_usb | ESP32 | SX1262 | 658709 | 658713 | +4 | +0 | +| heltec_v4_r8_companion_radio_wifi | ESP32 | SX1262 | 1108197 | 1108201 | +4 | +0 | +| heltec_v4_r8_kiss_modem | ESP32 | SX1262 | 583085 | 583089 | +4 | +0 | +| heltec_v4_r8_repeater | ESP32 | SX1262 | 1176625 | 1176629 | +4 | +0 | +| heltec_v4_r8_room_server | ESP32 | SX1262 | 1171909 | 1171913 | +4 | +0 | +| heltec_v4_r8_sensor | ESP32 | SX1262 | 1183241 | 1183245 | +4 | +0 | +| heltec_v4_r8_terminal_chat | ESP32 | SX1262 | 604185 | 604189 | +4 | +0 | +| heltec_v4_r8_tft_companion_radio_ble | ESP32 | SX1262 | 1289081 | 1289085 | +4 | +0 | +| heltec_v4_r8_tft_companion_radio_usb | ESP32 | SX1262 | 662557 | 662561 | +4 | +0 | +| heltec_v4_r8_tft_companion_radio_wifi | ESP32 | SX1262 | 1112049 | 1112053 | +4 | +0 | +| heltec_v4_r8_tft_kiss_modem | ESP32 | SX1262 | 583477 | 583481 | +4 | +0 | +| heltec_v4_r8_tft_repeater | ESP32 | SX1262 | 1178733 | 1178737 | +4 | +0 | +| heltec_v4_r8_tft_room_server | ESP32 | SX1262 | 1173917 | 1173921 | +4 | +0 | +| heltec_v4_r8_tft_sensor | ESP32 | SX1262 | 1185293 | 1185297 | +4 | +0 | +| heltec_v4_r8_tft_terminal_chat | ESP32 | SX1262 | 604573 | 604577 | +4 | +0 | +| heltec_v4_repeater | ESP32 | SX1262 | 1173133 | 1173137 | +4 | +0 | +| heltec_v4_repeater_bridge_espnow | ESP32 | SX1262 | 1183669 | 1183673 | +4 | +0 | +| heltec_v4_room_server | ESP32 | SX1262 | 1168417 | 1168421 | +4 | +0 | +| heltec_v4_sensor | ESP32 | SX1262 | 1179781 | 1179785 | +4 | +0 | +| heltec_v4_terminal_chat | ESP32 | SX1262 | 600733 | 600737 | +4 | +0 | +| heltec_v4_tft_companion_radio_ble | ESP32 | SX1262 | 1283401 | 1283405 | +4 | +0 | +| heltec_v4_tft_companion_radio_usb | ESP32 | SX1262 | 656917 | 656921 | +4 | +0 | +| heltec_v4_tft_companion_radio_wifi | ESP32 | SX1262 | 1106325 | 1106329 | +4 | +0 | +| heltec_v4_tft_repeater | ESP32 | SX1262 | 1174865 | 1174869 | +4 | +0 | +| heltec_v4_tft_repeater_bridge_espnow | ESP32 | SX1262 | 1185345 | 1185349 | +4 | +0 | +| heltec_v4_tft_room_server | ESP32 | SX1262 | 1170029 | 1170013 | -16 | +0 | +| heltec_v4_tft_sensor | ESP32 | SX1262 | 1181433 | 1181441 | +8 | +0 | +| heltec_v4_tft_terminal_chat | ESP32 | SX1262 | 600733 | 600737 | +4 | +0 | +| ikoka_handheld_nrf_e22_30dbm_096_companion_radio_ble | nRF52 | SX1262 | 397616 | 397616 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_096_companion_radio_usb | nRF52 | SX1262 | 390224 | 390224 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_ble | nRF52 | SX1262 | 397664 | 397664 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_usb | nRF52 | SX1262 | 390288 | 390288 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_repeater | nRF52 | SX1262 | 358296 | 358296 | +0 | +0 | +| ikoka_handheld_nrf_e22_30dbm_room_server | nRF52 | SX1262 | 351752 | 351752 | +0 | +0 | +| ikoka_handheld_nrf_kiss_modem | nRF52 | SX1262 | 304272 | 304272 | +0 | +0 | +| ikoka_nano_nrf_22dbm_companion_radio_ble | nRF52 | SX1262 | 386752 | 386752 | +0 | +0 | +| ikoka_nano_nrf_22dbm_companion_radio_usb | nRF52 | SX1262 | 379824 | 379824 | +0 | +0 | +| ikoka_nano_nrf_22dbm_kiss_modem | nRF52 | SX1262 | 305264 | 305264 | +0 | +0 | +| ikoka_nano_nrf_22dbm_repeater | nRF52 | SX1262 | 361176 | 361176 | +0 | +0 | +| ikoka_nano_nrf_22dbm_room_server | nRF52 | SX1262 | 353720 | 353720 | +0 | +0 | +| ikoka_nano_nrf_30dbm_companion_radio_ble | nRF52 | SX1262 | 386752 | 386752 | +0 | +0 | +| ikoka_nano_nrf_30dbm_companion_radio_usb | nRF52 | SX1262 | 379824 | 379824 | +0 | +0 | +| ikoka_nano_nrf_30dbm_kiss_modem | nRF52 | SX1262 | 305264 | 305264 | +0 | +0 | +| ikoka_nano_nrf_30dbm_repeater | nRF52 | SX1262 | 361176 | 361176 | +0 | +0 | +| ikoka_nano_nrf_30dbm_room_server | nRF52 | SX1262 | 353720 | 353720 | +0 | +0 | +| ikoka_nano_nrf_33dbm_companion_radio_ble | nRF52 | SX1262 | 386752 | 386752 | +0 | +0 | +| ikoka_nano_nrf_33dbm_companion_radio_usb | nRF52 | SX1262 | 379824 | 379824 | +0 | +0 | +| ikoka_nano_nrf_33dbm_kiss_modem | nRF52 | SX1262 | 305264 | 305264 | +0 | +0 | +| ikoka_nano_nrf_33dbm_repeater | nRF52 | SX1262 | 361176 | 361176 | +0 | +0 | +| ikoka_nano_nrf_33dbm_room_server | nRF52 | SX1262 | 353720 | 353720 | +0 | +0 | +| ikoka_stick_nrf_22dbm_companion_radio_ble | nRF52 | SX1262 | 397680 | 397680 | +0 | +0 | +| ikoka_stick_nrf_22dbm_companion_radio_usb | nRF52 | SX1262 | 390880 | 390880 | +0 | +0 | +| ikoka_stick_nrf_22dbm_kiss_modem | nRF52 | SX1262 | 318112 | 318112 | +0 | +0 | +| ikoka_stick_nrf_22dbm_repeater | nRF52 | SX1262 | 373768 | 373768 | +0 | +0 | +| ikoka_stick_nrf_22dbm_room_server | nRF52 | SX1262 | 366816 | 366816 | +0 | +0 | +| ikoka_stick_nrf_30dbm_companion_radio_ble | nRF52 | SX1262 | 397680 | 397680 | +0 | +0 | +| ikoka_stick_nrf_30dbm_companion_radio_usb | nRF52 | SX1262 | 390880 | 390880 | +0 | +0 | +| ikoka_stick_nrf_30dbm_kiss_modem | nRF52 | SX1262 | 318112 | 318112 | +0 | +0 | +| ikoka_stick_nrf_30dbm_repeater | nRF52 | SX1262 | 373768 | 373768 | +0 | +0 | +| ikoka_stick_nrf_30dbm_room_server | nRF52 | SX1262 | 366816 | 366816 | +0 | +0 | +| ikoka_stick_nrf_33dbm_companion_radio_ble | nRF52 | SX1262 | 397680 | 397680 | +0 | +0 | +| ikoka_stick_nrf_33dbm_companion_radio_usb | nRF52 | SX1262 | 390880 | 390880 | +0 | +0 | +| ikoka_stick_nrf_33dbm_kiss_modem | nRF52 | SX1262 | 318112 | 318112 | +0 | +0 | +| ikoka_stick_nrf_33dbm_repeater | nRF52 | SX1262 | 373768 | 373768 | +0 | +0 | +| ikoka_stick_nrf_33dbm_room_server | nRF52 | SX1262 | 366816 | 366816 | +0 | +0 | +| meshnology_w12_repeater | ESP32 | LR2021 | 1183449 | 1183449 | +0 | +0 | +| muzi_base_uno_companion_radio_ble | nRF52 | SX1262 | 330220 | 330220 | +0 | +0 | +| muzi_base_uno_companion_radio_usb | nRF52 | SX1262 | 325180 | 325180 | +0 | +0 | +| muzi_base_uno_repeater | nRF52 | SX1262 | 301236 | 301236 | +0 | +0 | +| muzi_base_uno_room_server | nRF52 | SX1262 | 294724 | 294724 | +0 | +0 | +| muzi_base_uno_superIO_companion_radio_ble | nRF52 | SX1262 | 349280 | 349280 | +0 | +0 | +| nibble_screen_connect_companion_radio_ble_ | ESP32 | SX1262 | 1210293 | 1210293 | +0 | +0 | +| nibble_screen_connect_companion_radio_usb_ | ESP32 | SX1262 | 584941 | 584941 | +0 | +0 | +| nibble_screen_connect_companion_radio_wifi_ | ESP32 | SX1262 | 1033905 | 1033905 | +0 | +0 | +| nibble_screen_connect_kiss_modem_ | ESP32 | SX1262 | 495625 | 495625 | +0 | +0 | +| nibble_screen_connect_repeater_ | ESP32 | SX1262 | 1103205 | 1103205 | +0 | +0 | +| nibble_screen_connect_repeater_bridge_espnow_ | ESP32 | SX1262 | 1113529 | 1113529 | +0 | +0 | +| nibble_screen_connect_room_server_ | ESP32 | SX1262 | 1098497 | 1098497 | +0 | +0 | +| nibble_screen_connect_terminal_chat_ | ESP32 | SX1262 | 523457 | 523457 | +0 | +0 | +| nibble_zero_connect_companion_radio_ble_ | ESP32 | SX1262 | 1210537 | 1210537 | +0 | +0 | +| nibble_zero_connect_companion_radio_usb_ | ESP32 | SX1262 | 585149 | 585149 | +0 | +0 | +| nibble_zero_connect_companion_radio_wifi_ | ESP32 | SX1262 | 1034133 | 1034133 | +0 | +0 | +| nibble_zero_connect_repeater_ | ESP32 | SX1262 | 1103309 | 1103309 | +0 | +0 | +| nibble_zero_connect_repeater_bridge_espnow_ | ESP32 | SX1262 | 1113613 | 1113613 | +0 | +0 | +| nibble_zero_connect_room_server_ | ESP32 | SX1262 | 1098589 | 1098589 | +0 | +0 | +| nibble_zero_connect_terminal_chat_ | ESP32 | SX1262 | 523537 | 523537 | +0 | +0 | +| t1000e_repeater | nRF52 | LR1110 | 294712 | 294712 | +0 | +0 | +| waveshare_rp2040_lora_companion_radio_usb | RP2040 | SX1262 | 239212 | 239212 | +0 | +0 | +| waveshare_rp2040_lora_kiss_modem | RP2040 | SX1262 | 183500 | 183500 | +0 | +0 | +| waveshare_rp2040_lora_repeater | RP2040 | SX1262 | 236596 | 236596 | +0 | +0 | +| waveshare_rp2040_lora_repeater_bridge_rs232 | RP2040 | SX1262 | 241080 | 241080 | +0 | +0 | +| waveshare_rp2040_lora_room_server | RP2040 | SX1262 | 232548 | 232548 | +0 | +0 | +| waveshare_rp2040_lora_terminal_chat | RP2040 | SX1262 | 213084 | 213084 | +0 | +0 | +| wio-e5-mini_companion_radio_usb | STM32 | STM32WLx | 209016 | 209016 | +0 | +0 | +| wio-e5-mini_kiss_modem | STM32 | STM32WLx | 151208 | 151208 | +0 | +0 | +| wio-e5-mini_repeater | STM32 | STM32WLx | 204136 | 204136 | +0 | +0 | +| wio-e5-mini_sensor | STM32 | STM32WLx | 203664 | 203664 | +0 | +0 | +| wio-e5-repeater_bridge_rs232 | STM32 | STM32WLx | 199064 | 199064 | +0 | +0 | +| wio-e5_companion_radio_usb | STM32 | STM32WLx | 197688 | 197688 | +0 | +0 | +| wio-e5_kiss_modem | STM32 | STM32WLx | 143248 | 143248 | +0 | +0 | +| wio-e5_repeater | STM32 | STM32WLx | 196848 | 196848 | +0 | +0 | + +
+ +### What was not verified on hardware + +No board test for 9b. The release images of SX1262 and SX1268 boards run the same +`std_init()` calls in the same order as before (host suites), and differ in flash +by the few bytes above; the debug build output is pinned on the host. LLCC68 has no +firmware environment and is covered by the host suites only. From 134c5a11b0d887cc35e49c7bd66bb382c943065f Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 13:48:41 +0200 Subject: [PATCH 131/136] docs(refactoring): explain the 9b image and flash differences --- docs/refactoring/0009-radio-chip-adapter.md | 54 ++++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/docs/refactoring/0009-radio-chip-adapter.md b/docs/refactoring/0009-radio-chip-adapter.md index d0cf81394b9..344b21c2923 100644 --- a/docs/refactoring/0009-radio-chip-adapter.md +++ b/docs/refactoring/0009-radio-chip-adapter.md @@ -517,12 +517,54 @@ Measured for `Heltec_v3_repeater` (+4) with `nm` and `size -A` on both ELF files to 0x198 bytes, where the radio object is now built through one more base class constructor. Every chip and wrapper method has the same size as before (compared with the template name folded into `CustomSX1262`). The -2 builds are the ESP32-C3 -and C6 ones; the outliers (-40, -16, +16) were not examined one by one. - -The image hashes differ between the two sides in all 512, but they also differ -for every control environment, whose code the step does not touch, at equal -size, so the builds are not byte reproducible across commits and the hashes -prove nothing here. Why was not examined. +and C6 ones. + +#### Image comparison and where the bytes go + +The image hashes of the matrix above differ in all 512, controls included, +because RadioLib puts the build time into every image: `Module.cpp:47` keeps +`static volatile const char rlb_info[] = RADIOLIB_INFO;`, and `BuildOpt.h:647` +ends `RADIOLIB_INFO` with `__DATE__ " " __TIME__`. Two builds of the same commit +one minute apart differ in exactly those three bytes. With `SOURCE_DATE_EPOCH` +set, which GCC uses for both macros, two builds of the same commit are byte +identical. + +65 environments were then built again on both sides with +`SOURCE_DATE_EPOCH=1792540800`, keeping the ELF files: all 22 controls, all 21 +environments with a flash difference other than +0 or +4, and 22 picked from the ++0 and +4 groups across the platforms and both chips. + +- 23 images are byte identical: all 22 controls and `RAK_4631_repeater`. +- 42 differ. In each of them, `nm -S` of both ELF files was compared symbol by + symbol, with the template name folded back into the class name. In 12 every + symbol has the same size; only the order of the template members in flash + changed (in `PicoW_repeater`, 23 of 2132 code symbols moved, for example + `isReceiving()` from 0x10006214 to 0x100063b0). In the other 30 only these + change size: `_GLOBAL__sub_I_board`, the static initializer of the variant + globals that builds the radio object (22 environments, +1 to +8 bytes); + `radio_init()`, into which `std_init()` is inlined (12, -2 to +12); and in a + few environments functions the step does not touch (`sx126xResetAGC()` -4, + `MicroNMEALocationProvider::waitFor()` -1, `::loop()` +4, `start_cpu0` -8, + `esp_vfs_open` +4, `ESP32Board::getResetReasonString()` -2). + +Three of them were disassembled and compared instruction by instruction: + +- `_GLOBAL__sub_I_board` in `Heltec_v3_repeater` (+4): 156 instructions on both + sides, the same stores in a different order, and four of them emitted in the + wide instead of the narrow Xtensa encoding (`s32i.n` to `s32i`, `mov.n` to + `or`), one byte each. +- `std_init()` of SX1268 in `Generic_E22_sx1268_repeater` (+4): the same + instruction sequence; one `ill` padding word after a jump and one `mov.n` + emitted as `or`, both alignment of a branch target. +- `MicroNMEALocationProvider::waitFor()` (-1): identical up to its final + `retw.n`; the difference is the padding after it, which depends on where the + function lands. + +So the flash differences are scheduling, instruction width and alignment around +the object construction and the inlined `std_init()`. The -40 of +`RAK_3112_room_server` is not a code change of its own: its changed symbols add +up to -6 (`_GLOBAL__sub_I_board` +3, `start_cpu0` -8, `waitFor` -1), the rest +is section padding.
Flash and RAM per environment (512) From ed6cc08022a2dcf7aaddbad69f7b22a3ab68bfd4 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 13:51:01 +0200 Subject: [PATCH 132/136] docs(refactoring): list step 9b in the step table --- docs/refactoring/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/refactoring/README.md b/docs/refactoring/README.md index 41b0e2efd30..939c63c1161 100644 --- a/docs/refactoring/README.md +++ b/docs/refactoring/README.md @@ -51,7 +51,7 @@ The full analysis, with file and line references for every finding, is in | 2 | Let the core read its tuning values from the radio settings instead of a getter per role | [0002](0002-core-reads-radio-prefs.md) | ready for review | | 3 | Drive the radio settings (JSON, CLI, key/value store) from one table | [0003](0003-radio-settings-table.md) | ready for review | | 4 | Share the underpinnings of repeater, room server and sensor in one base class (steps 4a to 4g; the boot sequence and the forwarding checks are the last two) | [0004](0004-shared-server-base.md), [0007](0007-boot-sequence.md), [0008](0008-forward-policy.md) | ready for review | -| 9 | One RadioLib adapter per chip family, part one: the receive detection the seven chip classes shared as copies (step 9a; 9b to 9d planned in the note) | [0009](0009-radio-chip-adapter.md) | 9a ready for review | +| 9 | One RadioLib adapter per chip family: the receive detection the seven chip classes shared as copies (9a), one template for the SX126x chip classes SX1262, SX1268 and LLCC68 (9b); 9c and 9d planned in the note | [0009](0009-radio-chip-adapter.md) | 9a and 9b ready for review | | 10 | A command table for the companion protocol: a host harness that pins every frame (10a), one row per command code instead of the if chain (10b), then a bounded frame reader in the handlers instead of hand counted offsets (10c); 10d proposed in the note | [0010](0010-companion-command-table.md) | 10a to 10c ready for review | | 11 | One generic variant target built from a board description, cut A: one shared `radio_new_identity()`; cut B: one FEM RX gain handler for four Heltec boards; cut C: one default `radio_init()` for 31 nRF52 boards (cuts D and E planned in the note) | [0011](0011-variant-target.md) | cuts A, B and C ready for review | From 7bab5b353f22c1d19d24dea0b8fdbbf253452073 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 16:44:53 +0200 Subject: [PATCH 133/136] test(companion): compare encrypted packets by shape, not by content The first CI run failed NoCommandReadsThePreviousFrame on Linux only: the payload of a queued channel message held bytes of earlier frames. The host's AES128 mock writes nothing, so an encrypted packet's payload, MAC and transport code are whatever its buffer held before, which depends on the allocator. With a mock that copies its input the test passes on Linux; the firmware is not involved. Encrypted packets are now compared by header, route, path and payload length; the other types stay byte for byte. Checked on macOS and in a Linux container, against the handlers before and after 10c, and the mutations caught before are still caught. The note records the cause in place of the earlier guess. --- .../0010-companion-command-table.md | 36 +++++++++++-------- .../test_frame_bounds.cpp | 26 ++++++++++++-- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/refactoring/0010-companion-command-table.md b/docs/refactoring/0010-companion-command-table.md index d39892b0aa8..1aa43ead3a8 100644 --- a/docs/refactoring/0010-companion-command-table.md +++ b/docs/refactoring/0010-companion-command-table.md @@ -304,12 +304,10 @@ handlers and go away with the reader without changing an answer; the CLI and `tryParsePacket()` are callees that 10c does not touch (the latter also parses every received radio packet), recorded here as findings for their own fixes. -A side observation of the ASan build, not checked further: built with ASan, the -frame sweep's packet digests differ for codes 2, 3, 26, 27 and 39 (encrypted -requests and messages) while every reply stays the same. Unproven guess: the -encryption pads its last block from an uninitialised stack buffer, which the -sweep's stack scrub reaches in a normal build and does not reach under ASan's -stack layout. Not part of 10c. +A side observation of the ASan build: the frame sweep's packet digests differ for +codes 2, 3, 26, 27 and 39 (encrypted requests and messages) while every reply +stays the same. Explained later, under "Open findings": an artefact of the host's +cipher mock, not of the firmware. ### The reader @@ -744,7 +742,9 @@ again against the handlers of `68e0fcdc` after the step (60 of 60 pass there too every eighth byte). Replies, queued packets, stored files (except the name tails in `/channels2`, see 10a), calls to board, radio, clock and sensors, and what 12 more loops, a `GET_CHANNEL` and a channel flood show afterwards, must be - identical. + identical. Encrypted packets are compared by header, route, path and payload + length, since the host's cipher mock leaves their content undefined (open + finding 3). - `OneByteBelowEachMinimum`: for each of the 44 rows with a minimum above 1, the catalog frame at the minimum minus one byte answers `UNSUPPORTED` (`SEND_CHANNEL_DATA`: `ILLEGAL_ARG`) behind every tail and queues nothing; and the @@ -917,12 +917,20 @@ Each needs its own fix and proposal; 10c leaves the code as it is. `memcmp()` with prefixes longer than the command text. The result is decided at the terminator, so no reply changes. Reproduce: the same probe lists `MyMesh::cmdRunCliCommand` with those three callers for code 66, lengths 3 to 22. -3. **Packet digests under ASan (unchecked).** Built with ASan, the frame sweep's - packet digests differ for codes 2, 3, 26, 27 and 39. Guess, not verified: the encryption pads from an uninitialised stack - buffer that the sweep's stack scrub reaches only without ASan. Reproduce: apply - the probe patch and run `pio test -e native_companion` without a filter; - `FrameSweep.MatchesTheRecordedTranscript` fails; the differences it printed (it - prints at most ten) were all `packets` lines of those codes, with equal `files` - digests. +3. **Encrypted packets on the host carry leftover memory (resolved, a mock + artefact).** The ASan build changed the frame sweep's packet digests for codes + 2, 3, 26, 27 and 39, and the first CI run of 10c failed + `NoCommandReadsThePreviousFrame` on Linux only, with bytes of earlier frames in + the payload of a queued channel message. Cause, checked: the host's `AES128` + mock (`test/mocks/AES.h`) writes nothing in `encryptBlock()`, and + `Utils::encrypt()` encrypts straight into the packet buffer, so the "ciphertext" + (and the MAC and transport code computed over it) is whatever that buffer held + before, which depends on the allocator. With a mock that copies its input, the + test passes on Linux and only the sweep differs. Not a firmware issue. The frame + sweep's digests of encrypted packets rest on the same leftovers and are stable + only because the sweep allocates the same way on macOS and on the CI's Linux; + giving the mock a deterministic output means regenerating the golden file, a + change of its own. `test_frame_bounds.cpp` compares encrypted packets by + header, route, path and payload length only. 4. **`SEND_TRACE_PATH`'s upper bound is dead code.** `len - 10 >= MAX_PACKET_PAYLOAD - 5` needs 189 bytes; a frame has at most 176. diff --git a/test/test_companion_frames/test_frame_bounds.cpp b/test/test_companion_frames/test_frame_bounds.cpp index dccd6589c1c..bc611d586bb 100644 --- a/test/test_companion_frames/test_frame_bounds.cpp +++ b/test/test_companion_frames/test_frame_bounds.cpp @@ -201,6 +201,28 @@ std::vector catalog() { return c; } +// A queued packet as text. The host's AES128 mock writes nothing, so the payload +// of an encrypted packet (and its MAC, and a transport code computed over it) is +// whatever the packet buffer held before, which depends on the allocator: on Linux +// it held bytes of an earlier node's frames. For those types only the header, the +// route, the path and the payload length are compared; their content cannot show +// a frame byte on the host anyway. Plain types are compared byte for byte. +std::string packetLine(const Bytes& p) { + uint8_t type = (p[0] >> 2) & 0x0F; + bool encrypted = type <= 0x02 || (type >= 0x05 && type <= 0x08); + if (!encrypted) return hex(p); + uint8_t route = p[0] & 0x03; + size_t i = 1 + ((route == 0 || route == 3) ? 4 : 0); + uint8_t path_len = p[i]; + size_t path_bytes = (path_len & 63) * ((path_len >> 6) + 1); + size_t payload_at = i + 1 + path_bytes; + std::ostringstream o; + o << hex(Bytes(p.begin(), p.begin() + 1)) << " route " << (int)route << " path " + << hex(Bytes(p.begin() + i, p.begin() + std::min(payload_at, p.size()))) + << " payload " << (p.size() > payload_at ? p.size() - payload_at : 0) << " bytes"; + return o.str(); +} + // What one frame did, as text: replies, packets, files, and the counters of the // collaborators, then what a few later steps show. /channels2 is left out, because CMD_SET_CHANNEL writes stack // bytes behind the channel name there (see test_frame_sweep.cpp); the channel's @@ -212,7 +234,7 @@ std::string outcome(Harness& h, const Bytes& f) { uint32_t rtc = rtc_clock.now; std::ostringstream o; for (const Bytes& r : h.send(f)) o << "reply " << hex(r) << "\n"; - for (const Bytes& p : h.mesh().outbound()) o << "packet " << hex(p) << "\n"; + for (const Bytes& p : h.mesh().outbound()) o << "packet " << packetLine(p) << "\n"; for (auto& file : h.fs.files) if (file.first != "/channels2") o << "file " << file.first << " " << hex(file.second) << "\n"; // State that only shows later: a contact listing still running (it writes one @@ -228,7 +250,7 @@ std::string outcome(Harness& h, const Bytes& f) { putU32(chat, RTC0); putStr(chat, "x"); for (const Bytes& r : h.send(chat)) o << "flood " << hex(r) << "\n"; - for (const Bytes& p : h.mesh().outbound()) o << "queued " << hex(p) << "\n"; + for (const Bytes& p : h.mesh().outbound()) o << "queued " << packetLine(p) << "\n"; for (auto& e : g_events) o << "event " << e << "\n"; o << "reboot " << board.reboot_calls - reboots << " params " << radio_driver.set_params_calls - params << " power " << radio_driver.tx_power_calls - power << " rtc " << rtc_clock.now - rtc From 12d453bb7a6a1243c5ddb770bc8243c425a3721e Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 23:11:25 +0200 Subject: [PATCH 134/136] test(mocks): give the AES128 mock an output that depends only on key and input The mock's encryptBlock() wrote nothing, and Utils::encrypt() encrypts straight into the packet buffer, so encrypted packets on the host carried whatever that buffer held before. The frame sweep pinned those leftovers in the packets digests of every sending command that encrypts and was stable only because macOS and the CI's Linux allocate alike. Each block is now XORed with the key. The golden file changes in exactly 20 packets digests (codes 02 03 1a 1b 27 32 34 39 3e); no reply line and no files digest changes. --- test/mocks/AES.h | 22 ++++++++-- test/test_companion_frames/frame_sweep.golden | 40 +++++++++---------- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/test/mocks/AES.h b/test/mocks/AES.h index 678b7c13b13..22997c435af 100644 --- a/test/mocks/AES.h +++ b/test/mocks/AES.h @@ -2,12 +2,26 @@ #include #include +#include // Mock AES128 class for testing -// Provides minimal interface to allow Utils.cpp to compile +// Not a cipher: each block is XORed with the key, so the output depends only on +// key and input. Utils::encrypt() writes straight into the packet buffer, and a +// mock that left the output untouched gave packets whatever that buffer held +// before (docs/refactoring/0010-companion-command-table.md, open finding 3). +// XOR is its own inverse, so decryptBlock() undoes encryptBlock(). class AES128 { + uint8_t _key[16] = {}; + + void xorBlock(uint8_t* output, const uint8_t* input) const { + for (int i = 0; i < 16; i++) output[i] = input[i] ^ _key[i]; + } + public: - void setKey(const uint8_t* key, size_t keySize) {} - void encryptBlock(uint8_t* output, const uint8_t* input) {} - void decryptBlock(uint8_t* output, const uint8_t* input) {} + void setKey(const uint8_t* key, size_t keySize) { + memset(_key, 0, sizeof(_key)); + memcpy(_key, key, keySize < sizeof(_key) ? keySize : sizeof(_key)); + } + void encryptBlock(uint8_t* output, const uint8_t* input) { xorBlock(output, input); } + void decryptBlock(uint8_t* output, const uint8_t* input) { xorBlock(output, input); } }; diff --git a/test/test_companion_frames/frame_sweep.golden b/test/test_companion_frames/frame_sweep.golden index f5a7e836d69..08048221bf2 100644 --- a/test/test_companion_frames/frame_sweep.golden +++ b/test/test_companion_frames/frame_sweep.golden @@ -25,7 +25,7 @@ 01 00/5a packets b5fd26618458b725 files f195e1bfb415df25 02 00 1-13: 0101 02 00 14-176: 0600e661e360bc190000 [Q] -02 00 packets 8bb0a0113ef03351 files f195e1bfb415df25 +02 00 packets 106eca7be92fcae3 files f195e1bfb415df25 02 01 1-13: 0101 02 01 14-176: 0102 02 01 packets b5fd26618458b725 files f195e1bfb415df25 @@ -37,10 +37,10 @@ 02 ff packets b5fd26618458b725 files f195e1bfb415df25 02 00/5a 1-13: 0101 02 00/5a 14-176: 0600e661e360bc190000 [Q] -02 00/5a packets 8bb0a0113ef03351 files f195e1bfb415df25 +02 00/5a packets 106eca7be92fcae3 files f195e1bfb415df25 03 00 1-6: 0101 03 00 7-176: 00 [Q] -03 00 packets 57e0ad4e64c2e5a5 files f195e1bfb415df25 +03 00 packets 367544479ace75c1 files f195e1bfb415df25 03 01 1-176: 0101 03 01 packets b5fd26618458b725 files f195e1bfb415df25 03 81 1-176: 0101 @@ -49,7 +49,7 @@ 03 ff packets b5fd26618458b725 files f195e1bfb415df25 03 00/5a 1-6: 0101 03 00/5a 7-176: 00 [Q] -03 00/5a packets 57e0ad4e64c2e5a5 files f195e1bfb415df25 +03 00/5a packets 367544479ace75c1 files f195e1bfb415df25 04 00 1-176: 0200000000 04 00 packets b5fd26618458b725 files f195e1bfb415df25 04 01 1-176: 0200000000 @@ -348,7 +348,7 @@ 19 00/5a packets e362c0b056690971 files f195e1bfb415df25 1a 00 1-32: 0101 1a 00 33-176: 0600000000009e340000 [Q] -1a 00 packets 2cf9726923d80a25 files f195e1bfb415df25 +1a 00 packets e9816169697575c5 files f195e1bfb415df25 1a 01 1-32: 0101 1a 01 33-176: 0102 1a 01 packets b5fd26618458b725 files f195e1bfb415df25 @@ -360,10 +360,10 @@ 1a ff packets b5fd26618458b725 files f195e1bfb415df25 1a 00/5a 1-32: 0101 1a 00/5a 33-176: 0600000000009e340000 [Q] -1a 00/5a packets 2cf9726923d80a25 files f195e1bfb415df25 +1a 00/5a packets e9816169697575c5 files f195e1bfb415df25 1b 00 1-32: 0101 1b 00 33-176: 06000078e768bc190000 [Q] -1b 00 packets 617c36223b95db65 files f195e1bfb415df25 +1b 00 packets c1a82a1e1bae0905 files f195e1bfb415df25 1b 01 1-32: 0101 1b 01 33-176: 0102 1b 01 packets b5fd26618458b725 files f195e1bfb415df25 @@ -375,7 +375,7 @@ 1b ff packets b5fd26618458b725 files f195e1bfb415df25 1b 00/5a 1-32: 0101 1b 00/5a 33-176: 06000078e768bc190000 [Q] -1b 00/5a packets 617c36223b95db65 files f195e1bfb415df25 +1b 00/5a packets c1a82a1e1bae0905 files f195e1bfb415df25 1c 00 1-32: 0101 1c 00 33-176: 0102 1c 00 packets b5fd26618458b725 files f195e1bfb415df25 @@ -964,7 +964,7 @@ 27 00 4: 8b00a0a1a2a3a4a5 27 00 5-35: 0101 27 00 36-176: 06000078e768bc190000 [Q] -27 00 packets 8c2b2b2703ad3c61 files f195e1bfb415df25 +27 00 packets c2f2f6c684c175e7 files f195e1bfb415df25 27 01 1-3: 0101 27 01 4: 8b00a0a1a2a3a4a5 27 01 5-35: 0101 @@ -984,7 +984,7 @@ 27 00/5a 4: 8b00a0a1a2a3a4a5 27 00/5a 5-35: 0101 27 00/5a 36-176: 06000078e768bc190000 [Q] -27 00/5a packets 8c2b2b2703ad3c61 files f195e1bfb415df25 +27 00/5a packets c2f2f6c684c175e7 files f195e1bfb415df25 28 00 1-176: 15 28 00 packets b5fd26618458b725 files f195e1bfb415df25 28 01 1-176: 15 @@ -1106,7 +1106,7 @@ 32 00 142-157: 06000078e768dc7a0000 [Q] 32 00 158-173: 06000078e768bc880000 [Q] 32 00 174-176: 06000078e7689c960000 [Q] -32 00 packets fc24cf834a5057ed files f195e1bfb415df25 +32 00 packets c741a700ce968c41 files f195e1bfb415df25 32 01 1-33: 0101 32 01 34-176: 0102 32 01 packets b5fd26618458b725 files f195e1bfb415df25 @@ -1127,7 +1127,7 @@ 32 00/5a 142-157: 06000078e768dc7a0000 [Q] 32 00/5a 158-173: 06000078e768bc880000 [Q] 32 00/5a 174-176: 06000078e7689c960000 [Q] -32 00/5a packets fc24cf834a5057ed files f195e1bfb415df25 +32 00/5a packets c741a700ce968c41 files f195e1bfb415df25 33 00 1-176: 0101 33 00 packets b5fd26618458b725 files f195e1bfb415df25 33 01 1-176: 0101 @@ -1140,7 +1140,7 @@ 33 00/5a packets b5fd26618458b725 files f195e1bfb415df25 34 00 1-33: 0101 34 00 34-176: 06010078e768c43e0000 [Q] -34 00 packets 6a3e13be22963d9a files f195e1bfb415df25 +34 00 packets 78703c0eba1ea63e files f195e1bfb415df25 34 01 1-176: 0101 34 01 packets b5fd26618458b725 files f195e1bfb415df25 34 81 1-176: 0101 @@ -1149,7 +1149,7 @@ 34 ff packets b5fd26618458b725 files f195e1bfb415df25 34 00/5a 1-33: 0101 34 00/5a 34-176: 06010078e768c43e0000 [Q] -34 00/5a packets 6a3e13be22963d9a files f195e1bfb415df25 +34 00/5a packets 78703c0eba1ea63e files f195e1bfb415df25 35 00 1-176: 0101 35 00 packets b5fd26618458b725 files f195e1bfb415df25 35 01 1-176: 0101 @@ -1211,7 +1211,7 @@ 39 00 142-157: 06000078e768be950000 [Q] 39 00 158-165: 06000078e7689ea30000 [Q] 39 00 166-176: 0103 -39 00 packets e878a09a4c9f36e5 files f195e1bfb415df25 +39 00 packets 08dd0aa1fcee62ed files f195e1bfb415df25 39 01 1-33: 0101 39 01 34-45: 06000078e7689e340000 [Q] 39 01 46-61: 06000078e7687e420000 [Q] @@ -1223,7 +1223,7 @@ 39 01 142-157: 06000078e768be950000 [Q] 39 01 158-165: 06000078e7689ea30000 [Q] 39 01 166-176: 0103 -39 01 packets a3397a1b28a6c44d files f195e1bfb415df25 +39 01 packets f0f32abcc77b7cb1 files f195e1bfb415df25 39 81 1-33: 0101 39 81 34-45: 06000078e7689e340000 [Q] 39 81 46-61: 06000078e7687e420000 [Q] @@ -1235,7 +1235,7 @@ 39 81 142-157: 06000078e768be950000 [Q] 39 81 158-165: 06000078e7689ea30000 [Q] 39 81 166-176: 0103 -39 81 packets 5238752c6ba4ca4d files f195e1bfb415df25 +39 81 packets 6dae462d85136c31 files f195e1bfb415df25 39 ff 1-33: 0101 39 ff 34-45: 06000078e7689e340000 [Q] 39 ff 46-61: 06000078e7687e420000 [Q] @@ -1247,7 +1247,7 @@ 39 ff 142-157: 06000078e768be950000 [Q] 39 ff 158-165: 06000078e7689ea30000 [Q] 39 ff 166-176: 0103 -39 ff packets 381a33b5b15bc49d files f195e1bfb415df25 +39 ff packets 12d26746829f9b71 files f195e1bfb415df25 39 00/5a 1-33: 0101 39 00/5a 34-45: 06000078e7689e340000 [Q] 39 00/5a 46-61: 06000078e7687e420000 [Q] @@ -1259,7 +1259,7 @@ 39 00/5a 142-157: 06000078e768be950000 [Q] 39 00/5a 158-165: 06000078e7689ea30000 [Q] 39 00/5a 166-176: 0103 -39 00/5a packets e878a09a4c9f36e5 files f195e1bfb415df25 +39 00/5a packets 08dd0aa1fcee62ed files f195e1bfb415df25 3a 00 1: 0101 3a 00 2-176: 00 [F] 3a 00 packets b5fd26618458b725 files 77dbac27de29b1ca @@ -1312,7 +1312,7 @@ 3e 01 1-5: 0106 3e 01 6-171: 00 [Q] 3e 01 172-176: 0106 -3e 01 packets 34db05b6aed41ba9 files f195e1bfb415df25 +3e 01 packets 13b7d8f1947de6f6 files f195e1bfb415df25 3e 81 1-7: 0106 3e 81 8-176: 0102 3e 81 packets b5fd26618458b725 files f195e1bfb415df25 From df5c08b009dbb96c2759d9c67a8ecd5d75ee39b2 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 23:11:25 +0200 Subject: [PATCH 135/136] test(companion): compare encrypted packets byte for byte again With a deterministic cipher mock the payload of an encrypted packet no longer depends on the allocator, so NoCommandReadsThePreviousFrame can compare every queued packet in full instead of its shape. --- .../test_frame_bounds.cpp | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/test/test_companion_frames/test_frame_bounds.cpp b/test/test_companion_frames/test_frame_bounds.cpp index bc611d586bb..dccd6589c1c 100644 --- a/test/test_companion_frames/test_frame_bounds.cpp +++ b/test/test_companion_frames/test_frame_bounds.cpp @@ -201,28 +201,6 @@ std::vector catalog() { return c; } -// A queued packet as text. The host's AES128 mock writes nothing, so the payload -// of an encrypted packet (and its MAC, and a transport code computed over it) is -// whatever the packet buffer held before, which depends on the allocator: on Linux -// it held bytes of an earlier node's frames. For those types only the header, the -// route, the path and the payload length are compared; their content cannot show -// a frame byte on the host anyway. Plain types are compared byte for byte. -std::string packetLine(const Bytes& p) { - uint8_t type = (p[0] >> 2) & 0x0F; - bool encrypted = type <= 0x02 || (type >= 0x05 && type <= 0x08); - if (!encrypted) return hex(p); - uint8_t route = p[0] & 0x03; - size_t i = 1 + ((route == 0 || route == 3) ? 4 : 0); - uint8_t path_len = p[i]; - size_t path_bytes = (path_len & 63) * ((path_len >> 6) + 1); - size_t payload_at = i + 1 + path_bytes; - std::ostringstream o; - o << hex(Bytes(p.begin(), p.begin() + 1)) << " route " << (int)route << " path " - << hex(Bytes(p.begin() + i, p.begin() + std::min(payload_at, p.size()))) - << " payload " << (p.size() > payload_at ? p.size() - payload_at : 0) << " bytes"; - return o.str(); -} - // What one frame did, as text: replies, packets, files, and the counters of the // collaborators, then what a few later steps show. /channels2 is left out, because CMD_SET_CHANNEL writes stack // bytes behind the channel name there (see test_frame_sweep.cpp); the channel's @@ -234,7 +212,7 @@ std::string outcome(Harness& h, const Bytes& f) { uint32_t rtc = rtc_clock.now; std::ostringstream o; for (const Bytes& r : h.send(f)) o << "reply " << hex(r) << "\n"; - for (const Bytes& p : h.mesh().outbound()) o << "packet " << packetLine(p) << "\n"; + for (const Bytes& p : h.mesh().outbound()) o << "packet " << hex(p) << "\n"; for (auto& file : h.fs.files) if (file.first != "/channels2") o << "file " << file.first << " " << hex(file.second) << "\n"; // State that only shows later: a contact listing still running (it writes one @@ -250,7 +228,7 @@ std::string outcome(Harness& h, const Bytes& f) { putU32(chat, RTC0); putStr(chat, "x"); for (const Bytes& r : h.send(chat)) o << "flood " << hex(r) << "\n"; - for (const Bytes& p : h.mesh().outbound()) o << "queued " << packetLine(p) << "\n"; + for (const Bytes& p : h.mesh().outbound()) o << "queued " << hex(p) << "\n"; for (auto& e : g_events) o << "event " << e << "\n"; o << "reboot " << board.reboot_calls - reboots << " params " << radio_driver.set_params_calls - params << " power " << radio_driver.tx_power_calls - power << " rtc " << rtc_clock.now - rtc From 8ebd390e3e2d7ea4862ef31fcbab459902bdef66 Mon Sep 17 00:00:00 2001 From: Marcel Verdult Date: Wed, 21 Oct 2026 23:11:26 +0200 Subject: [PATCH 136/136] docs(refactoring): record the fix of open finding 3 of note 0010 --- .../0010-companion-command-table.md | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/refactoring/0010-companion-command-table.md b/docs/refactoring/0010-companion-command-table.md index 1aa43ead3a8..d2842db9388 100644 --- a/docs/refactoring/0010-companion-command-table.md +++ b/docs/refactoring/0010-companion-command-table.md @@ -744,7 +744,7 @@ again against the handlers of `68e0fcdc` after the step (60 of 60 pass there too more loops, a `GET_CHANNEL` and a channel flood show afterwards, must be identical. Encrypted packets are compared by header, route, path and payload length, since the host's cipher mock leaves their content undefined (open - finding 3). + finding 3; since the mock was fixed, every packet is compared byte for byte). - `OneByteBelowEachMinimum`: for each of the 44 rows with a minimum above 1, the catalog frame at the minimum minus one byte answers `UNSUPPORTED` (`SEND_CHANNEL_DATA`: `ILLEGAL_ARG`) behind every tail and queues nothing; and the @@ -932,5 +932,24 @@ Each needs its own fix and proposal; 10c leaves the code as it is. giving the mock a deterministic output means regenerating the golden file, a change of its own. `test_frame_bounds.cpp` compares encrypted packets by header, route, path and payload length only. + + **Fixed after 10c** (branch `test/deterministic-aes-mock`): the mock XORs + each block with the key, so the output depends only on key and input, and + `decryptBlock()` undoes `encryptBlock()`. Measured before the fix: with every + fresh `malloc` block prefilled (macOS: an interposed `malloc`, fill `a5` and + `3c`; Linux container, glibc 2.41: `MALLOC_PERTURB_=165` and `60`) the sweep + differs in 20 lines, all of them `packets` digests, for every sending command + that encrypts: codes 2, 3, 26, 27, 39 and also 50, 52, 57, 62 (`02 03 1a 1b + 27 32 34 39 3e` in the golden file). Only two environments reach the mock at + run time (checked with an `abort()` in it): `native_companion` and + `native_chat`, whose group message test asserts payload lengths only. The + golden file was regenerated; its diff is exactly those 20 `packets` digests, + with code, pass and `files` digest unchanged and no reply line touched. + After the fix the sweep is identical under every fill on both systems, and + `test_frame_bounds.cpp` compares every packet byte for byte again + (`packetLine()` is gone), on macOS and Linux and under every fill. That + comparison does see the leftovers: with the old mock it fails + `NoCommandReadsThePreviousFrame` in the Linux container, as the first CI run + of 10c did. All 17 host environments: 616 of 616 on macOS and on Linux. 4. **`SEND_TRACE_PATH`'s upper bound is dead code.** `len - 10 >= MAX_PACKET_PAYLOAD - 5` needs 189 bytes; a frame has at most 176.