From a640d44b5cf39b13be3ceffa3e2ec28b133617f0 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 27 Jul 2026 09:54:53 -0400 Subject: [PATCH 01/30] Add T-Beam BPF - 144-148 Mhz LoRa @ ~37 dBm (5 Watts) (#10558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add T-Beam BPF (144-148 Mhz LoRa) * minor correction to fix compiler warnings * Add T-Beam BPF (144-148 Mhz LoRa) * minor correction to fix compiler warnings * Add ITU regions for this device and make GPS work. * Switch pin after defining it as output Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Lora CS is indeed 1, SD Card CS is 10 * Include the back option. * Fix compilation with pioarduino (USB_MODE) * Default ham to narrow_fast * Default PROFILE_HAM to slot 17 This is an appropriate default in the USA but not the EU. The slot override really should follow the region itself, not the regionprofile. * Fix for ITU 2/3 split * Add ITU region options to MenuAction enum * Add HAS_HAM_2M definition to variant headers for 2M support * Re-add PROFILE_HAM regionprofile Accidentally removed in last merge * Trunk fmt * Initial default slots * Switch to TinyFast Still need to flesh out the default channels * Adjust slotOverrides for TinyFast * RadioLib doesn't accept 15.625 kHz Use 15.6 instead * Set RF95 pins for T-Beam Supreme May cause regressions!! * Remove other-variant changes (BPF-only) These have been moved to other PRs * Remove mismatch guarding (we need a more comprehensive approach) * Add comment back * Add template for PA curve * This is a 5-6W radio!! Add TX_GAIN_LORA * Trunk fmt because NomDeTom hates emdashes --------- Co-authored-by: Thomas Göttgens Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- boards/t-beam-bpf.json | 39 ++++++++++++ src/Power.cpp | 16 +++++ src/graphics/draw/MenuHandler.cpp | 11 ++++ src/mesh/NodeDB.cpp | 7 +++ src/mesh/RF95Interface.cpp | 9 +++ src/platform/esp32/architecture.h | 2 + variants/esp32s3/t-beam-bpf/pins_arduino.h | 26 ++++++++ variants/esp32s3/t-beam-bpf/platformio.ini | 23 +++++++ variants/esp32s3/t-beam-bpf/variant.h | 70 ++++++++++++++++++++++ 9 files changed, 203 insertions(+) create mode 100644 boards/t-beam-bpf.json create mode 100644 variants/esp32s3/t-beam-bpf/pins_arduino.h create mode 100644 variants/esp32s3/t-beam-bpf/platformio.ini create mode 100644 variants/esp32s3/t-beam-bpf/variant.h diff --git a/boards/t-beam-bpf.json b/boards/t-beam-bpf.json new file mode 100644 index 000000000..50afb7900 --- /dev/null +++ b/boards/t-beam-bpf.json @@ -0,0 +1,39 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DLILYGO_TBEAM_BPF", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "t-beam-bpf" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino"], + "name": "LilyGo TBeam-BPF", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "speed": 921600 + }, + "url": "http://www.lilygo.cn/", + "vendor": "LilyGo" +} diff --git a/src/Power.cpp b/src/Power.cpp index 3a2bea10c..350208dcd 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -1401,6 +1401,22 @@ bool Power::axpChipInit() PMU->disablePowerOutput(XPOWERS_DLDO1); // Invalid power channel, it does not exist PMU->disablePowerOutput(XPOWERS_DLDO2); // Invalid power channel, it does not exist PMU->disablePowerOutput(XPOWERS_VBACKUP); + } else if (HW_VENDOR == meshtastic_HardwareModel_TBEAM_BPF) { + // T-Beam BPF rail map (per schematic LilyGo_TBeam_BPF r2025-05-08): + // DCDC1 -> ESP32 + OLED 3V3 (always on, protected) + // ALDO2 -> MicroSD 3V3 (OFF at reset, must enable) + // ALDO4 -> L76K GNSS 3V3 (OFF at reset, must enable) + // ALDO1/3, BLDO1/2, DLDO1 -> user headers / unused at boot, leave at reset defaults. + // LoRa power is outside the PMU (external P-MOSFET switched by RF95_POWER_EN / IO16). + PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 3300); + PMU->enablePowerOutput(XPOWERS_ALDO4); + + PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); + PMU->enablePowerOutput(XPOWERS_ALDO2); + + // Make sure nothing's driving into an unused rail + PMU->disablePowerOutput(XPOWERS_DCDC5); + PMU->disablePowerOutput(XPOWERS_DLDO1); } // disable all axp chip interrupt diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index b65917dad..6625d4303 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -227,6 +227,16 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool void menuHandler::LoraRegionPicker(uint32_t duration) { +#ifdef HAS_HAM_2M_ONLY + // Hardware is restricted to the amateur 2m band - offer only the 2m regions + // so the user cannot pick a sub-GHz region the RF path cannot emit or receive. + static const LoraRegionOption regionOptions[] = { + {"Back", OptionsAction::Back}, + {"ITU1_2M (144-146)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M}, + {"ITU2_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU2_2M}, + {"ITU3_2M (144-148)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_2M}, + }; +#else static const LoraRegionOption regionOptions[] = { {"Back", OptionsAction::Back}, {"US", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_US}, @@ -265,6 +275,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration) {"ITU3_70CM (430-450)", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM}, }; +#endif constexpr size_t regionCount = sizeof(regionOptions) / sizeof(regionOptions[0]); static std::array regionLabels{}; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e9e8f7d34..9ee69636d 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1960,6 +1960,13 @@ void NodeDB::installDefaultDeviceState() memcpy(owner.macaddr, ourMacAddr, sizeof(owner.macaddr)); owner.has_is_unmessagable = true; owner.is_unmessagable = false; + +#ifdef HAS_HAM_2M_ONLY + // Ham-band-only hardware defaults to licensed operation. The user can still flip this off later + // (e.g. a commercial operator on an adjacent allocation who wants to keep encryption on) - we + // only set the default here, not on every boot. + owner.is_licensed = true; +#endif } // We reserve a few nodenums for future use diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 65549207d..cbd792878 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -113,6 +113,11 @@ void RF95Interface::setTransmitEnable(bool txon) /// \return true if initialisation succeeded. bool RF95Interface::init() { +#ifdef RF95_POWER_EN + pinMode(RF95_POWER_EN, OUTPUT); + digitalWrite(RF95_POWER_EN, HIGH); +#endif + RadioLibInterface::init(); #if defined(RADIOMASTER_900_BANDIT_NANO) || defined(RADIOMASTER_900_BANDIT) @@ -335,6 +340,10 @@ bool RF95Interface::sleep() setStandby(); // First cancel any active receiving/sending lora->sleep(); +#ifdef RF95_POWER_EN + digitalWrite(RF95_POWER_EN, LOW); +#endif + #ifdef RF95_FAN_EN digitalWrite(RF95_FAN_EN, 0); #endif diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index e58a9b520..4cc4461cf 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -198,6 +198,8 @@ #define HW_VENDOR meshtastic_HardwareModel_T_DECK_PRO #elif defined(T_BEAM_1W) #define HW_VENDOR meshtastic_HardwareModel_TBEAM_1_WATT +#elif defined(T_BEAM_BPF) +#define HW_VENDOR meshtastic_HardwareModel_TBEAM_BPF #elif defined(T_LORA_PAGER) #define HW_VENDOR meshtastic_HardwareModel_T_LORA_PAGER #elif defined(HELTEC_V4) diff --git a/variants/esp32s3/t-beam-bpf/pins_arduino.h b/variants/esp32s3/t-beam-bpf/pins_arduino.h new file mode 100644 index 000000000..195ef2896 --- /dev/null +++ b/variants/esp32s3/t-beam-bpf/pins_arduino.h @@ -0,0 +1,26 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +// UART1 (qwiic) +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +// I2C for OLED and sensors +static const uint8_t SDA = 8; +static const uint8_t SCL = 9; + +// Default SPI mapped to Radio/SD +static const uint8_t SS = 1; // LoRa CS +static const uint8_t MOSI = 11; +static const uint8_t MISO = 13; +static const uint8_t SCK = 12; + +// SD Card CS +#define SDCARD_CS 10 + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/t-beam-bpf/platformio.ini b/variants/esp32s3/t-beam-bpf/platformio.ini new file mode 100644 index 000000000..ef12d0ba8 --- /dev/null +++ b/variants/esp32s3/t-beam-bpf/platformio.ini @@ -0,0 +1,23 @@ +; LilyGo T-Beam-BPF (144-148Mhz) ~5 Watt (37 dBm) +[env:t-beam-bpf] +custom_meshtastic_hw_model = 124 +custom_meshtastic_hw_model_slug = TBEAM_BPF +custom_meshtastic_architecture = esp32s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 3 +custom_meshtastic_display_name = LILYGO T-Beam BPF +custom_meshtastic_images = tbeam-1w.svg +custom_meshtastic_tags = LilyGo + +extends = esp32s3_base +board = t-beam-bpf +board_build.partitions = default_16MB.csv +board_check = true + +lib_deps = + ${esp32s3_base.lib_deps} + +build_flags = + ${esp32s3_base.build_flags} + -I variants/esp32s3/t-beam-bpf + -D T_BEAM_BPF diff --git a/variants/esp32s3/t-beam-bpf/variant.h b/variants/esp32s3/t-beam-bpf/variant.h new file mode 100644 index 000000000..d4f316ae8 --- /dev/null +++ b/variants/esp32s3/t-beam-bpf/variant.h @@ -0,0 +1,70 @@ +// LilyGo T-Beam-BPF variant.h +// Configuration based on LilyGO utilities.h and RF documentation + +// Hardware is restricted to the amateur 2m band (144-148 MHz). +#define HAS_HAM_2M_ONLY 1 + +// I2C for OLED display (SH1106 at 0x3C) +#define I2C_SDA 8 +#define I2C_SCL 9 + +// GPS - Quectel L76K. Per schematic sheet 7: + +#define GPS_RX_PIN 5 +#define GPS_TX_PIN 6 +#define GPS_1PPS_PIN 7 +#define HAS_GPS 1 +#define GPS_BAUDRATE 9600 + +// Buttons +#define BUTTON_PIN 0 // BUTTON 1 +#define ALT_BUTTON_PIN 3 // BUTTON 2 + +// SPI (shared by LoRa and SD) +#define SPI_MOSI 11 +#define SPI_SCK 12 +#define SPI_MISO 13 +#define SPI_CS 10 + +// SD Card +#define HAS_SDCARD +#define SDCARD_USE_SPI1 +// #define SDCARD_CS SPI_CS (already defined in pins_arduino.h) + +// LoRa Radio - SX1278 144-148Mhz +#define USE_RF95 +#define LORA_SCK SPI_SCK +#define LORA_MISO SPI_MISO +#define LORA_MOSI SPI_MOSI +#define LORA_CS 1 +#define LORA_RESET 18 +#define LORA_IRQ 14 +#define LORA_DIO0 LORA_IRQ +#define LORA_DIO1 21 +#define RF95_RXEN 39 // LNA enable - HIGH during RX + +// CRITICAL: Radio power enable - MUST be HIGH before lora.begin()! +// GPIO 16 powers the SX1278 via LDO +#define RF95_POWER_EN 16 + +// +27dBm PA +// LilyGo Docs specify SX1278 power must be capped at 10 +#define RF95_MAX_POWER 10 +// PA curve mapping (TX values 1-10) +// Measurements taken with 5V USB power (max 37 dBm on battery power) +// Input values: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 +// Output values: 0, 34, 35, 36, 36, 37, 37, 38, 38, 38 +#define NUM_PA_POINTS 10 +#define TX_GAIN_LORA 0, 32, 32, 32, 31, 31, 30, 30, 29, 28 + +// Display - SH1106 OLED (128x64) +#define USE_SH1106 +#define OLED_WIDTH 128 +#define OLED_HEIGHT 64 + +// 32768 Hz crystal present +#define HAS_32768HZ 1 + +// PMU +#define HAS_AXP2101 +// #define PMU_IRQ 4 // Leave disabled for now From f61f65891c6177fd9d1c341b18dabbcbe45b4d69 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 27 Jul 2026 10:20:57 -0500 Subject: [PATCH 02/30] fix(promicro): exclude emoji to fit under the warm-store region (#11256) nrf52_promicro_diy_tcxo has been failing the nrf52 warm-region guard on develop: the image ends at 0xEA0C0, 192 bytes past the 12 KB WarmNodeStore record-ring reserved at 0xEA000. This variant compiles four radio driver families (SX126x/LLCC68, SX127x/RF95, LR11x0/LR1121, LR2021) so any module can be soldered on, which makes it the largest nrf52 image we ship. Building it with -D EXCLUDE_EMOJI saves 6,808 bytes and puts the image at 0xE8618, 6.6 KB clear of the warm region. EXCLUDE_EMOJI was not previously usable: graphics::emotes[] becomes empty, but the canned-message emote picker never checked for that. Entering the picker clamped emotePickerIndex to numEmotes - 1 (i.e. -1), and selecting read emotes[-1].label into a String - an out-of-bounds read of whatever precedes the array in flash. Guard both entry points instead: refuse to open the picker when there are no emotes, and bounce back to freetext if the picker state is somehow reached anyway. Under whole-image LTO those guards fold to constants, so the picker draw and input paths dead-strip entirely on builds that set EXCLUDE_EMOJI - which is where the savings come from beyond the bitmap data itself. Received messages containing emoji render as text on this variant, and the emote-list key is a no-op. Verified rak4631 (emoji enabled) is unaffected: 0xE46D8, 22 KB clear. --- src/modules/CannedMessageModule.cpp | 11 +++++++++-- .../diy/nrf52_promicro_diy_tcxo/platformio.ini | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index d86878a8d..24e8339e3 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -880,8 +880,10 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event) // All hardware keys fall through to here (CardKB, physical, etc.) if (event->kbchar == INPUT_BROKER_MSG_EMOTE_LIST) { - updateState(CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER); - screen->forceDisplay(); + if (graphics::numEmotes > 0) { // no picker on EXCLUDE_EMOJI builds (empty emotes[]) + updateState(CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER); + screen->forceDisplay(); + } return true; } // Confirm select (Enter) @@ -965,6 +967,11 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event) int CannedMessageModule::handleEmotePickerInput(const InputEvent *event) { int numEmotes = graphics::numEmotes; + if (numEmotes == 0) { // EXCLUDE_EMOJI: emotes[] is empty, any index would read out of bounds + updateState(CANNED_MESSAGE_RUN_STATE_FREETEXT, true); + screen->forceDisplay(); + return 1; + } // Override isDown and isSelect ONLY for emote picker behavior bool isUp = isUpEvent(event); diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index 82a4e1953..c4cff6b13 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -15,6 +15,7 @@ board = promicro-nrf52840 build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/diy/nrf52_promicro_diy_tcxo -D NRF52_PROMICRO_DIY + -D EXCLUDE_EMOJI ; this variant builds 4 radio driver families and is the largest nrf52 image; emote bitmaps don't fit under the 0xEA000 warm-store cap build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> debug_tool = jlink From ae16c052d5e12ec6e736d9216cb5be10b7da67f9 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 27 Jul 2026 10:22:33 -0500 Subject: [PATCH 03/30] Time out an abandoned admin edit transaction (#11254) begin_edit_settings sets a bool that only the matching commit ever clears. If the commit never arrives -- the client dropped its link partway through a bulk config import, or went away entirely -- the transaction stays open indefinitely, and every later config write from any client is applied to RAM, acknowledged, and then never saved. The writes look successful and are visible in get_config, but the node reverts all of them at the next boot, and only a reboot clears it. Give the transaction a one minute idle timeout. Each deferred write restarts the clock, so the window bounds the gap between writes rather than the length of the edit; a bulk import sends them milliseconds apart. The next admin message after it lapses retires the transaction, persisting the segments it had deferred and flushing the warnings it was holding. The check runs after the auth gates and before the switch, so every case sees consistent state and the recovery's flash write happens in the main loop rather than a disconnect callback. It saves rather than rolls back because there is nothing to roll back to: each write already took effect in RAM and was acknowledged, so the transaction only ever deferred the save. That also makes an early expiry cheap -- the worst case for a client that really was still going is that its remaining writes are saved individually instead of batched. Closing on disconnect instead was tempting, but the reporter's captures show iOS dropping and reconnecting within half a second several times per session, which would abort imports that currently survive the blip. Also drops the file-scope hasOpenEditTransaction, shadowed by the class member at every use site and referenced nowhere else in the tree. Reported in #11245 --- src/modules/AdminModule.cpp | 25 +++++++++++- src/modules/AdminModule.h | 7 ++++ test/support/AdminModuleTestShim.h | 11 +++++- test/test_admin_radio/test_main.cpp | 60 +++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 08b94dc04..98a9c9d93 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -69,7 +69,6 @@ #endif AdminModule *adminModule; -bool hasOpenEditTransaction; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) static bool licensedIdentityWillMigrate() @@ -240,6 +239,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta return handled; } } + // Before the switch, so every case below sees consistent transaction state. + expireStaleEditTransaction(); + switch (r->which_payload_variant) { #ifdef MESHTASTIC_ENCRYPTED_STORAGE @@ -481,12 +483,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta case meshtastic_AdminMessage_begin_edit_settings_tag: { LOG_INFO("Begin transaction for editing settings"); hasOpenEditTransaction = true; + editTransactionActivityMs = millis(); break; } case meshtastic_AdminMessage_commit_edit_settings_tag: { disableBluetooth(); LOG_INFO("Commit transaction for edited settings"); hasOpenEditTransaction = false; + deferredEditSegments = 0; saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE); flushChannelWarnings(); // one coalesced message for everything edited in this transaction break; @@ -1875,6 +1879,23 @@ void AdminModule::reboot(int32_t seconds) rebootAtMsec = (seconds < 0) ? 0 : (millis() + seconds * 1000); } +// Without this, a commit that never arrives leaves the transaction open forever and every later +// config write from any client is applied, acknowledged, and then never saved. +void AdminModule::expireStaleEditTransaction() +{ + if (!hasOpenEditTransaction || Throttle::isWithinTimespanMs(editTransactionActivityMs, EDIT_TRANSACTION_IDLE_MS)) + return; + + LOG_WARN("Edit transaction abandoned for %us; committing what it applied", EDIT_TRANSACTION_IDLE_MS / 1000); + hasOpenEditTransaction = false; + int segments = deferredEditSegments; + deferredEditSegments = 0; + // No reboot: the settings are already live in RAM and the client that would expect one is gone. + if (segments) + saveChanges(segments, false); + flushChannelWarnings(); +} + void AdminModule::saveChanges(int saveWhat, bool shouldReboot) { #ifdef PIO_UNIT_TESTING @@ -1885,6 +1906,8 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot) service->reloadConfig(saveWhat); // Calls saveToDisk among other things } else { LOG_INFO("Delay save of changes to disk until the open transaction is committed"); + editTransactionActivityMs = millis(); // still in use, so not the abandoned kind we time out + deferredEditSegments |= saveWhat; } if (shouldReboot && !hasOpenEditTransaction) { reboot(DEFAULT_REBOOT_SECONDS); diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 020e9f0d6..b8e8c59ad 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -40,6 +40,13 @@ class AdminModule : public ProtobufModule, public Obser private: bool hasOpenEditTransaction = false; + // Each deferred write restarts the clock, so this bounds the gap between writes, not the length + // of the edit; a bulk import sends them milliseconds apart. + static constexpr uint32_t EDIT_TRANSACTION_IDLE_MS = 60 * 1000; + uint32_t editTransactionActivityMs = 0; // millis() of the last save this transaction deferred + int deferredEditSegments = 0; // segments that transaction has touched but not yet saved + /// Retire an open edit transaction whose client stopped talking, persisting what it applied. + void expireStaleEditTransaction(); #ifdef PIO_UNIT_TESTING int lastSaveWhatForTest = 0; #endif diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 1b06457f1..15fe9e782 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -21,9 +21,18 @@ class AdminModuleTestShim : public AdminModule meshtastic_MeshPacket *reply() { return myReply; } // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. - void deferSaves() { hasOpenEditTransaction = true; } + // Stamps the clock like begin_edit_settings, so a slow suite can't age the transaction into expiry. + void deferSaves() + { + hasOpenEditTransaction = true; + editTransactionActivityMs = millis(); + } int savedSegments() const { return lastSaveWhatForTest; } + bool editTransactionOpen() const { return hasOpenEditTransaction; } + // Backdate past the idle window so a test sees an abandoned transaction without waiting it out. + void ageEditTransaction() { editTransactionActivityMs = millis() - EDIT_TRANSACTION_IDLE_MS - 1; } + // Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks. void drainReply() { diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 966cc6859..4b88be701 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -1574,6 +1574,16 @@ static void sendCommitEdit() sendAdmin(m); } +// An admin message that changes nothing. It answers, so drain the reply or the packet pool leaks. +static void sendGetDeviceMetadata() +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_request_tag; + m.get_device_metadata_request = true; + sendAdmin(m); + testAdmin->drainReply(); +} + // Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against. static void usePresetLongFast() { @@ -1640,6 +1650,53 @@ static void test_warn_transaction_singleChannel_keepsSpecificMessage() TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels")); } +// An idle transaction is retired by the next admin message, flushing the warnings it held. +static void test_editTransaction_abandoned_isRetiredOnNextAdminMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + // Deferred, exactly as before: nothing emitted while the transaction looks alive. + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); + + testAdmin->ageEditTransaction(); + sendGetDeviceMetadata(); // any later admin message, from any client + + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +// A write arriving after abandonment is saved, not deferred to a commit that never comes. +static void test_editTransaction_abandoned_laterWriteIsNoLongerDeferred() +{ + usePresetLongFast(); + sendBeginEdit(); + testAdmin->ageEditTransaction(); + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + + // The write itself retired the stale transaction, so its own warning is emitted immediately. + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +// A transaction still in use is left alone: each write refreshes the window. +static void test_editTransaction_active_isNotRetired() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1)); + + TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1")); +} + static void test_warn_license_noTransaction_emittedImmediately() { usePresetLongFast(); @@ -1801,6 +1858,9 @@ void setup() RUN_TEST(test_warn_cleanChannel_noMessage); RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage); RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage); + RUN_TEST(test_editTransaction_abandoned_isRetiredOnNextAdminMessage); + RUN_TEST(test_editTransaction_abandoned_laterWriteIsNoLongerDeferred); + RUN_TEST(test_editTransaction_active_isNotRetired); RUN_TEST(test_warn_license_noTransaction_emittedImmediately); RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage); From 80f972655d4918b16f2a4e3db13131bfb0fa13b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 27 Jul 2026 20:54:57 +0200 Subject: [PATCH 04/30] Null-check reply allocations in allocErrorResponse and RemoteHardware (#11260) allocAckNak returns nullptr when the packet pool is exhausted, but allocErrorResponse passed the result straight to setReplyTo, which dereferences it. Reachable unauthenticated: an ADMIN_APP packet on a known-key channel takes the NOT_AUTHORIZED path, so a flood that empties the pool crashes the node. MemoryDynamic::alloc no longer aborting on failure made this reachable on PSRAM, STM32WL and Portduino targets. Callers already treat a null reply as no response. Same fix for the two RemoteHardwareModule sites. --- src/mesh/MeshModule.cpp | 2 ++ src/modules/RemoteHardwareModule.cpp | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index 5dc7fba4a..d8bff724e 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -87,6 +87,8 @@ meshtastic_MeshPacket *MeshModule::allocErrorResponse(meshtastic_Routing_Error e uint8_t channelIndex = p->which_payload_variant == meshtastic_MeshPacket_decoded_tag ? p->channel : channels.getPrimaryIndex(); auto r = allocAckNak(err, getFrom(p), p->id, channelIndex); + if (!r) // pool exhausted; callers treat a null reply as "no response" + return nullptr; setReplyTo(r, *p); diff --git a/src/modules/RemoteHardwareModule.cpp b/src/modules/RemoteHardwareModule.cpp index ef2b23c8f..bd5e15655 100644 --- a/src/modules/RemoteHardwareModule.cpp +++ b/src/modules/RemoteHardwareModule.cpp @@ -103,8 +103,10 @@ bool RemoteHardwareModule::handleReceivedProtobuf(const meshtastic_MeshPacket &r r.gpio_value = res; r.gpio_mask = p.gpio_mask; meshtastic_MeshPacket *p2 = allocDataProtobuf(r); - setReplyTo(p2, req); - myReply = p2; + if (p2) { + setReplyTo(p2, req); + myReply = p2; + } break; } @@ -149,7 +151,8 @@ int32_t RemoteHardwareModule::runOnce() r.type = meshtastic_HardwareMessage_Type_GPIOS_CHANGED; r.gpio_value = curVal; meshtastic_MeshPacket *p = allocDataProtobuf(r); - service->sendToMesh(p); + if (p) + service->sendToMesh(p); } } } else { From 3e2c98420be001927514764fdadfcb05626203e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 27 Jul 2026 21:51:39 +0200 Subject: [PATCH 05/30] nRF52: don't truncate the AES-CTR block length to uint8_t (#11261) blockLen() rounds numBytes up to the AES block size, so 241..256 returns 256. Stored in a uint8_t that truncates to 0, giving a zero-length encBuf that Process() then writes numBytes bytes into, smashing the stack with attacker-controlled ciphertext. encrypted.size is a 256-byte protobuf field and encryptPacket admits numBytes up to MAX_BLOCKSIZE, so sizes above the 239-byte LoRa frame limit arrive via the MQTT and UDP multicast ingress paths, which pass the packet to the router without clamping. Only the hardware AES path for keys of 16 bytes or less is affected, which includes the default channel. --- src/platform/nrf52/NRF52CryptoEngine.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/platform/nrf52/NRF52CryptoEngine.cpp b/src/platform/nrf52/NRF52CryptoEngine.cpp index 5de13c58b..e8844065f 100644 --- a/src/platform/nrf52/NRF52CryptoEngine.cpp +++ b/src/platform/nrf52/NRF52CryptoEngine.cpp @@ -18,7 +18,10 @@ class NRF52CryptoEngine : public CryptoEngine } else if (_key.length > 0) { nRFCrypto.begin(); nRFCrypto_AES ctx; - uint8_t myLen = ctx.blockLen(numBytes); + // Must not be uint8_t: blockLen() rounds up to the AES block size, so numBytes in + // 241..256 yields 256, which truncates to 0 and leaves Process() writing numBytes + // attacker-controlled bytes onto a zero-length stack buffer. + size_t myLen = ctx.blockLen(numBytes); char encBuf[myLen] = {0}; ctx.begin(); ctx.Process((char *)bytes, numBytes, _nonce, _key.bytes, _key.length, encBuf, ctx.encryptFlag, ctx.ctrMode); From 134fe5ec3ec1266f13bbb5adcb66df5a633b400b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 27 Jul 2026 14:56:23 -0500 Subject: [PATCH 06/30] Stop BLE log streaming from starving the NimBLE notify pool (#11253) sendLog() notified once per firmware log line with no backpressure. With debug_log_api_enabled and a subscribed client, a logging burst drains the msys_1 mbuf pool that every GATT notification and ATT response allocates from, so ble_gatts_notify_custom starts returning BLE_HS_ENOMEM (rc=6) -- for the fromNum doorbell too, not just the log line that exhausted it. Each failure then prints an error over serial at ~12ms apiece, which is itself enough to throttle the main loop during a burst. Register a callback on the log characteristic so the host's own ERROR_GATT verdict pauses log notifies for 250ms and lets the pool refill. Keying off the failure rather than a free-block count keeps this correct however the pools are sized. Also restore CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT to the IDF default of 12. Pool selection is by block size and a notify request always resolves to the smallest pool, so msys_1 is the only pool GATT draws from; 8 was thin once anything chains or fragments. msys_2 and the ACL/EVT transport pools are left trimmed -- neither is on the notify path, and restoring the full defaults would re-spend the contiguous allocation that #10741 was fixing on heap-tight boards. sendLog() also lacked the null check onNowHasData() already has, so a log line arriving during BLE teardown dereferenced a freed characteristic. Fixes #11245 --- src/nimble/NimbleBluetooth.cpp | 23 +++++++++++++++++++++++ variants/esp32/esp32-common.ini | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 647c1bef8..bd3de1601 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -637,6 +637,21 @@ class NimbleBluetoothFromRadioCallback : public BLECharacteristicCallbacks } }; +// One log notify per log line shares the msys_1 mbuf pool with the fromNum doorbell and ATT +// responses, so a logging burst starves them; back off on rejection until the pool refills. +static constexpr uint32_t LOG_NOTIFY_BACKOFF_MS = 250; +static std::atomic lastLogNotifyFailureMs{0}; + +class NimbleBluetoothLogRadioCallback : public BLECharacteristicCallbacks +{ + void onStatus(BLECharacteristic *, Status s, uint32_t) override + { + // ERROR_GATT is the only status meaning the host refused it; the rest never allocated. + if (s == Status::ERROR_GATT) + lastLogNotifyFailureMs.store(millis()); + } +}; + class NimbleBluetoothSecurityCallback : public BLESecurityCallbacks { void onPassKeyNotify(uint32_t passkey) override @@ -1005,6 +1020,9 @@ void NimbleBluetooth::setupService() static NimbleBluetoothFromRadioCallback fromRadioCallbacks; FromRadioCharacteristic->setCallbacks(&fromRadioCallbacks); + static NimbleBluetoothLogRadioCallback logRadioCallbacks; + logRadioCharacteristic->setCallbacks(&logRadioCallbacks); + bleService->start(); // Setup the battery service @@ -1056,6 +1074,11 @@ void NimbleBluetooth::sendLog(const uint8_t *logMessage, size_t length) if (!isConnected() || length > 512) { return; } + if (!logRadioCharacteristic) // BLE may have been torn down; never notify a freed characteristic + return; + // Pool still under pressure; drop this line rather than spend a buffer fromNum needs. + if (Throttle::isWithinTimespanMs(lastLogNotifyFailureMs.load(), LOG_NOTIFY_BACKOFF_MS)) + return; logRadioCharacteristic->setValue(logMessage, length); logRadioCharacteristic->notify(); } diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 72cc1966e..ea7d8d15c 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -291,7 +291,9 @@ custom_sdkconfig = CONFIG_BT_CTRL_BLE_MAX_ACT=2 CONFIG_BT_CTRL_SCAN_DUPL_CACHE_SIZE=10 CONFIG_BT_NIMBLE_WHITELIST_SIZE=1 - CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=8 + # msys_1 is the only pool GATT allocates from, and 8 blocks left no headroom for a notify burst + # (#11245); back to the IDF default of 12 for +1KB. Raising msys_2 would not help. + CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=12 CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=8 CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=8 CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=12 From cdd996248cfaf9346b4d133e4e42dc137de80318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 27 Jul 2026 21:57:39 +0200 Subject: [PATCH 07/30] Release the opaque relay copy the interface declines to send (#11262) relayOpaquePacket() allocates a copy and returns Router::send(relay) == ERRNO_OK, discarding ERRNO_SHOULD_RELEASE. The interface returns that for NODENUM_BROADCAST_NO_LORA, so the copy is never freed and one pool slot leaks per frame. The opaque path is reached for packets on a channel we have no key for, so no key or PSK is needed: a frame with an unknown channel hash, to=NODENUM_BROADCAST_NO_LORA, a nonzero id and hop_limit>0 leaks a slot, and roughly MAX_PACKETS of them exhaust the pool until reboot. #11087 fixed this pattern in perhapsRebroadcast and the retransmission paths but did not cover relayOpaquePacket, which was added separately with the opaque relay path. --- src/mesh/NextHopRouter.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index f1ed831f8..029648e2d 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -27,7 +27,12 @@ bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p) return false; relay->hop_limit--; relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); - return Router::send(relay) == ERRNO_OK; + // The interface declines some packets (NODENUM_BROADCAST_NO_LORA) with ERRNO_SHOULD_RELEASE, + // which leaves the copy ours to free. Dropping it here would leak a pool slot per opaque frame. + ErrorCode res = Router::send(relay); + if (res == ERRNO_SHOULD_RELEASE) + packetPool.release(relay); + return res == ERRNO_OK; } PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions) From dafa583f8b634c06edbe49efaa40253fa05a89db Mon Sep 17 00:00:00 2001 From: vidplace7 Date: Mon, 27 Jul 2026 16:33:48 -0400 Subject: [PATCH 08/30] Actions: Do not build meshtasticd for event/ branches Do not build MacOS, Windows, WASM, or Docker for event branches. --- .github/workflows/main_matrix.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index c3c035244..d7a80fbc6 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -140,7 +140,7 @@ jobs: secrets: inherit MacOS: - if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} + if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} strategy: fail-fast: false matrix: @@ -155,7 +155,7 @@ jobs: # secrets: inherit Windows: - if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} + if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} strategy: fail-fast: false matrix: @@ -182,7 +182,7 @@ jobs: uses: ./.github/workflows/test_native.yml build-wasm: - if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} + if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} # Build the WebAssembly portduino node ([env:native-wasm]) as part of normal CI, # like the other platforms. It's a dedicated job (not a row in the `build` # matrix) because its artifact is meshnode.{mjs,wasm} - not a flashable @@ -191,7 +191,7 @@ jobs: uses: ./.github/workflows/build_portduino_wasm.yml docker: - if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} + if: ${{ !contains(github.ref_name, 'event/') && github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} permissions: # Needed for pushing to GHCR. contents: read packages: write From 5f51bb1c1eb47bdbd1a6a054299fee312ed0f36e Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:09:40 +0200 Subject: [PATCH 09/30] MUI: Portduino curl maps (#11214) * add libcurl, fix missing SDL2 * device-ui commit reference PR353 * add curl dependencies to CI build * switch to gnutls curl version * Update device-ui library dependency URL * Update device-ui dependency to new version * Fix formatting in Dockerfile package installation * Update device-ui library dependency URL --- .github/actions/setup-native/action.yml | 2 +- .github/workflows/build_windows_bin.yml | 1 + Dockerfile | 4 ++-- alpine.Dockerfile | 4 ++-- platformio.ini | 2 +- variants/native/portduino/platformio.ini | 10 ++++++---- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/actions/setup-native/action.yml b/.github/actions/setup-native/action.yml index 3a38a4a22..7ed22b37d 100644 --- a/.github/actions/setup-native/action.yml +++ b/.github/actions/setup-native/action.yml @@ -11,4 +11,4 @@ runs: - name: Install libs needed for native build shell: bash run: | - sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev + sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev libcurl4-gnutls-dev diff --git a/.github/workflows/build_windows_bin.yml b/.github/workflows/build_windows_bin.yml index 5348a1163..8ad2acefd 100644 --- a/.github/workflows/build_windows_bin.yml +++ b/.github/workflows/build_windows_bin.yml @@ -47,6 +47,7 @@ jobs: mingw-w64-ucrt-x86_64-libuv mingw-w64-ucrt-x86_64-jsoncpp mingw-w64-ucrt-x86_64-openssl + mingw-w64-ucrt-x86_64-curl mingw-w64-ucrt-x86_64-libusb mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-ninja diff --git a/Dockerfile b/Dockerfile index a8d886569..3472c9d22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ curl wget g++ zip git ca-certificates pkg-config \ python3-pip python3-grpc-tools \ libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \ - libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \ + libcurl4-gnutls-dev libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \ libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && pip install --no-cache-dir -U platformio \ @@ -55,7 +55,7 @@ USER root RUN apt-get update && apt-get --no-install-recommends -y install \ libc-bin libc6 libgpiod3 libyaml-cpp0.8 libjsoncpp26 libi2c0 libuv1t64 libusb-1.0-0-dev \ - liborcania2.3 libulfius2.7t64 libssl3t64 \ + libcurl4t64 liborcania2.3 libulfius2.7t64 libssl3t64 \ libx11-6 libinput10 libxkbcommon-x11-0 libsdl2-2.0-0 \ && apt-get clean && rm -rf /var/lib/apt/lists/* \ && mkdir -p /var/lib/meshtasticd \ diff --git a/alpine.Dockerfile b/alpine.Dockerfile index e82230d61..8b5ace187 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -17,7 +17,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apk --no-cache add \ bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \ py3-pip py3-grpcio-tools \ - libgpiod-dev yaml-cpp-dev jsoncpp-dev bluez-dev \ + libgpiod-dev yaml-cpp-dev jsoncpp-dev bluez-dev curl-dev \ libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \ libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \ && rm -rf /var/cache/apk/* \ @@ -50,7 +50,7 @@ USER root RUN apk --no-cache add \ shadow libstdc++ libbsd libgpiod yaml-cpp jsoncpp libusb \ - i2c-tools libuv libx11 libinput libxkbcommon sdl2 \ + libcurl i2c-tools libuv libx11 libinput libxkbcommon sdl2 \ && rm -rf /var/cache/apk/* \ && mkdir -p /var/lib/meshtasticd \ && mkdir -p /etc/meshtasticd/config.d \ diff --git a/platformio.ini b/platformio.ini index 2fea56967..22348ec30 100644 --- a/platformio.ini +++ b/platformio.ini @@ -128,7 +128,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/ef573c368767625ffbe8c32cf921ea7366f2dd53.zip + https://github.com/meshtastic/device-ui/archive/2f5a7dd9c9af19b6f3a2ddab1477f75aceae281b.zip ; Common libs for environmental measurements in telemetry module [environmental_base] diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 22ac4ff5f..22fb6223c 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -32,7 +32,7 @@ build_type = release lib_deps = ${native_base.lib_deps} ${device-ui_base.lib_deps} -build_flags = ${native_base.build_flags} -Os -lX11 -linput -lxkbcommon -ffunction-sections -fdata-sections -Wl,--gc-sections +build_flags = ${native_base.build_flags} -Os -lcurl -lX11 -linput -lxkbcommon -ffunction-sections -fdata-sections -Wl,--gc-sections -D RAM_SIZE=16384 -D USE_X11=1 -D HAS_TFT=1 @@ -60,7 +60,7 @@ build_type = release lib_deps = ${native_base.lib_deps} ${device-ui_base.lib_deps} -build_flags = ${native_base.build_flags} -Os -ffunction-sections -fdata-sections -Wl,--gc-sections +build_flags = ${native_base.build_flags} -Os -ffunction-sections -fdata-sections -lcurl -Wl,--gc-sections -D RAM_SIZE=8192 -D USE_FRAMEBUFFER=1 -D LV_COLOR_DEPTH=32 @@ -79,6 +79,7 @@ build_flags = ${native_base.build_flags} -Os -ffunction-sections -fdata-sections -D MAP_FULL_REDRAW !pkg-config --libs libulfius --silence-errors || : !pkg-config --libs openssl --silence-errors || : + !pkg-config --cflags --libs sdl2 --silence-errors || : !pkg-config --cflags --libs libbsd-overlay --silence-errors || : build_src_filter = ${native_base.build_src_filter} @@ -89,12 +90,12 @@ build_type = debug lib_deps = ${native_base.lib_deps} ${device-ui_base.lib_deps} -build_flags = ${native_base.build_flags} -O0 -fsanitize=address -lX11 -linput -lxkbcommon +build_flags = ${native_base.build_flags} -g -O0 -fsanitize=address -lcurl -lX11 -linput -lxkbcommon -D DEBUG_HEAP -D RAM_SIZE=16384 -D USE_X11=1 -D HAS_TFT=1 - -D HAS_SCREEN=0 + -D HAS_SCREEN=1 -D LV_CACHE_DEF_SIZE=6291456 -D LV_BUILD_TEST=0 -D LV_USE_LOG=1 @@ -112,6 +113,7 @@ build_flags = ${native_base.build_flags} -O0 -fsanitize=address -lX11 -linput -l -D VIEW_320x240 !pkg-config --libs libulfius --silence-errors || : !pkg-config --libs openssl --silence-errors || : + !pkg-config --cflags --libs sdl2 --silence-errors || : !pkg-config --cflags --libs libbsd-overlay --silence-errors || : build_src_filter = ${env:native-tft.build_src_filter} From 2a20ee59544989a227d7f9ebb7d1d9863419691d Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Jul 2026 08:47:46 -0500 Subject: [PATCH 10/30] ESP32: link via @response files across the family, not just P4 The rak_wismesh_tap_v2-tft link failed on CI with "sh: Argument list too long": the device-ui bump in #11214 grew the MUI object list enough that the linker command line crossed the kernel exec arg limit (this env's long name lengthens every object path, so it tipped first). extra_scripts/ld_response_file.py already solves this for esp32p4; promote it to esp32_common so every ESP32 target links through a response file, and drop the now-inherited entry from esp32p4.ini. Verified by building rak_wismesh_tap_v2-tft at the failing commit plus this fix: links clean (macOS's exec arg limit is smaller than the Linux one that tripped CI, so an unwrapped link could not have passed). --- variants/esp32/esp32-common.ini | 3 +++ variants/esp32p4/esp32p4.ini | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index ea7d8d15c..669e6ed17 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -17,6 +17,9 @@ extra_scripts = ${env.extra_scripts} pre:extra_scripts/esp32_pre.py extra_scripts/esp32_extra.py + ; Spill long link commands into @response files. Big MUI/TFT images exceed the kernel's + ; exec arg limit ("sh: Argument list too long"; rak_wismesh_tap_v2-tft hit it first). + extra_scripts/ld_response_file.py build_src_filter = ${arduino_base.build_src_filter} + - - diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 5ad136806..8a284162d 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -15,9 +15,10 @@ build_flags = build_src_filter = ${esp32_common.build_src_filter} - + - - +; ld_response_file.py is inherited from esp32_common (promoted there when +; rak_wismesh_tap_v2-tft outgrew the exec arg limit; it originally shipped here). extra_scripts = ${esp32_common.extra_scripts} - extra_scripts/ld_response_file.py ; Override esp32_common component pruning: keep esp_hosted + esp_wifi_remote for P4 hosted BT custom_component_remove = From a8623a60c52e8cacef7521c8733552482f182d40 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Jul 2026 09:17:09 -0500 Subject: [PATCH 11/30] Stream our own position to the phone/UI while mesh position sharing is opt-in (#11270) * Position: stream our own position to the phone/UI while mesh sharing is opt-in Position broadcasts became opt-in in 2.8 (#10929): with every public channel at position_precision 0, sendOurPosition() finds no eligible channel and returns without queueing anything, so the connected phone or on-device UI never sees the node's own GPS fix ("GPS looks dead" on standalone MUI devices even though the receiver has a lock). Mirror device telemetry's local delivery: once a minute, when the toPhone queue is idle, stream our own position to the connected client at full precision. The packet is handed straight to sendToPhone() and never touches the mesh, so the per-channel opt-in and the public-channel precision clamp still govern everything on the air. Also stop logging "Send pos ... to mesh" before the channel scan has found an eligible channel; when sharing is disabled everywhere the skip is now logged explicitly instead of pretending a send happened. The cadence gate is a pure static (shouldSendPositionToPhone) alongside the existing broadcast-policy helpers, with unit tests covering the first-send, cadence, gating, and millis() rollover cases. * Review: only advance phone cadence on a queued packet; drop the ms==0 sentinel sendOurPositionToPhone() now reports whether a packet actually reached the phone queue, and runOnce() stamps the cadence only on success, so a guard or allocation failure retries on the next tick instead of waiting out a minute. The never-sent state is a dedicated hasSentPositionToPhone flag rather than lastPhoneSendMs == 0, so a send stamped exactly at millis() == 0 still holds the cadence. New regression test covers that case; existing cases updated to the explicit flag. * Review: align the rollover test fixture with its documented elapsed times lastSent now sits exactly 30,000 ms before the uint32 wrap, so the two cases are precisely 70,000 ms (sends) and 40,000 ms (held) - the previous comments claimed 70s/20s against actual elapsed values of 70,001/40,001 ms. --- src/modules/PositionModule.cpp | 57 +++++++++++++++++++++---- src/modules/PositionModule.h | 15 ++++++- test/test_position_module/test_main.cpp | 57 +++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 9 deletions(-) diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 40055006a..cb08503e4 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -167,9 +167,9 @@ bool PositionModule::hasGPS() } // Allocate a packet with our position data if we have one -meshtastic_MeshPacket *PositionModule::allocPositionPacket() +meshtastic_MeshPacket *PositionModule::allocPositionPacket(uint32_t atPrecision) { - if (precision == 0) { + if (atPrecision == 0) { LOG_DEBUG("Skip location send because precision is set to 0!"); return nullptr; } @@ -196,12 +196,12 @@ meshtastic_MeshPacket *PositionModule::allocPositionPacket() } // lat/lon are unconditionally included - IF AVAILABLE! - LOG_DEBUG("Send location with precision %i", precision); + LOG_DEBUG("Send location with precision %i", atPrecision); p.latitude_i = localPosition.latitude_i; p.longitude_i = localPosition.longitude_i; p.has_latitude_i = true; p.has_longitude_i = true; - applyPositionPrecision(p, precision); + applyPositionPrecision(p, atPrecision); // Always use NTP / GPS time if available if (getValidTime(RTCQualityNTP) > 0) { p.time = getValidTime(RTCQualityNTP); @@ -281,7 +281,7 @@ meshtastic_MeshPacket *PositionModule::allocReply() return nullptr; } - meshtastic_MeshPacket *reply = allocPositionPacket(); + meshtastic_MeshPacket *reply = allocPositionPacket(precision); if (reply) { lastSentReply = millis(); // Track when we sent this reply } @@ -373,13 +373,34 @@ void PositionModule::sendOurPosition() currentGeneration = radioGeneration; // If we changed channels, ask everyone else for their latest info - LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); for (uint8_t channelNum = 0; channelNum < 8; channelNum++) { if (getPositionPrecisionForChannel(channelNum) != 0) { + LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); sendOurPosition(NODENUM_BROADCAST, requestReplies, channelNum); return; } } + LOG_INFO("Skip pos@%x:6 broadcast; position sharing is opt-in and disabled on all channels", localPosition.timestamp); +} + +// Position broadcasts are opt-in per channel in 2.8, but our own position still plays to the +// connected phone/UI at full precision, like telemetry does. This copy never touches the mesh. +// Returns true only when a packet was actually handed to the phone queue, so the caller can +// hold off the cadence stamp (and retry soon) after a guard or allocation failure. +bool PositionModule::sendOurPositionToPhone() +{ + if (!config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot()) + return false; // Same stale-restored-position guard as sendOurPosition() + + meshtastic_MeshPacket *p = allocPositionPacket(32); + if (p == nullptr) + return false; + + p->to = NODENUM_BROADCAST; + p->decoded.want_response = false; + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + service->sendToPhone(p); + return true; } void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t channel) @@ -396,7 +417,7 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha // Set the class precision value for this particular packet. precision = getPositionPrecisionForChannel(channel); - meshtastic_MeshPacket *p = allocPositionPacket(); + meshtastic_MeshPacket *p = allocPositionPacket(precision); if (p == nullptr) { LOG_DEBUG("allocPositionPacket returned a nullptr"); return; @@ -465,6 +486,14 @@ bool PositionModule::positionWithinPrecisionCell(int32_t aLat, int32_t aLon, int truncateCoordinate(aLon, precision) == truncateCoordinate(bLon, precision); } +bool PositionModule::shouldSendPositionToPhone(bool hasValidPosition, bool phoneQueueEmpty, bool everSentToPhone, uint32_t nowMs, + uint32_t lastSentMs, uint32_t intervalMs) +{ + if (!hasValidPosition || !phoneQueueEmpty) + return false; + return !everSentToPhone || (nowMs - lastSentMs) >= intervalMs; +} + uint32_t PositionModule::effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs) { if (stationary && stationaryFloorMs > configuredIntervalMs) @@ -485,8 +514,20 @@ int32_t PositionModule::runOnce() if (node == nullptr) return RUNONCE_INTERVAL; - // We limit our GPS broadcasts to a max rate uint32_t now = millis(); + + // Local-only delivery, so it runs regardless of mesh opt-in state or channel utilization. + // Only send while the queue is empty (phone assumed connected), like telemetry. The cadence + // stamp only advances when a packet was actually queued, so a guard or allocation failure + // retries on the next tick instead of waiting out a full interval. + if (shouldSendPositionToPhone(nodeDB->hasValidPosition(node), service->isToPhoneQueueEmpty(), hasSentPositionToPhone, now, + lastPhoneSendMs, sendToPhoneIntervalMs) && + sendOurPositionToPhone()) { + hasSentPositionToPhone = true; + lastPhoneSendMs = now; + } + + // We limit our GPS broadcasts to a max rate uint32_t intervalMs = Default::getConfiguredOrDefaultMsScaled( config.position.position_broadcast_secs, default_broadcast_interval_secs, numOnlineNodes, TrafficType::POSITION); uint32_t msSinceLastSend = now - lastGpsSend; diff --git a/src/modules/PositionModule.h b/src/modules/PositionModule.h index bfd48047b..03754c22b 100644 --- a/src/modules/PositionModule.h +++ b/src/modules/PositionModule.h @@ -45,6 +45,12 @@ class PositionModule : public ProtobufModule, private concu // Effective min interval: stationary positions are held to stationaryFloorMs (when that is the // longer of the two); otherwise the normal configured interval. static uint32_t effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs); + // Pure local-play policy: stream our own position to the phone/UI when we have a valid + // position, the phone queue is idle, and the cadence has elapsed. everSentToPhone (rather + // than a lastSentMs sentinel) marks the never-sent state, so a send stamped at millis()==0 + // still honors the cadence. + static bool shouldSendPositionToPhone(bool hasValidPosition, bool phoneQueueEmpty, bool everSentToPhone, uint32_t nowMs, + uint32_t lastSentMs, uint32_t intervalMs); protected: /** Called to handle a particular incoming message @@ -63,7 +69,14 @@ class PositionModule : public ProtobufModule, private concu virtual int32_t runOnce() override; private: - meshtastic_MeshPacket *allocPositionPacket(); + meshtastic_MeshPacket *allocPositionPacket(uint32_t atPrecision); + // Streams our own position to the connected phone/UI at full precision without touching the + // mesh. Keeps the local view alive now that mesh position sharing is opt-in. Returns true + // only when a packet was handed to the phone queue. + bool sendOurPositionToPhone(); + bool hasSentPositionToPhone = false; + uint32_t lastPhoneSendMs = 0; + static constexpr uint32_t sendToPhoneIntervalMs = 60 * 1000; // Matches telemetry's local cadence struct SmartPosition getDistanceTraveledSinceLastSend(meshtastic_PositionLite currentPosition); // True when our position is unchanged since the last broadcast: it truncates to the same // precision grid cell, so re-sending would be a duplicate that traffic management dedups diff --git a/test/test_position_module/test_main.cpp b/test/test_position_module/test_main.cpp index 091eacd48..a5c2c8ea5 100644 --- a/test/test_position_module/test_main.cpp +++ b/test/test_position_module/test_main.cpp @@ -56,6 +56,56 @@ static void test_effectiveInterval_zeroFloorIsNoOp() TEST_ASSERT_EQUAL_UINT32(60000U, PositionModule::effectiveBroadcastIntervalMs(60000U, true, 0U)); } +// Local phone/UI play: positions are opt-in on the mesh, but our own position still streams to +// the connected phone at a fixed cadence (mirroring telemetry's local delivery). + +// A never-sent state streams immediately once a valid position exists, regardless of the clock. +static void test_sendToPhone_firstSendIsImmediate() +{ + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, false, 5000U, 0U, 60000U)); + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, false, 0U, 0U, 60000U)); +} + +static void test_sendToPhone_holdsUntilCadenceElapses() +{ + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(true, true, true, 59999U, 1U, 60000U)); +} + +static void test_sendToPhone_sendsWhenCadenceElapses() +{ + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, true, 60001U, 1U, 60000U)); +} + +// No valid local position yet (e.g. GPS has no fix since boot): nothing to stream. +static void test_sendToPhone_requiresValidPosition() +{ + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(false, true, true, 60001U, 1U, 60000U)); +} + +// A backed-up toPhone queue means no client is draining it; don't pile on. +static void test_sendToPhone_requiresIdlePhoneQueue() +{ + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(true, false, true, 60001U, 1U, 60000U)); +} + +// millis() rollover: unsigned subtraction keeps the elapsed math correct across the wrap. +static void test_sendToPhone_survivesMillisRollover() +{ + constexpr uint32_t lastSent = UINT32_MAX - 29999U; // exactly 30,000 ms before the wrap to 0 + // "now" 40s after the wrap: 70,000 ms elapsed >= the 60s cadence, so it sends. + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, true, 40000U, lastSent, 60000U)); + // "now" 10s after the wrap: only 40,000 ms elapsed, still held. + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(true, true, true, 10000U, lastSent, 60000U)); +} + +// Regression: a send stamped at millis()==0 must still hold the cadence - the never-sent state +// is the everSentToPhone flag, not a lastSentMs==0 sentinel. +static void test_sendToPhone_sentAtTimeZeroStillHoldsCadence() +{ + TEST_ASSERT_FALSE(PositionModule::shouldSendPositionToPhone(true, true, true, 30000U, 0U, 60000U)); + TEST_ASSERT_TRUE(PositionModule::shouldSendPositionToPhone(true, true, true, 60000U, 0U, 60000U)); +} + void setUp(void) {} void tearDown(void) {} @@ -74,6 +124,13 @@ void setup() RUN_TEST(test_effectiveInterval_movingKeepsConfigured); RUN_TEST(test_effectiveInterval_longConfiguredWinsOverFloor); RUN_TEST(test_effectiveInterval_zeroFloorIsNoOp); + RUN_TEST(test_sendToPhone_firstSendIsImmediate); + RUN_TEST(test_sendToPhone_holdsUntilCadenceElapses); + RUN_TEST(test_sendToPhone_sendsWhenCadenceElapses); + RUN_TEST(test_sendToPhone_requiresValidPosition); + RUN_TEST(test_sendToPhone_requiresIdlePhoneQueue); + RUN_TEST(test_sendToPhone_survivesMillisRollover); + RUN_TEST(test_sendToPhone_sentAtTimeZeroStillHoldsCadence); exit(UNITY_END()); } From 2c57a17124840245d8be85f08d41e7ff46148e08 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:32:18 +0100 Subject: [PATCH 12/30] Phantom node fix perhaps (#11271) * trying to fix phantom nodes * fix comment spam * oops - missed one --- protobufs | 2 +- src/mesh/LR11x0Interface.cpp | 1 + src/mesh/LR20x0Interface.cpp | 1 + src/mesh/MeshService.cpp | 4 ++- src/mesh/NodeDB.cpp | 31 ++++++++++++++-- src/mesh/NodeDB.h | 13 ++++++- src/mesh/PhoneAPI.cpp | 35 ++++++++++++++----- src/mesh/RF95Interface.cpp | 1 + src/mesh/RadioInterface.cpp | 2 +- src/mesh/SX126xInterface.cpp | 1 + src/mesh/SX128xInterface.cpp | 1 + src/mesh/TypeConversions.cpp | 3 ++ src/mesh/generated/meshtastic/deviceonly.pb.h | 7 ++-- src/mesh/generated/meshtastic/mesh.pb.h | 20 ++++++++--- src/mesh/udp/UdpMulticastHandler.h | 6 +++- src/modules/StoreForwardModule.cpp | 1 + src/modules/TraceRouteModule.cpp | 6 +++- src/serialization/MeshPacketSerializer.cpp | 8 +++-- test/test_fuzz_packets/test_main.cpp | 2 ++ .../test_meshpacket_serializer/test_helpers.h | 1 + 20 files changed, 120 insertions(+), 26 deletions(-) diff --git a/protobufs b/protobufs index bfd718fa1..94cdec45a 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit bfd718fa1dcb019ed11b7b7185f37318abebdafc +Subproject commit 94cdec45ae729650d5a1b0cea356cad12747983b diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index b051bc114..bb87ca7ae 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -247,6 +247,7 @@ template void LR11x0Interface::addReceiveMetadata(meshtastic_Mes // LOG_DEBUG("PacketStatus %x", lora.getPacketStatus()); mp->rx_snr = lora.getSNR(); mp->rx_rssi = lround(lora.getRSSI()); + mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError()); } diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index 95dad82e9..3e5ec9250 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -253,6 +253,7 @@ template void LR20x0Interface::addReceiveMetadata(meshtastic_Mes // LOG_DEBUG("PacketStatus %x", lora.getPacketStatus()); mp->rx_snr = lora.getSNR(); mp->rx_rssi = lround(lora.getRSSI()); + mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding // LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError()); // not implemented for LR20x0, but noop for LR11x0 // too(!) } diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 0efd23c81..782b0dc73 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -216,8 +216,10 @@ void MeshService::injectAsReceived(meshtastic_MeshPacket &p) return; if (mp->rx_snr == 0) // plausible synthetic link metadata unless the caller set it mp->rx_snr = 8; - if (mp->rx_rssi == 0) + if (!mp->has_rx_rssi) { // rx_rssi has explicit presence; only fabricate if the caller didn't supply a real one mp->rx_rssi = -40; + mp->has_rx_rssi = true; + } mp->rx_time = getValidTime(RTCQualityFromNet); LOG_INFO("inject: RX from=0x%08x to=0x%08x id=0x%08x ch=%d %s", mp->from, mp->to, mp->id, mp->channel, mp->which_payload_variant == meshtastic_MeshPacket_encrypted_tag ? "encrypted" : "decoded"); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 9ee69636d..c7d139aaa 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -202,7 +202,9 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (ostream) { const auto *vec = static_cast *>(iter->pData); for (auto item : *vec) { - item.snr_q4 = (int32_t)(item.snr * 4.0f); + // Round rather than truncate: truncation wiped any |SNR| < 0.25 dB to exactly + // 0, which collided with the "never stored" sentinel below. + item.snr_q4 = (int32_t)lroundf(item.snr * 4.0f); item.snr = 0.0f; if (!pb_encode_tag_for_field(ostream, iter)) return false; @@ -214,8 +216,14 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; auto *vec = static_cast *>(iter->pData); if (pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) { - if (node.snr_q4) + // snr_q4 = 0 is byte-identical to "field never written" but 0 dB is valid. + // NODEINFO_BITFIELD_HAS_SNR_MASK disambiguates going forward; legacy + // records (bit clear) treat this as unknown. + if (nodeInfoLiteHasSnr(&node)) { node.snr = node.snr_q4 / 4.0f; + } else if (node.snr_q4) { + node.snr = node.snr_q4 / 4.0f; + } node.snr_q4 = 0; vec->push_back(node); } @@ -3624,8 +3632,20 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) if (mp.rx_time) // if the packet has a valid timestamp use it to update our last_heard info->last_heard = mp.rx_time; - if (mp.rx_snr) + // Gate on the packet actually having been received over our own radio, not on rx_snr being + // truthy, because 0 dB is valid. TRANSPORT_LORA is set only on the real over-the-air RX path + // (RadioInterface.cpp); it excludes TRANSPORT_INTERNAL and TRANSPORT_MQTT, while still accepting + // an MQTT-origin packet that a gateway rebroadcasts onto LoRa - we genuinely measured that one + // ourselves. Mirrors hop histogram below. + // Belt-and-braces: also require has_rx_rssi, which every genuine RF-reception site sets + // unconditionally alongside rx_snr - unlike PhoneAPI's replay packets, which set TRANSPORT_LORA + // too (so the client treats restored history as if heard over the air) but never has_rx_rssi. + // Replay packets don't reach updateFrom() today; this check guards against a future change that + // routes them back through this path silently recording a replayed rx_snr as a fresh measurement. + if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && mp.has_rx_rssi) { info->snr = mp.rx_snr; // keep the most recent SNR we received for this node. + nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_HAS_SNR_MASK, true); + } nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK, mp.via_mqtt); // Store if we received this packet via MQTT @@ -3637,6 +3657,11 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) // inflate the local mesh-size estimate with non-RF nodes (and they usually carry // hop_start==0, landing in the hop-0 bucket that pulls the recommendation lowest), so // exclude via_mqtt too. + // + // The std::max clamp below is deliberate, not a bug: Counting an unproven-but-real neighbor as 0 hops is the + // conservative direction. This intentionally does not agree with the `hopsAway >= 0` + // gate below, which rejects the same -1 rather than storing it as a fabricated 0 - + // the histogram and the stored hops_away serve different purposes. if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt && hopScalingModule) { uint8_t hopCount = std::max(int8_t(0), getHopsAway(mp)); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index cb93754a6..5320c571b 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -740,7 +740,12 @@ extern uint32_t error_address; #define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT) #define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT 9 #define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK (1u << NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT) -// Bits 10..31 reserved for future single-bit flags. +// snr_q4 (persisted, sint32) is proto3 singular, so 0 == "never written", but 0 dB is valid. +// This bit disambiguates: whenever snr_q4 is written from a genuine RF measurement. +// Use this instead of `if (snr_q4)`. Legacy records (bit clear) are unambiguously "unknown". +#define NODEINFO_BITFIELD_HAS_SNR_SHIFT 10 +#define NODEINFO_BITFIELD_HAS_SNR_MASK (1u << NODEINFO_BITFIELD_HAS_SNR_SHIFT) +// Bits 11..31 reserved for future single-bit flags. // Convenience accessors so call sites read like the old struct fields. inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n) @@ -783,6 +788,12 @@ inline bool nodeInfoLiteHasXeddsaSigned(const meshtastic_NodeInfoLite *n) { return n && (n->bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK); } +/// True if this node's snr_q4 was written from a genuine RF measurement (including a real +/// 0 dB reading). False means "never measured" - do not treat 0 as data. +inline bool nodeInfoLiteHasSnr(const meshtastic_NodeInfoLite *n) +{ + return n && (n->bitfield & NODEINFO_BITFIELD_HAS_SNR_MASK); +} /// A node that the eviction/migration paths must not drop: a favourite, an /// ignored (blocked) node, or a manually-verified key. inline bool nodeInfoLiteIsProtected(const meshtastic_NodeInfoLite *n) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 28d047f13..75385f03f 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -1222,17 +1222,29 @@ uint32_t makeReplayPacketId(NodeNum num, uint32_t timestamp, uint32_t kind) return h ? h : 1; // some clients treat id 0 as "unset" } -/// Populate hop_start/hop_limit from the node's real last-known hop count (if any) so a -/// replayed packet doesn't read as "heard directly" when it wasn't. +/// Populate hop_start/hop_limit from the node's last-known hop count - never fabricate one. +/// hop_start == 0 with no decoded bitfield means unknown, not a direct neighbor; clients must +/// treat it that way too (see hop_start in mesh.proto). void setReplayHopFields(meshtastic_MeshPacket &pkt, const meshtastic_NodeInfoLite *header) { + if (!header || !header->has_hops_away) { + pkt.hop_start = 0; // unknown - do not fabricate a direct-neighbor reading + pkt.hop_limit = 0; + return; + } uint8_t hopLimit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); - uint8_t hopsAway = (header && header->has_hops_away) ? header->hops_away : 0; + uint8_t hopsAway = header->hops_away; pkt.hop_start = hopLimit; pkt.hop_limit = hopsAway < hopLimit ? (uint8_t)(hopLimit - hopsAway) : 0; } + } // namespace +// Replayed packets deliberately leave rx_rssi absent. NodeInfoLite stores no RSSI, and +// rx_rssi has explicit presence on the wire, indicating "unknown". +// Previously these packets carried a bare 0, which a client renders as a real reading. +// Note the asymmetry with rx_snr below: that field is still proto3 singular, so "unknown" and +// "0 dB" remain indistinguishable there. meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos) { // Shape this exactly like a fresh live broadcast Position from the peer so the @@ -1242,12 +1254,16 @@ meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const mesh const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.from = num; pkt.to = NODENUM_BROADCAST; - pkt.rx_time = pos.time; + // rx_time means "when *we* received this" - use last_heard, not the position's own GPS + // fix time (which is often 0 and, when present, already round-trips inside the payload + // via ConvertToPosition). + pkt.rx_time = header ? header->last_heard : 0; // Stable per-node/per-fix id: replaying the same unchanged history on every // reconnect must not look like a brand new packet to the phone's history/dedup. pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_POSITION_APP); - pkt.channel = 0; + pkt.channel = header ? header->channel : 0; pkt.rx_snr = header ? header->snr : 0; + pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated @@ -1270,8 +1286,9 @@ meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const mes const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag); - pkt.channel = 0; + pkt.channel = header ? header->channel : 0; pkt.rx_snr = header ? header->snr : 0; + pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated - iOS client filters @@ -1375,8 +1392,9 @@ meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag); - pkt.channel = 0; + pkt.channel = header ? header->channel : 0; pkt.rx_snr = header ? header->snr : 0; + pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated - iOS client filters @@ -1439,8 +1457,9 @@ meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const mesht const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP); - pkt.channel = 0; + pkt.channel = header ? header->channel : 0; pkt.rx_snr = header ? header->snr : 0; + pkt.via_mqtt = nodeInfoLiteViaMqtt(header); setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated - client filters diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index cbd792878..26a765ea8 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -267,6 +267,7 @@ void RF95Interface::addReceiveMetadata(meshtastic_MeshPacket *mp) { mp->rx_snr = lora->getSNR(); mp->rx_rssi = lround(lora->getRSSI()); + mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding LOG_DEBUG("Corrected frequency offset: %f", lora->getFrequencyError()); } diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 043bd59ef..ec1b9c9cb 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -881,7 +881,7 @@ void printPacket(const char *prefix, const meshtastic_MeshPacket *p) out += DEBUG_PORT.mt_sprintf(" rxtime=%u", p->rx_time); if (p->rx_snr != 0.0) out += DEBUG_PORT.mt_sprintf(" rxSNR=%g", p->rx_snr); - if (p->rx_rssi != 0) + if (p->has_rx_rssi) // rx_rssi has explicit presence; a != 0 check would hide a genuine 0 dBm reading out += DEBUG_PORT.mt_sprintf(" rxRSSI=%i", p->rx_rssi); if (p->via_mqtt != 0) out += DEBUG_PORT.mt_sprintf(" via MQTT"); diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 817134b58..7c46bee71 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -335,6 +335,7 @@ template void SX126xInterface::addReceiveMetadata(meshtastic_Mes // LOG_DEBUG("PacketStatus %x", lora.getPacketStatus()); mp->rx_snr = lora.getSNR(); mp->rx_rssi = lround(lora.getRSSI()); + mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError()); } diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 608cd37e0..7848d51db 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -205,6 +205,7 @@ template void SX128xInterface::addReceiveMetadata(meshtastic_Mes // LOG_DEBUG("PacketStatus %x", lora.getPacketStatus()); mp->rx_snr = lora.getSNR(); mp->rx_rssi = lround(lora.getRSSI()); + mp->has_rx_rssi = true; // rx_rssi has explicit presence - a genuine reading must be marked present to survive encoding LOG_DEBUG("Corrected frequency offset: %f", lora.getFrequencyError()); } diff --git a/src/mesh/TypeConversions.cpp b/src/mesh/TypeConversions.cpp index 0b33578de..9fc80fd86 100644 --- a/src/mesh/TypeConversions.cpp +++ b/src/mesh/TypeConversions.cpp @@ -10,6 +10,9 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo meshtastic_NodeInfo info = meshtastic_NodeInfo_init_default; info.num = lite->num; + // NodeInfo.snr (wire) is still proto3 singular float - unlike NodeInfoLite.snr_q4, it has no + // presence bit and cannot distinguish a genuine 0 dB from "unknown". nodeInfoLiteHasSnr(lite) + // is available if a future NodeInfo revision needs to carry that distinction to clients. info.snr = lite->snr; info.last_heard = lite->last_heard; info.channel = lite->channel; diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 059398cf0..669792ddd 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -95,8 +95,11 @@ typedef struct _meshtastic_NodeInfoLite { /* The public key of the user's device, for PKI-based encrypted DMs. */ meshtastic_NodeInfoLite_public_key_t public_key; /* Q4-encoded SNR: dB × 4, sint32 zigzag. Matches RouteDiscovery convention. - Encode: snr_q4 = (int32_t)(snr * 4.0f). Decode: snr = snr_q4 / 4.0f. - float snr is always zeroed on disk; this field carries all persisted SNR. */ + Encode: snr_q4 = (int32_t)lroundf(snr * 4.0f). Decode: snr = snr_q4 / 4.0f. + float snr is always zeroed on disk; this field carries all persisted SNR. + A stored 0 does not by itself mean "unknown" here - see NODEINFO_BITFIELD_HAS_SNR in + src/mesh/NodeDB.h for the presence bit that disambiguates a genuine 0 dB reading from + "never measured". */ int32_t snr_q4; } meshtastic_NodeInfoLite; diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 7ce8115ba..637aae3ab 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -1103,14 +1103,24 @@ typedef struct _meshtastic_MeshPacket { /* The priority of this message for sending. See MeshPacket.Priority description for more details. */ meshtastic_MeshPacket_Priority priority; - /* rssi of received packet. Only sent to phone for dispay purposes. */ + /* rssi of received packet. Only sent to phone for dispay purposes. + Explicit presence: rssi 0 is a legitimate reading on some radios (SX126x can report exactly + 0 dBm; SX127x's formula can even go positive), so implicit-presence proto3 made an unset + value indistinguishable from a measured one. has_rx_rssi disambiguates; a replayed packet + built from history the device never restored an RSSI for should leave this field absent + rather than emitting 0. */ + bool has_rx_rssi; int32_t rx_rssi; /* Describe if this message is delayed */ meshtastic_MeshPacket_Delayed delayed; /* Describes whether this packet passed via MQTT somewhere along the path it currently took. */ bool via_mqtt; /* Hop limit with which the original packet started. Sent via LoRa using three bits in the unencrypted header. - When receiving a packet, the difference between hop_start and hop_limit gives how many hops it traveled. */ + When receiving a packet, the difference between hop_start and hop_limit gives how many hops it traveled. + hop_start == 0 does not necessarily mean a direct (0-hop) neighbor: firmware prior to 2.3.0 + never populated this field, so a receiver can only trust hop_start == 0 as genuine once it has + decoded the packet and confirmed the sender's bitfield is present (added in 2.5.0). Until then, + or for a sender that never sets that bitfield, treat hop_start == 0 as unknown, not direct. */ uint8_t hop_start; /* Records the public key the packet was encrypted with, if applicable. */ meshtastic_MeshPacket_public_key_t public_key; @@ -1730,7 +1740,7 @@ extern "C" { #define meshtastic_Waypoint_init_default {0, false, 0, false, 0, 0, 0, "", "", 0, 0, false, meshtastic_BoundingBox_init_default, 0, 0, 0} #define meshtastic_StatusMessage_init_default {""} #define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} +#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} #define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_default {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN} @@ -1769,7 +1779,7 @@ extern "C" { #define meshtastic_Waypoint_init_zero {0, false, 0, false, 0, 0, 0, "", "", 0, 0, false, meshtastic_BoundingBox_init_zero, 0, 0, 0} #define meshtastic_StatusMessage_init_zero {""} #define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} +#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} #define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_zero {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN} @@ -2194,7 +2204,7 @@ X(a, STATIC, SINGULAR, FLOAT, rx_snr, 8) \ X(a, STATIC, SINGULAR, UINT32, hop_limit, 9) \ X(a, STATIC, SINGULAR, BOOL, want_ack, 10) \ X(a, STATIC, SINGULAR, UENUM, priority, 11) \ -X(a, STATIC, SINGULAR, INT32, rx_rssi, 12) \ +X(a, STATIC, OPTIONAL, INT32, rx_rssi, 12) \ X(a, STATIC, SINGULAR, UENUM, delayed, 13) \ X(a, STATIC, SINGULAR, BOOL, via_mqtt, 14) \ X(a, STATIC, SINGULAR, UINT32, hop_start, 15) \ diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 37ab44524..60967ccef 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -86,9 +86,13 @@ class UdpMulticastHandler final UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); if (!p) return; - // Unset received SNR/RSSI + // Unset received SNR/RSSI - no local RF measurement exists for a UDP arrival. rx_rssi + // has explicit presence, so also clear has_rx_rssi: `mp` may have arrived already + // carrying a real measurement from whichever node forwarded it onto UDP, and leaving + // the presence bit set would misrepresent that stale value as "0 dBm over UDP". p->rx_snr = 0; p->rx_rssi = 0; + p->has_rx_rssi = false; router->enqueueReceivedMessage(p.release()); } } diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index de1d6865c..3c6d2de7d 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -259,6 +259,7 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t p->rx_time = this->packetHistory[i].time; p->decoded.emoji = (uint32_t)this->packetHistory[i].emoji; p->rx_rssi = this->packetHistory[i].rx_rssi; + p->has_rx_rssi = true; // rx_rssi has explicit presence; the stored value was a genuine measurement p->rx_snr = this->packetHistory[i].rx_snr; p->hop_start = this->packetHistory[i].hop_start; p->hop_limit = this->packetHistory[i].hop_limit; diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index c0cac7123..4d3819f12 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -422,7 +422,11 @@ void TraceRouteModule::appendMyIDandSNR(meshtastic_RouteDiscovery *updated, floa } if (*snr_count < ROUTE_SIZE) { - snr_list[*snr_count] = (int8_t)(snr * 4); // Convert SNR to 1 byte + // Clamp before the cast: q4-scaled SNR at or below the demodulation floor can reach + // -128 (=-32dB), which is bit-identical to the INT8_MIN "unknown SNR" sentinel used + // throughout this file. Reserve -128 for the sentinel; clamp real readings to -127. + int32_t q4 = clamp(lroundf(snr * 4.0f), -127, 127); + snr_list[*snr_count] = (int8_t)q4; *snr_count += 1; } if (SNRonly) diff --git a/src/serialization/MeshPacketSerializer.cpp b/src/serialization/MeshPacketSerializer.cpp index 7dd728284..88d134496 100644 --- a/src/serialization/MeshPacketSerializer.cpp +++ b/src/serialization/MeshPacketSerializer.cpp @@ -426,7 +426,9 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, jsonObj["channel"] = (Json::UInt)mp->channel; jsonObj["type"] = msgType; jsonObj["sender"] = nodeDB->getNodeId(); - if (mp->rx_rssi != 0) + // rx_rssi has explicit presence on the wire (unlike rx_snr): trust has_rx_rssi rather than a + // != 0 heuristic, since 0 dBm is a legitimate reading on some radios. + if (mp->has_rx_rssi) jsonObj["rssi"] = (int)mp->rx_rssi; if (mp->rx_snr != 0) jsonObj["snr"] = (float)mp->rx_snr; @@ -456,7 +458,9 @@ std::string MeshPacketSerializer::JsonSerializeEncrypted(const meshtastic_MeshPa jsonObj["channel"] = (Json::UInt)mp->channel; jsonObj["want_ack"] = mp->want_ack; - if (mp->rx_rssi != 0) + // rx_rssi has explicit presence on the wire (unlike rx_snr): trust has_rx_rssi rather than a + // != 0 heuristic, since 0 dBm is a legitimate reading on some radios. + if (mp->has_rx_rssi) jsonObj["rssi"] = (int)mp->rx_rssi; if (mp->rx_snr != 0) jsonObj["snr"] = (float)mp->rx_snr; diff --git a/test/test_fuzz_packets/test_main.cpp b/test/test_fuzz_packets/test_main.cpp index c9edcabb1..2bccf1d40 100644 --- a/test/test_fuzz_packets/test_main.cpp +++ b/test/test_fuzz_packets/test_main.cpp @@ -616,6 +616,7 @@ void test_E7_nodedb_update_fuzz(void) mp.id = rngNext(); mp.rx_snr = (float)((int)rngRange(60) - 30); mp.rx_rssi = (int32_t)rngRange(256) - 128; + mp.has_rx_rssi = (rngRange(2) != 0); // exercise both the presence and absence paths mp.hop_start = (uint8_t)rngRange(8); mp.hop_limit = (uint8_t)rngRange(8); mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; @@ -666,6 +667,7 @@ static void fuzzRxHeader(meshtastic_MeshPacket &mp, meshtastic_PortNum portnum) mp.channel = (uint8_t)rngRange(channels.getNumChannels()); mp.rx_snr = (float)((int)rngRange(40) - 20); mp.rx_rssi = -(int)rngRange(130); + mp.has_rx_rssi = (rngRange(2) != 0); // exercise both the presence and absence paths mp.hop_start = (uint8_t)rngRange(8); // 0..7, wire-bounded mp.hop_limit = (uint8_t)rngRange(8); mp.want_ack = (rngRange(2) == 0); diff --git a/test/test_meshpacket_serializer/test_helpers.h b/test/test_meshpacket_serializer/test_helpers.h index 5f5efd2cf..da8c89fff 100644 --- a/test/test_meshpacket_serializer/test_helpers.h +++ b/test/test_meshpacket_serializer/test_helpers.h @@ -40,6 +40,7 @@ static meshtastic_MeshPacket create_test_packet(meshtastic_PortNum port, const u packet.rx_snr = 10.5f; packet.hop_start = 3; packet.rx_rssi = -85; + packet.has_rx_rssi = true; // rx_rssi has explicit presence; mark this synthetic reading as measured packet.delayed = meshtastic_MeshPacket_Delayed_NO_DELAY; // Set decoded variant From d87cbec45b54e2451de90520db0e7d1bd9f16ef2 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 28 Jul 2026 14:47:02 -0400 Subject: [PATCH 13/30] Remove esp32c6 guard precluding NonBlockingRTTTL (#11273) --- src/modules/ExternalNotificationModule.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/ExternalNotificationModule.h b/src/modules/ExternalNotificationModule.h index 03eac036b..a5b9f68da 100644 --- a/src/modules/ExternalNotificationModule.h +++ b/src/modules/ExternalNotificationModule.h @@ -23,7 +23,7 @@ extern AmbientLightingThread *ambientLightingThread; #endif #endif -#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !defined(CONFIG_IDF_TARGET_ESP32C6) +#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) #include #else // Noop class for portduino. From b4ff1df864c23f7976f71e5db6b0387bf1525484 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:52:26 -0500 Subject: [PATCH 14/30] chore(deps): update platformio/ststm32 to v19.7.1 (#11263) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/stm32/stm32.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index ea8673d77..02955ff2a 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -2,7 +2,7 @@ extends = arduino_base platform = # renovate: datasource=custom.pio depName=platformio/ststm32 packageName=platformio/platform/ststm32 - platformio/ststm32@19.7.0 + platformio/ststm32@19.7.1 platform_packages = # renovate: datasource=github-tags depName=Arduino_Core_STM32 packageName=stm32duino/Arduino_Core_STM32 platformio/framework-arduinoststm32@https://github.com/stm32duino/Arduino_Core_STM32/archive/2.10.1.zip From 0fef83d4344d07e6e3910c48423e15670baff54d Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:54:48 -0700 Subject: [PATCH 15/30] Add configurable event mode hop limit (#11275) * feat: resolve event mode hop limit * feat: bake event mode hop limit * fix: honor event mode hop cap in routing * docs: expose event mode hop limit preference * fix: enforce event hop defaults across routing * docs: clarify event hop override behavior * refactor: simplify event mode hop preference * fix: cap equal event hop limit --- src/mesh/Default.cpp | 2 +- src/mesh/Default.h | 16 +++++++- src/mesh/NextHopRouter.cpp | 22 ++++++++--- src/mesh/NodeDB.cpp | 4 ++ src/modules/RoutingModule.cpp | 13 ++++--- test/test_default/test_main.cpp | 32 ++++++++++++++++ test/test_nexthop_routing/test_main.cpp | 49 +++++++++++++++++++++++++ userPrefs.jsonc | 1 + 8 files changed, 126 insertions(+), 13 deletions(-) diff --git a/src/mesh/Default.cpp b/src/mesh/Default.cpp index c8cea9d89..95e4132bf 100644 --- a/src/mesh/Default.cpp +++ b/src/mesh/Default.cpp @@ -92,7 +92,7 @@ uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minV uint8_t Default::getConfiguredOrDefaultHopLimit(uint8_t configured) { #if USERPREFS_EVENT_MODE - return (configured > HOP_RELIABLE) ? HOP_RELIABLE : config.lora.hop_limit; + return (configured >= eventModeHopLimit) ? eventModeHopLimit : config.lora.hop_limit; #else return (configured >= HOP_MAX) ? HOP_MAX : config.lora.hop_limit; #endif diff --git a/src/mesh/Default.h b/src/mesh/Default.h index b38a68185..e5e8b8ab1 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -5,6 +5,7 @@ #include #include #include +#include #define ONE_DAY 24 * 60 * 60 #define ONE_MINUTE_MS 60 * 1000 #define THIRTY_SECONDS_MS 30 * 1000 @@ -75,7 +76,20 @@ enum class TrafficType { POSITION, TELEMETRY }; class Default { +#if USERPREFS_EVENT_MODE && defined(USERPREFS_EVENT_MODE_HOP_LIMIT) + static constexpr auto eventModeHopLimitSetting = USERPREFS_EVENT_MODE_HOP_LIMIT; +#else + static constexpr auto eventModeHopLimitSetting = HOP_RELIABLE; +#endif + using EventModeHopLimitType = typename std::remove_cv::type; + static_assert(std::is_integral::value && !std::is_same::value && + eventModeHopLimitSetting >= 0 && eventModeHopLimitSetting <= HOP_MAX, + "USERPREFS_EVENT_MODE_HOP_LIMIT must be an integer between 0 and 7"); + public: + static constexpr uint8_t eventModeHopLimit = static_cast(eventModeHopLimitSetting); + static constexpr uint8_t eventModeRelayHopLimit = eventModeHopLimit > 0 ? eventModeHopLimit - 1 : 0; + static uint32_t getConfiguredOrDefaultMs(uint32_t configuredInterval); static uint32_t getConfiguredOrDefaultMs(uint32_t configuredInterval, uint32_t defaultInterval); static uint32_t getConfiguredOrDefault(uint32_t configured, uint32_t defaultValue); @@ -129,4 +143,4 @@ class Default return 1.0 + (nodesOverForty * throttlingFactor); // Each number of online node scales by throttle factor } } -}; \ No newline at end of file +}; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 029648e2d..0a64a1f1a 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -1,4 +1,5 @@ #include "NextHopRouter.h" +#include "Default.h" #include "MeshTypes.h" #include "meshUtils.h" #if !MESHTASTIC_EXCLUDE_TRACEROUTE @@ -9,6 +10,18 @@ #endif #include "NodeDB.h" +#if USERPREFS_EVENT_MODE +static void capEventRelayHops(meshtastic_MeshPacket *packet) +{ + if (packet->hop_limit <= Default::eventModeRelayHopLimit) + return; + + const uint8_t reduction = packet->hop_limit - Default::eventModeRelayHopLimit; + packet->hop_start = reduction <= packet->hop_start ? packet->hop_start - reduction : 0; + packet->hop_limit = Default::eventModeRelayHopLimit; +} +#endif + NextHopRouter::NextHopRouter() {} bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p) @@ -26,6 +39,9 @@ bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p) if (!relay) return false; relay->hop_limit--; +#if USERPREFS_EVENT_MODE + capEventRelayHops(relay); +#endif relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); // The interface declines some packets (NODENUM_BROADCAST_NO_LORA) with ERRNO_SHOULD_RELEASE, // which leaves the copy ours to free. Dropping it here would leak a pool slot per opaque frame. @@ -211,11 +227,7 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: preserving hop_limit"); } #if USERPREFS_EVENT_MODE - if (tosend->hop_limit > 2) { - // if we are "correcting" the hop_limit, "correct" the hop_start by the same amount to preserve hops away. - tosend->hop_start -= (tosend->hop_limit - 2); - tosend->hop_limit = 2; - } + capEventRelayHops(tosend); #endif ErrorCode res = diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index c7d139aaa..3c742e7ca 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -927,7 +927,11 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY; #endif +#if USERPREFS_EVENT_MODE + config.lora.hop_limit = Default::eventModeHopLimit; +#else config.lora.hop_limit = HOP_RELIABLE; +#endif #ifdef USERPREFS_CONFIG_LORA_IGNORE_MQTT config.lora.ignore_mqtt = USERPREFS_CONFIG_LORA_IGNORE_MQTT; #else diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp index 1ce7c4502..3d0812e5e 100644 --- a/src/modules/RoutingModule.cpp +++ b/src/modules/RoutingModule.cpp @@ -64,19 +64,20 @@ void RoutingModule::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketI uint8_t RoutingModule::getHopLimitForResponse(const meshtastic_MeshPacket &mp) { const int8_t hopsUsed = getHopsAway(mp); + const uint8_t responseHopLimit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); if (hopsUsed >= 0) { - if (hopsUsed > (int32_t)(config.lora.hop_limit)) { -// In event mode, we never want to send packets with more than our default 3 hops. -#if !(EVENTMODE) // This falls through to the default. + if (hopsUsed > static_cast(responseHopLimit)) { +// In event mode, never exceed the configured event hop limit. +#if !USERPREFS_EVENT_MODE // This falls through to the default. return hopsUsed; // If the request used more hops than the limit, use the same amount of hops #endif } else if (mp.hop_start == 0) { return 0; // The requesting node wanted 0 hops, so the response also uses a direct/local path. - } else if ((uint8_t)(hopsUsed + 2) < config.lora.hop_limit) { + } else if (static_cast(hopsUsed + 2) < responseHopLimit) { return hopsUsed + 2; // Use only the amount of hops needed with some margin as the way back may be different } } - return Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); // Use the default hop limit + return responseHopLimit; } meshtastic_MeshPacket *RoutingModule::allocAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, @@ -93,4 +94,4 @@ RoutingModule::RoutingModule() : ProtobufModule("routing", meshtastic_PortNum_RO // LocalOnly requires either the from or to to be a known node // knownOnly specifically requires the from to be a known node. encryptedOk = true; -} \ No newline at end of file +} diff --git a/test/test_default/test_main.cpp b/test/test_default/test_main.cpp index 674475b79..ca9e48b89 100644 --- a/test/test_default/test_main.cpp +++ b/test/test_default/test_main.cpp @@ -3,6 +3,8 @@ #include "MeshRadio.h" #include "TestUtil.h" #include "meshUtils.h" +#include "modules/RoutingModule.h" +#include #include // Helper to compute expected ms using same logic as Default::congestionScalingCoefficient @@ -181,6 +183,32 @@ void test_scaled_overflow_saturates() TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), res); } +void test_configured_or_default_hop_limit() +{ + config.lora.hop_limit = HOP_MAX; + const uint8_t result = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); + +#if USERPREFS_EVENT_MODE + TEST_ASSERT_EQUAL_UINT8(Default::eventModeHopLimit, result); + TEST_ASSERT_EQUAL_UINT8(Default::eventModeHopLimit, Default::getConfiguredOrDefaultHopLimit(Default::eventModeHopLimit)); +#else + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, result); +#endif +} + +#if USERPREFS_EVENT_MODE +void test_event_mode_caps_optimized_response() +{ + config.lora.hop_limit = HOP_MAX; + meshtastic_MeshPacket request = meshtastic_MeshPacket_init_zero; + request.hop_start = HOP_MAX; + request.hop_limit = HOP_MAX - 4; + + RoutingModule module; + TEST_ASSERT_EQUAL_UINT8(std::min(6, Default::eventModeHopLimit), module.getHopLimitForResponse(request)); +} +#endif + void setup() { // Small delay to match other test mains @@ -201,6 +229,10 @@ void setup() RUN_TEST(test_ms_default_clamps); RUN_TEST(test_ms_result_is_int32_safe); RUN_TEST(test_scaled_overflow_saturates); + RUN_TEST(test_configured_or_default_hop_limit); +#if USERPREFS_EVENT_MODE + RUN_TEST(test_event_mode_caps_optimized_response); +#endif exit(UNITY_END()); } diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index c7a27bf5c..7d3dd9eec 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -13,6 +13,7 @@ #include "configuration.h" #include "gps/RTC.h" +#include "mesh/Default.h" #include "mesh/NextHopRouter.h" #include "mesh/NodeDB.h" #include "mesh/RadioInterface.h" @@ -89,6 +90,7 @@ class NextHopRouterTestShim : public NextHopRouter using NextHopRouter::noteRouteLearned; using NextHopRouter::noteRouteSuccess; using NextHopRouter::perhapsRebroadcast; + using NextHopRouter::relayOpaquePacket; using Router::shouldDecrementHopLimit; // protected in Router void resetRouteHealthForTest() @@ -108,6 +110,8 @@ class MockRadioInterface : public RadioInterface ErrorCode send(meshtastic_MeshPacket *p) override { sendCount++; + lastHopLimit = p->hop_limit; + lastHopStart = p->hop_start; if (declineAll || p->to == NODENUM_BROADCAST_NO_LORA) return ERRNO_SHOULD_RELEASE; @@ -124,6 +128,8 @@ class MockRadioInterface : public RadioInterface int sendCount = 0; bool declineAll = false; + uint8_t lastHopLimit = 0; + uint8_t lastHopStart = 0; }; static MockNodeDB *mockNodeDB = nullptr; @@ -498,6 +504,46 @@ void test_rebroadcast_declined_send_releases_packet(void) TEST_ASSERT_EQUAL_MESSAGE(1, mockIface->sendCount, "the copy must have reached the mock radio"); } +#if USERPREFS_EVENT_MODE +void test_event_mode_hop_behavior(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.from = kLocalNode; + p.hop_start = 0; + p.hop_limit = HOP_MAX; + + TEST_ASSERT_EQUAL(ERRNO_OK, shim->send(packetPool.allocCopy(p))); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, mockIface->lastHopLimit); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, mockIface->lastHopStart); + + p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.hop_start = HOP_MAX; + p.hop_limit = HOP_MAX; + + TEST_ASSERT_TRUE(shim->perhapsRebroadcast(&p)); + TEST_ASSERT_EQUAL_UINT8(Default::eventModeRelayHopLimit, mockIface->lastHopLimit); + TEST_ASSERT_EQUAL_UINT8(static_cast(Default::eventModeRelayHopLimit + 1), mockIface->lastHopStart); + + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.hop_start = HOP_MAX; + p.hop_limit = HOP_MAX; + + TEST_ASSERT_TRUE(shim->relayOpaquePacket(&p)); + TEST_ASSERT_EQUAL_UINT8(Default::eventModeRelayHopLimit, mockIface->lastHopLimit); + TEST_ASSERT_EQUAL_UINT8(static_cast(Default::eventModeRelayHopLimit + 1), mockIface->lastHopStart); + + p = makeRebroadcastCandidate(NODENUM_BROADCAST); + p.hop_start = 0; + p.hop_limit = HOP_MAX; + + TEST_ASSERT_TRUE(shim->relayOpaquePacket(&p)); + TEST_ASSERT_EQUAL_UINT8(Default::eventModeRelayHopLimit, mockIface->lastHopLimit); + TEST_ASSERT_EQUAL_UINT8(0, mockIface->lastHopStart); +} +#endif + // =========================================================================== void setup() @@ -552,6 +598,9 @@ void setup() RUN_TEST(test_rebroadcast_normal_broadcast_is_relayed); RUN_TEST(test_rebroadcast_no_lora_broadcast_is_not_relayed); RUN_TEST(test_rebroadcast_declined_send_releases_packet); +#if USERPREFS_EVENT_MODE + RUN_TEST(test_event_mode_hop_behavior); +#endif exit(UNITY_END()); } diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 639ac6e28..fc18c0097 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -24,6 +24,7 @@ // "USERPREFS_CONFIG_OWNER_SHORT_NAME": "MLN", // "USERPREFS_CONFIG_DEVICE_ROLE": "meshtastic_Config_DeviceConfig_Role_CLIENT", // Defaults to CLIENT. ROUTER*, and LOST AND FOUND roles are restricted. // "USERPREFS_EVENT_MODE": "1", + // "USERPREFS_EVENT_MODE_HOP_LIMIT": "3", // Event-mode default and firmware-generated/relay hop cap (0-7; default 3) // "USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS": "1", // Extend TMM position dedup and precision clamping to private/custom-key channels (default: well-known channels only) // "USERPREFS_FIRMWARE_EDITION": "meshtastic_FirmwareEdition_BURNING_MAN", // "USERPREFS_FIXED_BLUETOOTH": "121212", From 2024bb8384bdc60778d2fcf253e6b8aa22cfb311 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:03:25 +0100 Subject: [PATCH 16/30] Arrival time fix perhaps (#11274) * Add explicit presence for MeshPacket.rx_time (arrival time) rx_time is now proto3 optional with a has_rx_time presence bit, matching the rx_rssi treatment. A node with no GPS and no phone connected yet has no time source at all, so a bare 0 was indistinguishable from a genuine 1970-01-01 reading; downstream consumers (replay packets, JSON serialization) now check has_rx_time instead of the value. * Dedupe rx_time stamping into a shared helper; trim a debug log string Extract the repeated haveTime/rx_time/has_rx_time stamp logic (5 call sites across Router.cpp, MeshBeaconModule.cpp, MeshService.cpp) into Router::computeRxTimeStamp()/stampRxTime(). Also shorten the new RTC.cpp LOG_DEBUG string. Saves 48 bytes of flash on rak4631 (measured), no behavior change. * Fix has_rx_rssi presence carried unconditionally through StoreForward replay preparePayload() set has_rx_rssi = true unconditionally on replay, regardless of whether the packet's rx_rssi at store time was a genuine measurement (e.g. MQTT-relayed packets carry no real RSSI). Store the presence bit alongside rx_rssi in PacketHistoryStruct and restore it on replay instead. Flagged by Copilot on #11271 (same root cause the has_rx_time explicit presence work fixes) but never addressed before that PR merged. * Trim comment blocks to the repo's 1-2 line guideline .github/copilot-instructions.md:338 caps code comments at 1-2 lines; several blocks added across the rx_time explicit-presence work ran well past that. Also consolidates Time.cpp's file-level doc comment into Time.h, where the rest of the Time:: API contract already lives. No behavior change. * Add rx_time explicit-presence test coverage - test_meshpacket_serializer: has_rx_time=false fixture plus tests asserting JsonSerialize/JsonSerializeEncrypted emit 0 rather than leaking the millis() placeholder, alongside the has_rx_time=true baseline. - test_stream_api: two tests driving a real PhoneAPI handshake (want_config_id through STATE_SEND_PACKETS) that simulate a phone time-giving transaction arriving before vs. after a queued packet is drained - covering both the reconciled and the ships-with-placeholder-absent paths of MeshService::reconcilePendingRxTimes(). * Fix three correctness issues flagged in review - Time.h: drop the reserved-identifier include guard (_MT_TIME_H); pragma once already covers it, matching convention elsewhere (e.g. RTC.h). - Time.cpp: rebase getMillis64()'s wrap accumulator when the test seam swaps clock sources, so a real<->injected clock jump isn't miscounted as a genuine 32-bit wrap. - NodeInfoModule: the 12h reply-suppression window is a local dedup duration, not a wall-clock reading - switch it to Time::getMillis64() so RTC-quality jumps and replayed packets' stale rx_time can't perturb it. - StoreForwardModule: has_rx_time was derived from *current* RTC quality at replay time rather than stored at capture time, so a history entry saved while time-blind could be misreported as a valid epoch once the clock later improved. Persist the presence bit in PacketHistoryStruct instead. * tryfix CI * post review fixes * more test fixes --- protobufs | 2 +- src/UptimeClock.cpp | 33 +++++ src/UptimeClock.h | 46 +++++++ src/gps/RTC.cpp | 17 ++- src/mesh/MeshService.cpp | 43 +++++- src/mesh/MeshService.h | 5 + src/mesh/NodeDB.cpp | 8 +- src/mesh/NodeDB.h | 7 +- src/mesh/PhoneAPI.cpp | 19 +++ src/mesh/RadioInterface.cpp | 2 +- src/mesh/Router.cpp | 34 +++-- src/mesh/Router.h | 11 ++ src/mesh/generated/meshtastic/mesh.pb.h | 15 +- src/modules/MeshBeaconModule.cpp | 2 +- src/modules/NodeInfoModule.cpp | 5 +- src/modules/StoreForwardModule.cpp | 7 +- src/modules/StoreForwardModule.h | 2 + src/modules/Telemetry/HealthTelemetry.cpp | 7 +- src/modules/Telemetry/PowerTelemetry.cpp | 7 +- src/serialization/MeshPacketSerializer.cpp | 6 +- .../ports/test_timestamp.cpp | 37 +++++ .../test_meshpacket_serializer/test_helpers.h | 12 ++ .../test_serializer.cpp | 8 ++ test/test_stream_api/test_main.cpp | 130 ++++++++++++++++++ test/test_traffic_management/test_main.cpp | 1 + variants/native/portduino/platformio.ini | 2 +- 26 files changed, 436 insertions(+), 32 deletions(-) create mode 100644 src/UptimeClock.cpp create mode 100644 src/UptimeClock.h create mode 100644 test/test_meshpacket_serializer/ports/test_timestamp.cpp diff --git a/protobufs b/protobufs index 94cdec45a..cd290ba24 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 94cdec45ae729650d5a1b0cea356cad12747983b +Subproject commit cd290ba246fb5130cb449055248f7e22c15bcafb diff --git a/src/UptimeClock.cpp b/src/UptimeClock.cpp new file mode 100644 index 000000000..5f85f50ff --- /dev/null +++ b/src/UptimeClock.cpp @@ -0,0 +1,33 @@ +// See UptimeClock.h for the full contract. +#include "UptimeClock.h" +#include + +uint32_t Time::getMillis() +{ +#ifdef PIO_UNIT_TESTING + if (Time::useTestClock) + return Time::testNowMs; +#endif + return millis(); +} + +uint64_t Time::getMillis64() +{ + static uint32_t lastLow = 0; // last 32-bit sample + static uint32_t highWord = 0; // number of observed wraps + + uint32_t now = Time::getMillis(); +#ifdef PIO_UNIT_TESTING + // A test swapping clock sources (real <-> injected) can make `now` jump backward for + // reasons other than a genuine wrap - rebase rather than miscount it as one. + if (Time::clockSourceChanged) { + lastLow = now; + highWord = 0; + Time::clockSourceChanged = false; + } +#endif + if (now < lastLow) + highWord++; // low word wrapped since last call + lastLow = now; + return (static_cast(highWord) << 32) | now; +} diff --git a/src/UptimeClock.h b/src/UptimeClock.h new file mode 100644 index 000000000..9329a7bf6 --- /dev/null +++ b/src/UptimeClock.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +// Monotonic uptime clock, injectable so tests can drive a virtual timebase instead of sleeping. +// Uptime only; see gps/RTC.h for wall-clock. Not named Time.h: -Isrc would shadow C's . +namespace Time +{ +#ifdef PIO_UNIT_TESTING +// Test-only virtual clock; OFF by default so suites relying on real time are unaffected. +inline uint32_t testNowMs = 0; +inline bool useTestClock = false; +inline bool clockSourceChanged = true; // forces getMillis64() to rebase its wrap accumulator + +inline void setTestMillis(uint32_t ms) +{ + testNowMs = ms; + useTestClock = true; + clockSourceChanged = true; +} +inline void advanceTestMillis(uint32_t deltaMs) +{ + // Advancing from 0 after getMillis64() sampled the real clock steps backward, which would + // otherwise be miscounted as a wrap. + if (!useTestClock) + clockSourceChanged = true; + testNowMs += deltaMs; + useTestClock = true; +} +// Restore real-clock behaviour (call in test tearDown if a suite mixes real and fake time). +inline void useRealClock() +{ + useTestClock = false; + testNowMs = 0; + clockSourceChanged = true; +} +#endif + +/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis(). +uint32_t getMillis(); + +/// Milliseconds since boot, 64-bit, rollover-immune. Must be polled at least once per ~49.7-day +/// wrap window to catch every wrap, and keeps mutable static carry state, so it is NOT ISR-safe. +uint64_t getMillis64(); + +} // namespace Time diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 94288529e..217bbb4c3 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -2,6 +2,7 @@ #include "configuration.h" #include "detect/ScanI2C.h" #include "main.h" +#include "mesh/MeshService.h" #include "modules/NodeInfoModule.h" #include #include @@ -17,12 +18,16 @@ uint32_t lastSetFromPhoneNtpOrGps = 0; static uint32_t lastTimeValidationWarning = 0; static const uint32_t TIME_VALIDATION_WARNING_INTERVAL_MS = 15000; // 15 seconds -static void triggerNodeInfoCheckOnTimeSource(RTCQuality oldQuality, RTCQuality newQuality) +static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQuality) { if (oldQuality == RTCQualityNone && newQuality > RTCQualityNone && nodeInfoModule) { LOG_DEBUG("Time source acquired (%s -> %s), triggering NodeInfo recheck", RtcName(oldQuality), RtcName(newQuality)); nodeInfoModule->triggerImmediateNodeInfoCheck(); } + if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet && service) { + LOG_DEBUG("RTC net quality reached (%s -> %s), reconciling rx_time", RtcName(oldQuality), RtcName(newQuality)); + service->reconcilePendingRxTimes(); + } } RTCQuality getRTCQuality() @@ -128,7 +133,7 @@ RTCSetResult readFromRTC() timeStartMsec = now; zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; - triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality); + onTimeSourceQualityChanged(oldQuality, currentQuality); } return RTCSetResultSuccess; } else { @@ -174,7 +179,7 @@ RTCSetResult readFromRTC() timeStartMsec = now; zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; - triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality); + onTimeSourceQualityChanged(oldQuality, currentQuality); } return RTCSetResultSuccess; } else { @@ -210,7 +215,7 @@ RTCSetResult readFromRTC() timeStartMsec = now; zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; - triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality); + onTimeSourceQualityChanged(oldQuality, currentQuality); } return RTCSetResultSuccess; } @@ -235,7 +240,7 @@ RTCSetResult readFromRTC() timeStartMsec = now; zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; - triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality); + onTimeSourceQualityChanged(oldQuality, currentQuality); } return RTCSetResultSuccess; } @@ -381,7 +386,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd #endif readFromRTC(); - triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality); + onTimeSourceQualityChanged(oldQuality, currentQuality); return RTCSetResultSuccess; } else { return RTCSetResultNotSet; // RTC was already set with a higher quality time diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 782b0dc73..2f3dbb1a5 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -12,6 +12,7 @@ #include "Power.h" #include "PowerFSM.h" #include "TypeConversions.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include "graphics/draw/MessageRenderer.h" #include "main.h" @@ -180,6 +181,36 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id) return nodenum; } +// Back-calculate the real epoch for any queued packet still carrying a millis() rx_time +// placeholder, now that the clock is trustworthy. +void MeshService::reconcilePendingRxTimes() +{ + const uint32_t nowEpoch = getValidTime(RTCQualityFromNet); + if (nowEpoch == 0) // called before the clock was actually valid - nothing to reconcile against + return; + const uint32_t nowMillis = Time::getMillis(); + + // Rotate the queue once. TypedQueue is strictly FIFO on both backends, so dequeueing and + // re-enqueueing every element in turn leaves the delivery order unchanged. + for (int remaining = toPhoneQueue.numUsed(); remaining > 0; remaining--) { + meshtastic_MeshPacket *p = toPhoneQueue.dequeuePtr(0); + if (!p) // drained from under us - nothing left to rotate + break; + if (!p->has_rx_time) { + // Unsigned subtraction is wraparound-safe; rx_time is a 32-bit wire field, so the + // placeholder was never wider than 32 bits to begin with. + const uint32_t elapsedMs = nowMillis - p->rx_time; + p->rx_time = nowEpoch - (elapsedMs / 1000); + p->has_rx_time = true; + } + if (!toPhoneQueue.enqueue(p, 0)) { // mirrors sendToPhone()'s degrade-on-failure path + LOG_CRIT("Failed to requeue a packet into toPhoneQueue!"); + releaseToPool(p); + fromNum++; // notify observers so the phone can resync + } + } +} + #if MESHTASTIC_ENABLE_FRAME_INJECTION // Deliver a client-supplied frame into the receive pipeline as if it arrived off the LoRa chip. Mirrors // the portduino SimRadio SIMULATOR_APP unwrap so the same host wire format works on real hardware: the @@ -220,7 +251,9 @@ void MeshService::injectAsReceived(meshtastic_MeshPacket &p) mp->rx_rssi = -40; mp->has_rx_rssi = true; } - mp->rx_time = getValidTime(RTCQualityFromNet); + // dispatchReceived() restamps this when the packet re-enters the pipeline below; stamp it + // here anyway so the packet is never observable with an unset arrival time. + stampRxTime(mp); LOG_INFO("inject: RX from=0x%08x to=0x%08x id=0x%08x ch=%d %s", mp->from, mp->to, mp->id, mp->channel, mp->which_payload_variant == meshtastic_MeshPacket_encrypted_tag ? "encrypted" : "decoded"); router->enqueueReceivedMessage(mp); @@ -258,7 +291,8 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p) if (p.id == 0) p.id = generatePacketId(); // If the phone didn't supply one, then pick one - p.rx_time = getValidTime(RTCQualityFromNet); // Record the time the packet arrived from the phone + // Record the time the packet arrived from the phone. + stampRxTime(&p); IF_SCREEN(if (p.decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP && p.decoded.payload.size > 0 && p.to != NODENUM_BROADCAST && p.to != 0) // DM only @@ -595,6 +629,11 @@ bool MeshService::isToPhoneQueueEmpty() uint32_t MeshService::GetTimeSinceMeshPacket(const meshtastic_MeshPacket *mp) { + // rx_time may be a millis() placeholder while has_rx_time is false - don't age it as + // wall-clock, and don't pass it off as "just now" either. + if (!mp->has_rx_time) + return SINCE_UNKNOWN; + uint32_t now = getTime(); uint32_t last_seen = mp->rx_time; diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index b529d2836..bae955969 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -137,6 +137,10 @@ class MeshService // search the queue for a request id and return the matching nodenum NodeNum getNodenumFromRequestId(uint32_t request_id); + // Rewrite any queued-for-phone packet still carrying a millis() rx_time placeholder into a + // real epoch, now that the wall clock is trustworthy. + void reconcilePendingRxTimes(); + // Release QueueStatus packet to pool void releaseQueueStatusToPool(meshtastic_QueueStatus *p) { queueStatusPool.release(p); } @@ -205,6 +209,7 @@ class MeshService ErrorCode sendQueueStatusToPhone(const meshtastic_QueueStatus &qs, ErrorCode res, uint32_t mesh_packet_id); + /// Seconds since the packet arrived, or SINCE_UNKNOWN if it carries no trustworthy rx_time. uint32_t GetTimeSinceMeshPacket(const meshtastic_MeshPacket *mp); private: diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 3c742e7ca..32e851d8b 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3237,6 +3237,11 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n) uint32_t sinceReceived(const meshtastic_MeshPacket *p) { + // rx_time may be a millis() placeholder while has_rx_time is false - don't age it as + // wall-clock, and don't pass it off as "just now" either. + if (!p->has_rx_time) + return SINCE_UNKNOWN; + uint32_t now = getTime(); int delta = (int)(now - p->rx_time); @@ -3633,7 +3638,8 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) return; } - if (mp.rx_time) // if the packet has a valid timestamp use it to update our last_heard + // Gate on has_rx_time, not truthiness - rx_time may hold a millis() placeholder. + if (mp.has_rx_time) info->last_heard = mp.rx_time; // Gate on the packet actually having been received over our own radio, not on rx_snr being diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 5320c571b..152fdee1d 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -166,10 +166,15 @@ inline bool isRadioProfileFile(const char *filename) strcmp(filename, backupFileName) == 0; } +/// "No trustworthy arrival time", as distinct from "zero seconds ago". Deliberately huge so the +/// display formatters fall into their existing unknown-age branches ("unknown age" / "?"). +inline constexpr uint32_t SINCE_UNKNOWN = UINT32_MAX; + /// Given a node, return how many seconds in the past (vs now) that we last heard from it uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n); -/// Given a packet, return how many seconds in the past (vs now) it was received +/// Given a packet, return how many seconds in the past (vs now) it was received, +/// or SINCE_UNKNOWN if it carries no trustworthy rx_time. uint32_t sinceReceived(const meshtastic_MeshPacket *p); /// Outcome of mapping a single on-wire last-byte (next_hop / relay_node) back to a full NodeNum. diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 75385f03f..70d869bea 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -1238,6 +1238,17 @@ void setReplayHopFields(meshtastic_MeshPacket &pkt, const meshtastic_NodeInfoLit pkt.hop_limit = hopsAway < hopLimit ? (uint8_t)(hopLimit - hopsAway) : 0; } +/// 2020-01-01: a boot-relative counter needs ~50 years of uptime to reach this, so it cannot be +/// confused with a real epoch. +constexpr uint32_t MIN_PLAUSIBLE_EPOCH = 1577836800u; + +/// Not every last_heard writer gates on RTC quality - NodeDB::addFromContact stamps it with a bare +/// getTime(), which is boot-relative seconds on a node that has never had a clock. +bool lastHeardIsWallClock(const meshtastic_NodeInfoLite *header) +{ + return header && header->last_heard >= MIN_PLAUSIBLE_EPOCH; +} + } // namespace // Replayed packets deliberately leave rx_rssi absent. NodeInfoLite stores no RSSI, and @@ -1258,6 +1269,8 @@ meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const mesh // fix time (which is often 0 and, when present, already round-trips inside the payload // via ConvertToPosition). pkt.rx_time = header ? header->last_heard : 0; + // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). + pkt.has_rx_time = lastHeardIsWallClock(header); // Stable per-node/per-fix id: replaying the same unchanged history on every // reconnect must not look like a brand new packet to the phone's history/dedup. pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_POSITION_APP); @@ -1285,6 +1298,8 @@ meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const mes // No native timestamp on telemetry packets here; use last_heard. const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). + pkt.has_rx_time = lastHeardIsWallClock(header); pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag); pkt.channel = header ? header->channel : 0; pkt.rx_snr = header ? header->snr : 0; @@ -1391,6 +1406,8 @@ meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const pkt.to = NODENUM_BROADCAST; const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). + pkt.has_rx_time = lastHeardIsWallClock(header); pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag); pkt.channel = header ? header->channel : 0; pkt.rx_snr = header ? header->snr : 0; @@ -1456,6 +1473,8 @@ meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const mesht // StatusMessage has no native timestamp; use last_heard. const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + // Present only when last_heard is a genuine epoch - see lastHeardIsWallClock(). + pkt.has_rx_time = lastHeardIsWallClock(header); pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP); pkt.channel = header ? header->channel : 0; pkt.rx_snr = header ? header->snr : 0; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index ec1b9c9cb..9c199f5a7 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -877,7 +877,7 @@ void printPacket(const char *prefix, const meshtastic_MeshPacket *p) out += DEBUG_PORT.mt_sprintf(" len=%d", p->encrypted.size + sizeof(PacketHeader)); } - if (p->rx_time != 0) + if (p->has_rx_time) // rx_time has explicit presence; a millis() placeholder isn't a real reading to print out += DEBUG_PORT.mt_sprintf(" rxtime=%u", p->rx_time); if (p->rx_snr != 0.0) out += DEBUG_PORT.mt_sprintf(" rxSNR=%g", p->rx_snr); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 2ec9b43b3..cf0e05c9a 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -5,6 +5,7 @@ #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include "configuration.h" @@ -269,6 +270,19 @@ PacketId generatePacketId() return id; } +RxTimeStamp computeRxTimeStamp() +{ + const bool haveTime = getRTCQuality() >= RTCQualityFromNet; + return {haveTime ? getValidTime(RTCQualityFromNet) : Time::getMillis(), haveTime}; +} + +void stampRxTime(meshtastic_MeshPacket *p) +{ + const RxTimeStamp ts = computeRxTimeStamp(); + p->rx_time = ts.time; + p->has_rx_time = ts.valid; +} + meshtastic_MeshPacket *Router::allocForSending() { meshtastic_MeshPacket *p = packetPool.allocZeroed(); @@ -280,8 +294,8 @@ meshtastic_MeshPacket *Router::allocForSending() p->to = NODENUM_BROADCAST; p->hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); p->id = generatePacketId(); - p->rx_time = - getValidTime(RTCQualityFromNet); // Just in case we process the packet locally - make sure it has a valid timestamp + // Just in case we process the packet locally - make sure it has a timestamp. + stampRxTime(p); return p; } @@ -1305,12 +1319,15 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) if (src == RX_SRC_RADIO) applyRoutingAuthCache(p); - // Also, we should set the time from the ISR and it should have msec level resolution. // Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp. - const uint32_t rxTime = getValidTime(RTCQualityFromNet); - p->rx_time = rxTime; - if (p_encrypted) - p_encrypted->rx_time = rxTime; + // See computeRxTimeStamp() for the placeholder/has_rx_time semantics. + const RxTimeStamp rxStamp = computeRxTimeStamp(); + p->rx_time = rxStamp.time; + p->has_rx_time = rxStamp.valid; + if (p_encrypted) { + p_encrypted->rx_time = rxStamp.time; + p_encrypted->has_rx_time = rxStamp.valid; + } // Take those raw bytes and convert them back into a well structured protobuf we can understand auto decodedState = perhapsDecode(p); @@ -1443,7 +1460,8 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) #if ARCH_PORTDUINO // Even ignored packets get logged in the trace if (portduino_config.traceFilename != "" || portduino_config.logoutputlevel == level_trace) { - p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone + // Store the arrival timestamp for the phone before it's traced. + stampRxTime(p); LOG_TRACE("%s", MeshPacketSerializer::JsonSerializeEncrypted(p).c_str()); } #endif diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 4a6356cb5..be686ee15 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -10,6 +10,17 @@ #include "concurrency/OSThread.h" #include +/// rx_time/has_rx_time for "now": a real epoch when the clock is trustworthy, else a +/// Time::getMillis() placeholder with valid=false. +struct RxTimeStamp { + uint32_t time; + bool valid; +}; +RxTimeStamp computeRxTimeStamp(); + +/// Stamp p->rx_time/p->has_rx_time with computeRxTimeStamp(). +void stampRxTime(meshtastic_MeshPacket *p); + /** * A mesh aware router that supports multiple interfaces. */ diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 637aae3ab..60d817d73 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -1079,7 +1079,14 @@ typedef struct _meshtastic_MeshPacket { /* The time this message was received by the esp32 (secs since 1970). Note: this field is _never_ sent on the radio link itself (to save space) Times are typically not sent over the mesh, but they will be added to any Packet - (chain of SubPacket) sent to the phone (so the phone can know exact time of reception) */ + (chain of SubPacket) sent to the phone (so the phone can know exact time of reception) + Explicit presence: firmware cannot always attach a trustworthy wall-clock timestamp at the + moment of reception - a node with no GPS and no phone connected yet has no time source at + all. has_rx_time disambiguates that state from a genuine (if coincidental) 1970-01-01 + reading. A packet delivered with this field absent may still be re-timestamped once a valid + clock becomes available, before the phone ever sees it - "absent" is not guaranteed + permanent, only "not yet known at last observation". */ + bool has_rx_time; uint32_t rx_time; /* *Never* sent over the radio links. Set during reception to indicate the SNR of this packet. @@ -1740,7 +1747,7 @@ extern "C" { #define meshtastic_Waypoint_init_default {0, false, 0, false, 0, 0, 0, "", "", 0, 0, false, meshtastic_BoundingBox_init_default, 0, 0, 0} #define meshtastic_StatusMessage_init_default {""} #define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} +#define meshtastic_MeshPacket_init_default {0, 0, 0, 0, {meshtastic_Data_init_default}, 0, false, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} #define meshtastic_NodeInfo_init_default {0, false, meshtastic_User_init_default, false, meshtastic_Position_init_default, 0, 0, false, meshtastic_DeviceMetrics_init_default, 0, 0, false, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_default {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN} @@ -1779,7 +1786,7 @@ extern "C" { #define meshtastic_Waypoint_init_zero {0, false, 0, false, 0, 0, 0, "", "", 0, 0, false, meshtastic_BoundingBox_init_zero, 0, 0, 0} #define meshtastic_StatusMessage_init_zero {""} #define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0} -#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} +#define meshtastic_MeshPacket_init_zero {0, 0, 0, 0, {meshtastic_Data_init_zero}, 0, false, 0, 0, 0, 0, _meshtastic_MeshPacket_Priority_MIN, false, 0, _meshtastic_MeshPacket_Delayed_MIN, 0, 0, {0, {0}}, 0, 0, 0, 0, _meshtastic_MeshPacket_TransportMechanism_MIN, 0} #define meshtastic_NodeInfo_init_zero {0, false, meshtastic_User_init_zero, false, meshtastic_Position_init_zero, 0, 0, false, meshtastic_DeviceMetrics_init_zero, 0, 0, false, 0, 0, 0, 0, 0, 0} #define meshtastic_MyNodeInfo_init_zero {0, 0, 0, {0, {0}}, "", _meshtastic_FirmwareEdition_MIN, 0} #define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN} @@ -2199,7 +2206,7 @@ X(a, STATIC, SINGULAR, UINT32, channel, 3) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,decoded,decoded), 4) \ X(a, STATIC, ONEOF, BYTES, (payload_variant,encrypted,encrypted), 5) \ X(a, STATIC, SINGULAR, FIXED32, id, 6) \ -X(a, STATIC, SINGULAR, FIXED32, rx_time, 7) \ +X(a, STATIC, OPTIONAL, FIXED32, rx_time, 7) \ X(a, STATIC, SINGULAR, FLOAT, rx_snr, 8) \ X(a, STATIC, SINGULAR, UINT32, hop_limit, 9) \ X(a, STATIC, SINGULAR, BOOL, want_ack, 10) \ diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 2e9d865a6..cc5c6e807 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -342,7 +342,7 @@ void MeshBeaconBroadcastModule::sendBeacon() p->hop_limit = 0; // all beacon packets are zero hopped to limit spamming. p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; p->want_ack = false; - p->rx_time = getValidTime(RTCQualityFromNet); + stampRxTime(p); }; // ── Packet type decisions ──────────────────────────────────────────────── diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index 5b8d46223..cf6180ca2 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -5,6 +5,7 @@ #include "NodeStatus.h" #include "Router.h" #include "TransmitHistory.h" +#include "UptimeClock.h" #include "configuration.h" #include "gps/RTC.h" #include "main.h" @@ -33,7 +34,9 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes // Suppress replies to senders we've replied to recently (12H window) if (mp.decoded.want_response && !isFromUs(&mp)) { const NodeNum sender = getFrom(&mp); - const uint32_t now = mp.rx_time ? mp.rx_time : getTime(); + // A local dedup window, not a wall-clock reading - uptime avoids RTC-quality jumps and + // replayed packets' stale rx_time perturbing it. + const uint32_t now = (uint32_t)(Time::getMillis64() / 1000); auto it = lastNodeInfoSeen.find(sender); if (it != lastNodeInfoSeen.end()) { uint32_t sinceLast = now >= it->second ? now - it->second : 0; diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index 3c6d2de7d..bd023716a 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -193,6 +193,9 @@ void StoreForwardModule::historyAdd(const meshtastic_MeshPacket &mp) } this->packetHistory[this->packetHistoryTotalCount].time = getTime(); + // getTime() silently falls back to a boot-relative count with no RTC source at all; record + // whether it was actually trustworthy so replay doesn't have to guess from current quality. + this->packetHistory[this->packetHistoryTotalCount].has_rx_time = (getRTCQuality() >= RTCQualityFromNet); this->packetHistory[this->packetHistoryTotalCount].to = mp.to; this->packetHistory[this->packetHistoryTotalCount].channel = mp.channel; this->packetHistory[this->packetHistoryTotalCount].from = getFrom(&mp); @@ -201,6 +204,7 @@ void StoreForwardModule::historyAdd(const meshtastic_MeshPacket &mp) this->packetHistory[this->packetHistoryTotalCount].emoji = (bool)p.emoji; this->packetHistory[this->packetHistoryTotalCount].payload_size = p.payload.size; this->packetHistory[this->packetHistoryTotalCount].rx_rssi = mp.rx_rssi; + this->packetHistory[this->packetHistoryTotalCount].has_rx_rssi = mp.has_rx_rssi; this->packetHistory[this->packetHistoryTotalCount].rx_snr = mp.rx_snr; this->packetHistory[this->packetHistoryTotalCount].hop_start = mp.hop_start; this->packetHistory[this->packetHistoryTotalCount].hop_limit = mp.hop_limit; @@ -257,9 +261,10 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t p->channel = this->packetHistory[i].channel; p->decoded.reply_id = this->packetHistory[i].reply_id; p->rx_time = this->packetHistory[i].time; + p->has_rx_time = this->packetHistory[i].has_rx_time; // presence captured at store time, not replay time p->decoded.emoji = (uint32_t)this->packetHistory[i].emoji; p->rx_rssi = this->packetHistory[i].rx_rssi; - p->has_rx_rssi = true; // rx_rssi has explicit presence; the stored value was a genuine measurement + p->has_rx_rssi = this->packetHistory[i].has_rx_rssi; // presence captured at store time, not replay time p->rx_snr = this->packetHistory[i].rx_snr; p->hop_start = this->packetHistory[i].hop_start; p->hop_limit = this->packetHistory[i].hop_limit; diff --git a/src/modules/StoreForwardModule.h b/src/modules/StoreForwardModule.h index 77565b22c..82406b70a 100644 --- a/src/modules/StoreForwardModule.h +++ b/src/modules/StoreForwardModule.h @@ -12,6 +12,7 @@ struct PacketHistoryStruct { uint32_t time; + bool has_rx_time; // whether `time` was a trustworthy epoch when captured, not a getTime() boot-relative fallback uint32_t to; uint32_t from; uint32_t id; @@ -21,6 +22,7 @@ struct PacketHistoryStruct { uint8_t payload[meshtastic_Constants_DATA_PAYLOAD_LEN]; pb_size_t payload_size; int32_t rx_rssi; + bool has_rx_rssi; // whether rx_rssi was a genuine measurement (e.g. not MQTT-relayed) when captured float rx_snr; uint8_t hop_start; uint8_t hop_limit; diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index 944bc4db4..dc6d2e8d0 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -132,8 +132,13 @@ void HealthTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * } // Display "Health From: ..." on its own + char agoStr[16]; + if (agoSecs == SINCE_UNKNOWN) + snprintf(agoStr, sizeof(agoStr), "?"); // no trustworthy arrival time to age against + else + snprintf(agoStr, sizeof(agoStr), "%us", (unsigned)agoSecs); char headerStr[64]; - snprintf(headerStr, sizeof(headerStr), "Health From: %s(%ds)", lastSender, (int)agoSecs); + snprintf(headerStr, sizeof(headerStr), "Health From: %s(%s)", lastSender, agoStr); display->drawString(x, y, headerStr); char last_temp[16]; diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 23ef56ba8..4ec1adb1c 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -154,8 +154,13 @@ void PowerTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *s } // Display "Pow. From: ..." + char agoStr[16]; + if (agoSecs == SINCE_UNKNOWN) + snprintf(agoStr, sizeof(agoStr), "?"); // no trustworthy arrival time to age against + else + snprintf(agoStr, sizeof(agoStr), "%us", (unsigned)agoSecs); char fromStr[64]; - snprintf(fromStr, sizeof(fromStr), "Pow. From: %s (%us)", lastSender, agoSecs); + snprintf(fromStr, sizeof(fromStr), "Pow. From: %s (%s)", lastSender, agoStr); display->drawString(x, graphics::getTextPositions(display)[line++], fromStr); // Display current and voltage based on ...power_metrics.has_[channel/voltage/current]... flags diff --git a/src/serialization/MeshPacketSerializer.cpp b/src/serialization/MeshPacketSerializer.cpp index 88d134496..f889c4f3c 100644 --- a/src/serialization/MeshPacketSerializer.cpp +++ b/src/serialization/MeshPacketSerializer.cpp @@ -420,7 +420,8 @@ std::string MeshPacketSerializer::JsonSerialize(const meshtastic_MeshPacket *mp, } jsonObj["id"] = (Json::UInt)mp->id; - jsonObj["timestamp"] = (Json::UInt)mp->rx_time; + // Emit 0 rather than leak a millis() placeholder when has_rx_time is false. + jsonObj["timestamp"] = mp->has_rx_time ? (Json::UInt)mp->rx_time : 0; jsonObj["to"] = (Json::UInt)mp->to; jsonObj["from"] = (Json::UInt)mp->from; jsonObj["channel"] = (Json::UInt)mp->channel; @@ -452,7 +453,8 @@ std::string MeshPacketSerializer::JsonSerializeEncrypted(const meshtastic_MeshPa jsonObj["id"] = (Json::UInt)mp->id; jsonObj["time_ms"] = (double)millis(); - jsonObj["timestamp"] = (Json::UInt)mp->rx_time; + // Emit 0 rather than leak a millis() placeholder when has_rx_time is false. + jsonObj["timestamp"] = mp->has_rx_time ? (Json::UInt)mp->rx_time : 0; jsonObj["to"] = (Json::UInt)mp->to; jsonObj["from"] = (Json::UInt)mp->from; jsonObj["channel"] = (Json::UInt)mp->channel; diff --git a/test/test_meshpacket_serializer/ports/test_timestamp.cpp b/test/test_meshpacket_serializer/ports/test_timestamp.cpp new file mode 100644 index 000000000..333945f80 --- /dev/null +++ b/test/test_meshpacket_serializer/ports/test_timestamp.cpp @@ -0,0 +1,37 @@ +#include "../test_helpers.h" + +void test_timestamp_present_when_has_rx_time() +{ + const char *test_text = "hi"; + meshtastic_MeshPacket packet = + create_test_packet(meshtastic_PortNum_TEXT_MESSAGE_APP, reinterpret_cast(test_text), strlen(test_text)); + + std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); + Json::Value root = parse_json(json); + TEST_ASSERT_TRUE(root.isMember("timestamp")); + TEST_ASSERT_EQUAL_UINT32(1609459200u, root["timestamp"].asUInt()); +} + +void test_timestamp_zeroed_when_rx_time_absent() +{ + const char *test_text = "hi"; + meshtastic_MeshPacket packet = create_test_packet_no_rx_time(meshtastic_PortNum_TEXT_MESSAGE_APP, + reinterpret_cast(test_text), strlen(test_text)); + + std::string json = MeshPacketSerializer::JsonSerialize(&packet, false); + Json::Value root = parse_json(json); + TEST_ASSERT_TRUE(root.isMember("timestamp")); + TEST_ASSERT_EQUAL_UINT32(0u, root["timestamp"].asUInt()); // must not leak the millis() placeholder +} + +void test_encrypted_timestamp_zeroed_when_rx_time_absent() +{ + const char *test_text = "hi"; + meshtastic_MeshPacket packet = create_test_packet_no_rx_time(meshtastic_PortNum_TEXT_MESSAGE_APP, + reinterpret_cast(test_text), strlen(test_text)); + + std::string json = MeshPacketSerializer::JsonSerializeEncrypted(&packet); + Json::Value root = parse_json(json); + TEST_ASSERT_TRUE(root.isMember("timestamp")); + TEST_ASSERT_EQUAL_UINT32(0u, root["timestamp"].asUInt()); +} diff --git a/test/test_meshpacket_serializer/test_helpers.h b/test/test_meshpacket_serializer/test_helpers.h index da8c89fff..2dc06cec7 100644 --- a/test/test_meshpacket_serializer/test_helpers.h +++ b/test/test_meshpacket_serializer/test_helpers.h @@ -37,6 +37,7 @@ static meshtastic_MeshPacket create_test_packet(meshtastic_PortNum port, const u packet.want_ack = false; packet.priority = meshtastic_MeshPacket_Priority_UNSET; packet.rx_time = 1609459200; + packet.has_rx_time = true; // rx_time has explicit presence; mark this synthetic reading as measured packet.rx_snr = 10.5f; packet.hop_start = 3; packet.rx_rssi = -85; @@ -62,3 +63,14 @@ static meshtastic_MeshPacket create_test_packet(meshtastic_PortNum port, const u return packet; } + +// Same as create_test_packet(), but with rx_time absent (has_rx_time = false) and a nonzero +// millis()-style placeholder in rx_time, to verify serializers treat it as unknown, not a reading. +static meshtastic_MeshPacket create_test_packet_no_rx_time(meshtastic_PortNum port, const uint8_t *payload, size_t payload_size, + int payload_variant = meshtastic_MeshPacket_decoded_tag) +{ + meshtastic_MeshPacket packet = create_test_packet(port, payload, payload_size, payload_variant); + packet.rx_time = 123456; // a plausible millis() placeholder, not a real epoch + packet.has_rx_time = false; + return packet; +} diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp index 484db8d74..58483ff91 100644 --- a/test/test_meshpacket_serializer/test_serializer.cpp +++ b/test/test_meshpacket_serializer/test_serializer.cpp @@ -19,6 +19,9 @@ void test_telemetry_environment_metrics_complete_coverage(); void test_telemetry_environment_metrics_unset_fields(); void test_encrypted_packet_serialization(); void test_empty_encrypted_packet(); +void test_timestamp_present_when_has_rx_time(); +void test_timestamp_zeroed_when_rx_time_absent(); +void test_encrypted_timestamp_zeroed_when_rx_time_absent(); void setup() { @@ -52,6 +55,11 @@ void setup() RUN_TEST(test_encrypted_packet_serialization); RUN_TEST(test_empty_encrypted_packet); + // rx_time explicit-presence tests + RUN_TEST(test_timestamp_present_when_has_rx_time); + RUN_TEST(test_timestamp_zeroed_when_rx_time_absent); + RUN_TEST(test_encrypted_timestamp_zeroed_when_rx_time_absent); + UNITY_END(); } diff --git a/test/test_stream_api/test_main.cpp b/test/test_stream_api/test_main.cpp index 1fbd370a0..5075fe461 100644 --- a/test/test_stream_api/test_main.cpp +++ b/test/test_stream_api/test_main.cpp @@ -1,7 +1,9 @@ #include "MeshTypes.h" #include "SerialConsole.h" #include "TestUtil.h" +#include "UptimeClock.h" #include "configuration.h" +#include "gps/RTC.h" #include "mesh-pb-constants.h" #include "mesh/MeshService.h" #include "mesh/NodeDB.h" @@ -10,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -522,6 +525,131 @@ static void test_want_config_includes_status_message_module_config(void) TEST_ASSERT_TRUE(foundStatusMessageConfig); } +/// Queue a packet as Router::dispatchReceived would have, before any time source existed. +static void queuePendingTimePlaceholderPacket(NodeNum from, uint32_t placeholderMillis) +{ + meshtastic_MeshPacket pending = meshtastic_MeshPacket_init_zero; + pending.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + pending.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + pending.from = from; + pending.to = NODENUM_BROADCAST; + pending.rx_time = placeholderMillis; + pending.has_rx_time = false; + service->sendToPhone(packetPool.allocCopy(pending)); +} + +static void startHandshake(PhoneAPITestShim &api) +{ + meshtastic_ToRadio request = meshtastic_ToRadio_init_zero; + request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag; + request.want_config_id = SPECIAL_NONCE_ONLY_CONFIG; + uint8_t requestBytes[meshtastic_ToRadio_size]; + const size_t requestSize = pb_encode_to_bytes(requestBytes, sizeof(requestBytes), &meshtastic_ToRadio_msg, &request); + api.handleToRadio(requestBytes, requestSize); +} + +/// Drain the config stream looking for the first packet from `from`; false if never delivered. +static bool drainHandshakeForPacketFrom(PhoneAPITestShim &api, NodeNum from, meshtastic_MeshPacket &outPacket) +{ + for (unsigned i = 0; i < 256; ++i) { + uint8_t responseBytes[meshtastic_FromRadio_size]; + const size_t responseSize = api.getFromRadio(responseBytes); + if (responseSize == 0) + return false; + meshtastic_FromRadio response = meshtastic_FromRadio_init_zero; + TEST_ASSERT_TRUE(pb_decode_from_bytes(responseBytes, responseSize, &meshtastic_FromRadio_msg, &response)); + if (response.which_payload_variant == meshtastic_FromRadio_packet_tag && response.packet.from == from) { + outPacket = response.packet; + return true; + } + } + return false; +} + +/// Swaps in a scratch NodeDB and the injected clock, restoring both plus the RTC on destruction. +/// Unity's TEST_ASSERT longjmps out on failure, so cleanup must not live at the end of the test. +class ScopedTimeFixture +{ + public: + ScopedTimeFixture(uint32_t startMillis) : previous(nodeDB) + { + resetRTCStateForTests(); + nodeDB = &instance; + Time::setTestMillis(startMillis); + } + ~ScopedTimeFixture() + { + nodeDB = previous; + Time::useRealClock(); + resetRTCStateForTests(); + } + + private: + NodeDB instance; + NodeDB *previous; +}; + +// Time given at the start of the handshake, before the queued packet is drained: reconciliation +// (fired by the RTC quality crossing hook in RTC.cpp) rewrites the placeholder in place. +static void test_time_given_at_handshake_start_reconciles_queued_packet(void) +{ + ScopedMeshService scopedService; + ScopedTimeFixture timeFixture(5000); + + const NodeNum sender = 0x12345678; + queuePendingTimePlaceholderPacket(sender, 2000); // "received" 3s before the test's current millis() + + PhoneAPITestShim api; + startHandshake(api); + + struct timeval networkTime; + networkTime.tv_sec = time(NULL) + SEC_PER_DAY; + networkTime.tv_usec = 0; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime)); + + meshtastic_MeshPacket delivered; + TEST_ASSERT_TRUE_MESSAGE(drainHandshakeForPacketFrom(api, sender, delivered), + "queued packet was not delivered during the handshake"); + TEST_ASSERT_TRUE(delivered.has_rx_time); + TEST_ASSERT_UINT32_WITHIN(2, (uint32_t)networkTime.tv_sec - 3, delivered.rx_time); + + api.close(); +} + +// Time given at the end - after the queued packet already left via the handshake: the delivered +// copy keeps its unresolved placeholder, since reconciliation can only rewrite what's still queued. +static void test_time_given_at_handshake_end_does_not_rewrite_already_sent_packet(void) +{ + ScopedMeshService scopedService; + ScopedTimeFixture timeFixture(5000); + + const NodeNum sender = 0x12345678; + queuePendingTimePlaceholderPacket(sender, 2000); + + PhoneAPITestShim api; + startHandshake(api); + + // rx_time is proto3 optional, so has_rx_time false omits it from the wire entirely: the + // decoded copy reads back 0 and the placeholder itself never left the device. + meshtastic_MeshPacket delivered; + TEST_ASSERT_TRUE_MESSAGE(drainHandshakeForPacketFrom(api, sender, delivered), + "queued packet was not delivered during the handshake"); + TEST_ASSERT_FALSE(delivered.has_rx_time); + TEST_ASSERT_EQUAL_UINT32(0u, delivered.rx_time); + + // Time-giving transaction happens only now, at the end of the handshake. + struct timeval networkTime; + networkTime.tv_sec = time(NULL) + SEC_PER_DAY; + networkTime.tv_usec = 0; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime)); + + // The already-delivered copy is a value, not a queue reference - untouched either way. + TEST_ASSERT_FALSE(delivered.has_rx_time); + TEST_ASSERT_EQUAL_UINT32(0u, delivered.rx_time); + + api.close(); +} + /// Unity per-test setup; fixtures are local to each test. void setUp(void) {} /// Unity per-test teardown; fixtures clean themselves up. @@ -544,6 +672,8 @@ void setup() RUN_TEST(test_lockdown_admin_gate_ignores_wire_from); RUN_TEST(test_lockdown_admin_gate_rejects_undecodable_admin); RUN_TEST(test_want_config_includes_status_message_module_config); + RUN_TEST(test_time_given_at_handshake_start_reconciles_queued_packet); + RUN_TEST(test_time_given_at_handshake_end_does_not_rewrite_already_sent_packet); // usingProtobufs intentionally has no reset path, so this must run last. RUN_TEST(test_serial_console_suppresses_raw_output_in_protobuf_mode); exit(UNITY_END()); diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index afdcb9646..34f5a5456 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -766,6 +766,7 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie observed.decoded.bitfield = BITFIELD_WANT_RESPONSE_MASK; observed.channel = 2; observed.rx_time = 123456; + observed.has_rx_time = true; // rx_time has explicit presence; mark this synthetic reading as measured ProcessMessage observedResult = module.handleReceived(observed); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(observedResult)); diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 22fb6223c..006a4d0b2 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -423,7 +423,7 @@ build_src_filter = + + + + + + + + - + + + + + + + + + + + + + + + + + + + + From 50a37b3a19e05d46e7dcc5e3f4bacf6e8fb1efbc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:03:47 -0500 Subject: [PATCH 17/30] chore(deps): update meshtastic/device-ui digest to bcb327f (#11276) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 22348ec30..040da932c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -128,7 +128,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/2f5a7dd9c9af19b6f3a2ddab1477f75aceae281b.zip + https://github.com/meshtastic/device-ui/archive/bcb327f058469282408eed93e27249d6447de9c0.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From df6e67f70be6139b0c8385b24ad5b159af0449d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 30 Jul 2026 00:28:25 +0200 Subject: [PATCH 18/30] Signing and ingress hardening (#11282) * Include warm-tier signers in the identity update gate * Fail the send when PKI encryption fails * Require signatures on licensed unicasts * Include warm-tier signers in the NodeInfo downgrade drop * Clamp hop fields on UDP multicast ingress * Address review comments on signing hardening Condense the updateUser rationale to two lines and stop calling the Balanced-mode drop a broadcast now that licensed unicasts reach it. --- src/mesh/NodeDB.cpp | 7 +++---- src/mesh/Router.cpp | 21 ++++++++++----------- src/mesh/udp/UdpMulticastHandler.h | 5 +++++ src/modules/NodeInfoModule.cpp | 7 +++---- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 32e851d8b..7b1263147 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3504,10 +3504,9 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) */ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned) { - // Only a signed update may change the identity of a node that has proven it signs; our own record is - // exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier. - const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId); - if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) { + // Only a signed update may change the identity of a proven signer; our own record is exempt. + // Checked before getOrCreateMeshNode so a refusal cannot evict; isKnownXeddsaSigner covers the warm tier. + if (nodeId != getNodeNum() && isKnownXeddsaSigner(nodeId) && !xeddsaSigned) { LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId); return false; } diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index cf0e05c9a..3abcf64b3 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -681,20 +681,14 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) if (compatible) return true; - // In Balanced, preserve legacy unsigned-unicast compatibility and only reject the class a - // signing node always signs: a non-PKI broadcast whose signed encoding would still fit the - // LoRa frame. Canonical sizing removes unknown protobuf fields before mirroring the - // sender-side signedDataFits() gate, so this counts the same fields that gate counted. - // Unicast packets and broadcasts too big to carry a signature are never signed, so they - // must not be hard-failed here even for a known signer (PKI already returned above). - // isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store - // must not become impersonatable via unsigned broadcasts until it is re-heard. - if (nodeDB->isKnownXeddsaSigner(p->from) && isBroadcast(p->to)) { + // Balanced rejects only what a signer always signs: non-PKI broadcasts whose signed encoding + // would have fit, plus unicasts on ham where licensed senders sign too. Mirrors perhapsEncode. + if (nodeDB->isKnownXeddsaSigner(p->from) && (isBroadcast(p->to) || owner.is_licensed)) { size_t canonicalSize; if (!canonicalSignableSize(&p->decoded, &canonicalSize)) return true; // can't size it; never drop on a sizing failure if (canonicalSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { - LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from); + LOG_WARN("Dropping unsigned packet from 0x%08x that previously signed", p->from); return false; } } @@ -1172,7 +1166,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) *destKey.bytes); return meshtastic_Routing_Error_PKI_FAILED; } - crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes); + // On failure encrypted.bytes holds no ciphertext, so continuing would put the plaintext + // on the air labelled pki_encrypted. + if (!crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes)) { + LOG_WARN("PKI encryption failed for destination node 0x%08x", p->to); + return meshtastic_Routing_Error_PKI_FAILED; + } numbytes += MESHTASTIC_PKC_OVERHEAD; p->channel = 0; p->pki_encrypted = true; diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 60967ccef..4a05d1292 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -79,6 +79,11 @@ class UdpMulticastHandler final LOG_WARN("UDP packet with spoofed local from=0x%08x, dropping", mp.from); return; } + // Same clamp the MQTT ingress applies: an out-of-range hop count is not relayable. + if (mp.hop_limit > HOP_MAX || mp.hop_start > HOP_MAX) { + LOG_WARN("UDP packet with invalid hop_limit(%u) or hop_start(%u), dropping", mp.hop_limit, mp.hop_start); + return; + } mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; // Authentication metadata is local-only; Router re-establishes it after successful PKI decryption. mp.pki_encrypted = false; diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index cf6180ca2..91ec9d633 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -53,10 +53,9 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes return true; } NodeNum sourceNum = getFrom(&mp); - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum); - // Broadcasts only: senders never sign unicast NodeInfo, so dropping it would break exchanges - // with signer nodes. Backstops ingress that skips Router's downgrade drop (e.g. decoded MQTT). - if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed && isBroadcast(mp.to)) { + // Broadcasts only: unicast NodeInfo is unsigned off ham, so updateUser refuses the identity + // write instead. isKnownXeddsaSigner also covers the warm tier. + if (nodeDB->isKnownXeddsaSigner(sourceNum) && !mp.xeddsa_signed && isBroadcast(mp.to)) { LOG_WARN("Dropping unsigned NodeInfo broadcast from node 0x%08x that previously signed", sourceNum); return true; } From daf12135803416f657628c38ea6cea18e14b48a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 30 Jul 2026 00:29:12 +0200 Subject: [PATCH 19/30] Licensed channel defaults, phone map growth, and payload read bounds (#11286) * Strip the default PSK when licensed defaults are installed * Only record rate-limited portnums from the phone * Bound payload reads by the received size --- src/MessageStore.cpp | 7 ++++++- src/mesh/NodeDB.cpp | 4 ++++ src/mesh/PhoneAPI.cpp | 7 ++++++- src/modules/DropzoneModule.cpp | 11 +++++++---- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 8b6775eec..4030bdd28 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -215,7 +215,12 @@ const StoredMessage *MessageStore::tryAddFromPacket(const meshtastic_MeshPacket sm.channelIndex = packet.channel; const char *payload = reinterpret_cast(packet.decoded.payload.bytes); - size_t len = strnlen(payload, MAX_MESSAGE_SIZE - 1); + // payload.bytes is not NUL-terminated, so bound by the received size too: a shorter message + // stored after a longer one would otherwise pick up the previous occupant's trailing bytes. + size_t avail = packet.decoded.payload.size; + if (avail > MAX_MESSAGE_SIZE - 1) + avail = MAX_MESSAGE_SIZE - 1; + size_t len = strnlen(payload, avail); sm.textOffset = storeTextInPool(payload, len); sm.textLength = len; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 7b1263147..cdd61d15f 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -741,6 +741,10 @@ void NodeDB::resetRadioConfig(bool is_fresh_install) LOG_INFO("Set default channel and radio preferences!"); channels.initDefaults(); + // Defaults ship the public PSK, so strip it again before onConfigChanged() publishes hashes; + // loadFromDisk's sanitation is a no-op when the channel file was absent or corrupt. + if (owner.is_licensed) + channels.ensureLicensedOperation(); } channels.onConfigChanged(); diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 70d869bea..572b79242 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -1855,7 +1855,12 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p) p.want_ack = true; } - lastPortNumToRadio[p.decoded.portnum] = millis(); + // Only the rate-limited ports above are ever read back, so recording any other portnum would let + // a client grow this map without bound by cycling through them. + if (IS_ONE_OF(p.decoded.portnum, meshtastic_PortNum_TRACEROUTE_APP, meshtastic_PortNum_POSITION_APP, + meshtastic_PortNum_WAYPOINT_APP, meshtastic_PortNum_ALERT_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_TEXT_MESSAGE_APP)) + lastPortNumToRadio[p.decoded.portnum] = millis(); service->handleToRadio(p); return true; } diff --git a/src/modules/DropzoneModule.cpp b/src/modules/DropzoneModule.cpp index 2340f31f2..4658cb5b6 100644 --- a/src/modules/DropzoneModule.cpp +++ b/src/modules/DropzoneModule.cpp @@ -32,14 +32,17 @@ ProcessMessage DropzoneModule::handleReceived(const meshtastic_MeshPacket &mp) auto &p = mp.decoded; char matchCompare[54]; auto incomingMessage = reinterpret_cast(p.payload.bytes); - sprintf(matchCompare, "%s conditions", owner.short_name); - if (strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) { + // payload.bytes is not NUL-terminated, so a comparison longer than the received size would read + // whatever the previous occupant of the packet left behind. + const size_t received = p.payload.size; + snprintf(matchCompare, sizeof(matchCompare), "%s conditions", owner.short_name); + if (received >= strlen(matchCompare) && strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) { LOG_DEBUG("Received dropzone conditions request"); startSendConditions = millis(); } - sprintf(matchCompare, "%s conditions", owner.long_name); - if (strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) { + snprintf(matchCompare, sizeof(matchCompare), "%s conditions", owner.long_name); + if (received >= strlen(matchCompare) && strncasecmp(incomingMessage, matchCompare, strlen(matchCompare)) == 0) { LOG_DEBUG("Received dropzone conditions request"); startSendConditions = millis(); } From 9c260ad7923d9bb9364867dda527e8be7ac01136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 30 Jul 2026 00:30:30 +0200 Subject: [PATCH 20/30] Signature key resolution and a send-path leak (#11283) * Verify signatures against authoritative keys only * Release the packet on the unset-variant send error --- src/mesh/Router.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 3abcf64b3..b60942bcd 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -465,6 +465,8 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) if (!(p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag || p->which_payload_variant == meshtastic_MeshPacket_decoded_tag)) { + // Error returns from here own the packet, as the position-precision path below does. + packetPool.release(p); return meshtastic_Routing_Error_BAD_REQUEST; } @@ -633,7 +635,9 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) { meshtastic_NodeInfoLite_public_key_t senderKey = {0, {0}}; meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); - if (nodeDB->copyPublicKey(p->from, senderKey)) { + // Authoritative keys only: verifying against an opportunistic cache key would let a planted + // key mark its own node a signer, the trust loop #11116 closed on the decrypt path. + if (nodeDB->copyPublicKeyAuthoritative(p->from, senderKey)) { p->xeddsa_signed = crypto->xeddsa_verify(senderKey.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, p->decoded.xeddsa_signature.bytes); From 16765dd08946c7859c85d833db456953f19bacb4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:11:23 -0500 Subject: [PATCH 21/30] Upgrade trunk (#11251) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 7bec30998..ad8a3eed9 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -4,7 +4,7 @@ cli: plugins: sources: - id: trunk - ref: v1.10.0 + ref: v1.10.2 uri: https://github.com/trunk-io/plugins lint: # Custom file set + formatter that rewrites Unicode em-dash (U+2014, UTF-8 @@ -69,19 +69,19 @@ lint: enabled: - ascii-dash@SYSTEM - too-many-defined@SYSTEM - - checkov@3.2.529 - - renovate@43.150.0 - - prettier@3.8.3 - - trufflehog@3.95.3 + - checkov@3.3.8 + - renovate@44.2.3 + - prettier@3.9.6 + - trufflehog@3.96.0 - yamllint@1.38.0 - bandit@1.9.4 - - trivy@0.70.0 + - trivy@0.72.0 - taplo@0.10.0 - - ruff@0.15.13 + - ruff@0.16.0 - isort@8.0.1 - - markdownlint@0.48.0 + - markdownlint@0.49.1 - oxipng@10.1.1 - - svgo@4.0.1 + - svgo@4.0.2 - actionlint@1.7.12 - flake8@7.3.0 - hadolint@2.14.0 From 047c4e9feb9bc884f24edb1585d56d9318cbe463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 30 Jul 2026 12:44:52 +0200 Subject: [PATCH 22/30] add LR 2021 to portduino, and allow Framebuffer devices to rotate the screen from config (#11252) * add LR 2021 to portduino, and allow Framebuffer devices to rotate the screen from config. Requires https://github.com/meshtastic/device-ui/pull/355 and supersedes https://github.com/meshtastic/firmware/pull/10567 and https://github.com/meshtastic/firmware/pull/11138 Many thanks to the original authors https://github.com/a-li3n and https://github.com/jessm33 * Build LR2021Interface.cpp in the wasm env initLoRa() constructs LR2021Interface for Lora.Module: lr2021, so excluding the file from the native-wasm source filter left the constructor undefined at link time. LR20x0Interface.cpp stays excluded; it is template-only and comes in via the InterfacesTemplates.cpp amalgamation. Also replace the non-UTF-8 degree signs in the framebuffer rotation comment and correct the rfswitch alias cleanup comment. * Trim comments * Report setenv failure for the framebuffer rotation --- src/graphics/tftSetup.cpp | 7 +++++++ src/mesh/InterfacesTemplates.cpp | 2 +- src/mesh/LR11x0Interface.cpp | 4 ++++ src/mesh/LR2021Interface.cpp | 2 +- src/mesh/LR20x0Interface.cpp | 7 ++++++- src/mesh/RadioInterface.cpp | 2 ++ src/platform/portduino/PortduinoGlue.cpp | 4 ++++ src/platform/portduino/PortduinoGlue.h | 16 ++++++++++++---- variants/native/portduino/platformio.ini | 3 ++- 9 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp index 708cd8967..95b721e67 100644 --- a/src/graphics/tftSetup.cpp +++ b/src/graphics/tftSetup.cpp @@ -11,6 +11,8 @@ #ifdef ARCH_PORTDUINO #include "PortduinoGlue.h" +#include +#include #include #endif @@ -60,6 +62,11 @@ void tftSetup(void) } else #elif defined(USE_FRAMEBUFFER) if (portduino_config.displayPanel == fb) { + // Rotation from yaml Display.OffsetRotate: 1=90, 2=180, 3=270 deg + char rbuf[4]; + snprintf(rbuf, sizeof(rbuf), "%d", portduino_config.displayRotate ? (portduino_config.displayOffsetRotate & 3) : 0); + if (setenv("MESHTASTIC_FB_ROTATION", rbuf, 1) != 0) + LOG_ERROR("Failed to set MESHTASTIC_FB_ROTATION, framebuffer will use its default rotation"); if (portduino_config.displayWidth && portduino_config.displayHeight) displayConfig = DisplayDriverConfig(DisplayDriverConfig::device_t::FB, (uint16_t)portduino_config.displayWidth, (uint16_t)portduino_config.displayHeight); diff --git a/src/mesh/InterfacesTemplates.cpp b/src/mesh/InterfacesTemplates.cpp index 6e27b56f3..78d08b05f 100644 --- a/src/mesh/InterfacesTemplates.cpp +++ b/src/mesh/InterfacesTemplates.cpp @@ -27,7 +27,7 @@ template class LR11x0Interface; template class LR11x0Interface; template class LR11x0Interface; #endif -#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1 +#if (defined(USE_LR2021) || defined(ARCH_PORTDUINO)) && RADIOLIB_EXCLUDE_LR2021 != 1 template class LR20x0Interface; #endif #ifdef ARCH_STM32WL diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index bb87ca7ae..c54ae91a6 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -379,4 +379,8 @@ template int16_t LR11x0Interface::getCurrentRSSI() #endif return (int16_t)round(rssi); } + +// Don't leak the aliases into the files InterfacesTemplates.cpp includes after this one. +#undef rfswitch_dio_pins +#undef rfswitch_table #endif diff --git a/src/mesh/LR2021Interface.cpp b/src/mesh/LR2021Interface.cpp index 9aa4d5f1a..a3ae448ca 100644 --- a/src/mesh/LR2021Interface.cpp +++ b/src/mesh/LR2021Interface.cpp @@ -1,6 +1,6 @@ #include "configuration.h" -#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1 +#if (defined(USE_LR2021) || defined(ARCH_PORTDUINO)) && RADIOLIB_EXCLUDE_LR2021 != 1 #include "LR2021Interface.h" #include "error.h" diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index 3e5ec9250..c7d00e3ab 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -1,6 +1,6 @@ #include "configuration.h" -#if defined(USE_LR2021) && RADIOLIB_EXCLUDE_LR2021 != 1 +#if (defined(USE_LR2021) || defined(ARCH_PORTDUINO)) && RADIOLIB_EXCLUDE_LR2021 != 1 #include "LR20x0Interface.h" #include "error.h" #include "mesh/NodeDB.h" @@ -381,4 +381,9 @@ template int16_t LR20x0Interface::getCurrentRSSI() float rssi = lora.getRSSI(false, true); return (int16_t)round(rssi); } + +// Don't leak the aliases into the files InterfacesTemplates.cpp includes after this one. +#undef lr20x0_rfswitch_dio_pins +#undef lr20x0_rfswitch_table +#undef LR20x0 #endif diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 9c199f5a7..208e8603d 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -401,6 +401,8 @@ std::unique_ptr initLoRa() return std::unique_ptr(new LR1121Interface(hal, cs, irq, rst, busy)); case use_llcc68: return std::unique_ptr(new LLCC68Interface(hal, cs, irq, rst, busy)); + case use_lr2021: + return std::unique_ptr(new LR2021Interface(hal, cs, irq, rst, busy)); case use_simradio: return std::unique_ptr(new SimRadio); default: diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 750396769..dd0f663f0 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -899,6 +899,10 @@ bool loadConfig(const char *configPath) portduino_config.lr1110_max_power = yamlConfig["Lora"]["LR1110_MAX_POWER"].as(22); if (yamlConfig["Lora"]["LR1120_MAX_POWER"]) portduino_config.lr1120_max_power = yamlConfig["Lora"]["LR1120_MAX_POWER"].as(13); + if (yamlConfig["Lora"]["LR2021_MAX_POWER"]) + portduino_config.lr2021_max_power = yamlConfig["Lora"]["LR2021_MAX_POWER"].as(22); + if (yamlConfig["Lora"]["LR2021_MAX_POWER_HF"]) + portduino_config.lr2021_max_power_hf = yamlConfig["Lora"]["LR2021_MAX_POWER_HF"].as(12); if (yamlConfig["Lora"]["RF95_MAX_POWER"]) portduino_config.rf95_max_power = yamlConfig["Lora"]["RF95_MAX_POWER"].as(20); diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index b5a47787e..a6797c292 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -47,7 +47,8 @@ enum lora_module_enum { use_lr1110, use_lr1120, use_lr1121, - use_llcc68 + use_llcc68, + use_lr2021 }; struct pinMapping { @@ -76,9 +77,10 @@ std::string exec(const char *cmd); extern struct portduino_config_struct { // Lora - std::map loraModules = { - {use_simradio, "sim"}, {use_autoconf, "auto"}, {use_rf95, "RF95"}, {use_sx1262, "sx1262"}, {use_sx1268, "sx1268"}, - {use_sx1280, "sx1280"}, {use_lr1110, "lr1110"}, {use_lr1120, "lr1120"}, {use_lr1121, "lr1121"}, {use_llcc68, "LLCC68"}}; + std::map loraModules = {{use_simradio, "sim"}, {use_autoconf, "auto"}, {use_rf95, "RF95"}, + {use_sx1262, "sx1262"}, {use_sx1268, "sx1268"}, {use_sx1280, "sx1280"}, + {use_lr1110, "lr1110"}, {use_lr1120, "lr1120"}, {use_lr1121, "lr1121"}, + {use_llcc68, "LLCC68"}, {use_lr2021, "lr2021"}}; std::map screen_names = {{x11, "X11"}, {fb, "FB"}, {st7789, "ST7789"}, {st7735, "ST7735"}, {st7735s, "ST7735S"}, {st7796, "ST7796"}, @@ -100,6 +102,8 @@ extern struct portduino_config_struct { int sx128x_max_power = 13; int lr1110_max_power = 22; int lr1120_max_power = 13; + int lr2021_max_power = 22; + int lr2021_max_power_hf = 12; int rf95_max_power = 20; bool dio2_as_rf_switch = false; int dio3_tcxo_voltage = 0; @@ -287,6 +291,10 @@ extern struct portduino_config_struct { out << YAML::Key << "LR1110_MAX_POWER" << YAML::Value << lr1110_max_power; if (lr1120_max_power != 13) out << YAML::Key << "LR1120_MAX_POWER" << YAML::Value << lr1120_max_power; + if (lr2021_max_power != 22) + out << YAML::Key << "LR2021_MAX_POWER" << YAML::Value << lr2021_max_power; + if (lr2021_max_power_hf != 12) + out << YAML::Key << "LR2021_MAX_POWER_HF" << YAML::Value << lr2021_max_power_hf; if (rf95_max_power != 20) out << YAML::Key << "RF95_MAX_POWER" << YAML::Value << rf95_max_power; diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 006a4d0b2..76d3fec82 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -431,7 +431,8 @@ build_src_filter = + + + + + - - - + ; template-only, amalgamated into InterfacesTemplates.cpp + - + + + + From 9a372504386d49de3ed01311025bb52d04d26ef5 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:00:32 -0700 Subject: [PATCH 23/30] fix(router): clear failed reliable send retries (#11267) * fix(router): clear failed reliable send retries * test(router): cover failed interface enqueue * fix(router): retain retries after duty cycle limits --- src/mesh/ReliableRouter.cpp | 12 ++++- test/test_packet_signing/test_main.cpp | 73 +++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 7ec6bb4b7..fce6b8a32 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -16,6 +16,9 @@ */ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p) { + const GlobalPacketId key(p); + const bool retransmitting = p->want_ack; + if (p->want_ack) { DEBUG_HEAP_BEFORE; auto copy = packetPool.allocCopy(*p); @@ -34,7 +37,12 @@ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p) } } - return isBroadcast(p->to) ? FloodingRouter::send(p) : NextHopRouter::send(p); + ErrorCode result = isBroadcast(p->to) ? FloodingRouter::send(p) : NextHopRouter::send(p); + // Duty-cycle rejections may clear before the scheduled retry. + if (retransmitting && result != ERRNO_OK && result != meshtastic_Routing_Error_DUTY_CYCLE_LIMIT) + stopRetransmission(key); + + return result; } bool ReliableRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) @@ -196,4 +204,4 @@ bool ReliableRouter::shouldSuccessAckWithWantAck(const meshtastic_MeshPacket *p) } return false; -} \ No newline at end of file +} diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index f58c585b3..c3abb7bc9 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -121,7 +121,7 @@ class AuthPipelineRadio : public RadioInterface { sendCalls++; packetPool.release(p); - return ERRNO_OK; + return failSend ? ERRNO_DISABLED : ERRNO_OK; } bool cancelSending(NodeNum, PacketId) override { @@ -139,8 +139,13 @@ class AuthPipelineRadio : public RadioInterface return true; } uint32_t getPacketTime(uint32_t, bool = false) override { return 7; } - void reset() { sendCalls = cancelCalls = findCalls = removeCalls = 0; } + void reset() + { + sendCalls = cancelCalls = findCalls = removeCalls = 0; + failSend = false; + } + bool failSend = false; uint32_t sendCalls = 0; uint32_t cancelCalls = 0; uint32_t findCalls = 0; @@ -406,6 +411,8 @@ void setUp(void) pipelineMqtt->clearQueue(); while (meshtastic_MeshPacket *queued = pipelineService->getForPhone()) packetPool.release(queued); + while (meshtastic_QueueStatus *queued = pipelineService->getQueueStatusForPhone()) + pipelineService->releaseQueueStatusToPool(queued); resetRoutingAuthEvaluationCount(); } @@ -1441,6 +1448,66 @@ void test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated"); } +// A local reliable send that fails before reaching the radio must not outlive the error as a scheduled retransmission. +void test_C13_failed_initial_reliable_send_does_not_retry(void) +{ + meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + initial.id = 0xC13C13C1; + initial.want_ack = true; + initial.channel = MAX_NUM_CHANNELS; // Out of range, so encoding returns NO_CHANNEL. + + auto *packet = packetPool.allocCopy(initial); + TEST_ASSERT_NOT_NULL(packet); + + pipelineRouter->send(packet); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls, + "initial encoding failure must be reported to the originating client"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, pipelineRouter->pendingCount(), + "failed initial send must not leave a retransmission pending"); + + pipelineRadio->failSend = true; + meshtastic_MeshPacket interfaceFailure = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + interfaceFailure.id = 0xC13C13C2; + interfaceFailure.want_ack = true; + packet = packetPool.allocCopy(interfaceFailure); + TEST_ASSERT_NOT_NULL(packet); + + pipelineService->sendToMesh(packet, RX_SRC_USER); + meshtastic_QueueStatus *status = pipelineService->getQueueStatusForPhone(); + TEST_ASSERT_NOT_NULL(status); + TEST_ASSERT_EQUAL(ERRNO_DISABLED, status->res); + TEST_ASSERT_EQUAL_UINT32(interfaceFailure.id, status->mesh_packet_id); + pipelineService->releaseQueueStatusToPool(status); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls, + "interface errors are reported to the client through QueueStatus, not a routing NAK"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, pipelineRouter->pendingCount(), + "failed interface enqueue must not leave a retransmission pending"); +} + +void test_C14_duty_cycle_limited_reliable_send_remains_pending(void) +{ + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.override_duty_cycle = false; + initRegion(); + airTime->utilizationTX[0] = MS_IN_HOUR; + + meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + initial.id = 0xC14C14C1; + initial.want_ack = true; + auto *packet = packetPool.allocCopy(initial); + TEST_ASSERT_NOT_NULL(packet); + + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_DUTY_CYCLE_LIMIT, pipelineRouter->send(packet)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls, + "duty-cycle rejection must still notify the originating client"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouter->pendingCount(), + "duty-cycle rejection must retain the retry for when airtime is available"); + + airTime->utilizationTX[0] = 0; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); +} + // C5: the packet survives (C4) but the identity claim inside it must not land - the pubkey guard // can't tell a signer from an impersonator replaying its (public) key. Only the write is refused. void test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name(void) @@ -1907,6 +1974,8 @@ void setup() RUN_TEST(test_C10_legacy_channel_dm_failure_has_no_pipeline_effects); RUN_TEST(test_C11_malformed_pki_plaintext_has_no_pipeline_effects); RUN_TEST(test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass); + RUN_TEST(test_C13_failed_initial_reliable_send_does_not_retry); + RUN_TEST(test_C14_duty_cycle_limited_reliable_send_remains_pending); printf("\n=== Group N: NodeInfoModule authentication ===\n"); RUN_TEST(test_N1_unsigned_nodeinfo_from_signer_dropped); RUN_TEST(test_N2_signed_nodeinfo_from_signer_not_dropped); From 21e3a583bd88241bd1d0baea7c34a17e8cb8039f Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:06:24 +0100 Subject: [PATCH 24/30] Yaml check for Meshtasticd (#11224) * feat(portduino): add `meshtasticd --check` config validator Users hand-writing files in /etc/meshtasticd/config.d/ get no feedback when a key is misplaced, misspelled or duplicated: meshtasticd silently ignores what it does not read, so a broken config looks identical to a working one. Add a --check mode that loads the configuration exactly as startup does, then reports what it found and exits: - Duplicate keys, via the yaml-cpp Parser/EventHandler stream. The Node API cannot see them because the map is already collapsed by the time it exists, and yaml-cpp keeps the FIRST occurrence, so a later override is discarded. - Unknown or misnested keys, against a schema mirroring what loadConfig() reads, with a hint naming the section a stray key actually belongs to. - rfswitch_table validation: unrecognised pins, mode rows whose length does not match the pin list, values that are not HIGH/LOW, and unknown modes. - Cross-file overlap: every .yaml in the config directory merges into one portduino_config, so the file loaded LAST wins, the opposite of the within-file rule. Those files are read in filesystem order, not alphabetical. - A warning when more than one file defines a Lora section: spidev, spiSpeed, gpiochip, DIO2_AS_RF_SWITCH, DIO3_TCXO_VOLTAGE and USB_PID/VID/Serialnum are assigned unconditionally with a default every time one is seen, so any of them not repeated in the last file loaded is silently reset. - The resolved gpiochip/line for each pin, since a line that exists on the wrong chip is claimed successfully and then silently does nothing. Exits non-zero when errors were found so it can also gate CI over bin/config.d/**, keeping one implementation rather than a second schema. Co-Authored-By: Claude Opus 5 (1M context) * fix(portduino): flag pins that resolve to -1 in --check A pin key whose value will not convert to a number falls back to RADIOLIB_NC (-1) while still being marked enabled, and initGPIOPin() then trips an assertion inside LinuxGPIOPin rather than failing cleanly. YAML indentation makes this easy to hit by accident: a stray line under "CS: 8" folds into the value as a multi-line scalar, so the file parses, the daemon crashes with a stack trace from a library file, and --check reported "Configuration looks good" while printing "pin -1" two lines above. Report it as an error naming the likely cause instead. Also correct a comment claiming unparseable config.d files are skipped silently. They are not: loadConfig() prints "*** Exception ..." with the line and column. It is the discarded return value, not the diagnostic, that makes the file's absence from the merged config easy to miss. Co-Authored-By: Claude Opus 5 (1M context) * test(portduino): cover `meshtasticd --check` with fixtures and a fuzz suite Adds the tests the config validator was missing, and the checks and fixes that writing them turned up. The theme throughout is configuration that the YAML parser accepts but that does not mean what it looks like it means. Tests ----- bin/test-config-check.sh - 57 assertions driving a built meshtasticd against test/fixtures/portduino-config (50 fixtures plus two config.d trees). A shell test rather than a Unity suite because both behaviours under test are properties of the process: --check is judged by its exit status and printed report, and the "a normal run rejects a bad config" path ends in exit() inside portduinoSetup(), neither of which is reachable from a suite that links one translation unit. Every fixture carries a comment header naming its planted fault and the expected finding, so it can be read on its own. Coverage: * a clean config for each of the ten radio module families (RF95, sx1262, sx1268, LLCC68, sx1280, lr1110, lr1120, lr1121, sim, auto), asserted both findings-free and resolving to that module, so a silent fallback to sim cannot pass * LR11xx rfswitch tables: unrecognised pins, rows longer and shorter than the pin count, levels that are not exactly HIGH, a missing pins list, more than five pins, a scalar table, unknown MODE_ keys, a MODE_ row stranded one level out, and a legal partial table * the PA gain table in both accepted shapes, entries outside the uint16 range it is stored in, and more than the 22 points that are kept * values of the wrong type, split by consequence: the two settings read with no fallback stop meshtasticd starting, everything else is silently replaced by its default * out-of-range and unit mistakes: TCXO voltage written in millivolts, ports outside their usable range, an over-long StatusMessage * MAC sources: both keys set at once, a malformed address, an interface that does not exist * structural faults: duplicate keys, non-mapping and unknown sections, a key left at the top level, a sequence at the document root, an empty file, unreadable pins, unparseable YAML * cross-file behaviour over a config.d directory, including the switch tables that do not override each other * five configs run WITHOUT --check, each of which must still be refused, so check mode cannot quietly make the normal path permissive test/test_fuzz_config - adversarial fuzzing of the checker itself, the "the tool meant to diagnose your config crashes on it" failure mode. Scope is deliberately narrow: yaml-cpp does the parsing and is fuzzed upstream, so what is exercised here is our code above the parse, above all the duplicate-key detector, which is the one hand-rolled piece and walks the raw parser event stream with its own stack. Groups: the checked-in fixtures as a seed corpus, 3000 byte mutations of them (flips, truncation, insertion, splicing, deletion), and structural torture (nesting to 4096 in flow and block style, duplicate keys at depth, anchors, aliases and merge keys, 64KB keys, 256KB scalars, multi-document files). A fourth group of random bytes is present but disabled behind FUZZ_CONFIG_RANDOM_BYTES: it was half the runtime for the least return, since uniform noise is rejected on the first token. The contract is crash-freedom and termination under AddressSanitizer, not any particular finding. CI runs the shell test in the existing native simulator job; the fuzz suite is picked up by the existing ^test_fuzz_ area rule. native-suite-count 40 -> 41. The fixtures are exempt from trunk in .trunk/trunk.yaml, since prettier rejects the duplicate keys and bad indentation that are the point of them. Checker fixes found while writing the tests ------------------------------------------- --check reported a clean exit 0 on configs meshtasticd then refuses to boot, the worst failure a diagnostic tool can have. Four hard exits inside loadConfig() killed the report before it printed: an unparseable file, an unknown Lora.Module, MACAddress and MACAddressSource both set, and HUB75 on a build without it. All are now reported as findings, and all are still refused on a normal run. New validation: Lora.Module against the accepted spellings, which are matched exactly and inconsistently cased, with a suggestion when only case differs; a per-key value type table covering ~85 keys, tested by asking yaml-cpp to perform the same conversion loadConfig() will so it cannot drift; the PA gain table; DIO3_TCXO_VOLTAGE, which is in volts and multiplied by 1000, so the millivolt value everything else uses silently asks for 1800V; APIPort and Webserver.Port ranges; MaxNodes; StatusMessage truncation; MAC address and source; and an unreadable ConfigDirectory. Also fixes a crash: a ConfigDirectory that cannot be read threw an uncaught filesystem_error from directory_iterator and aborted meshtasticd with SIGABRT, taking --check down with it. It now fails cleanly. Two smaller ones: cppcheck's uselessCallsSubstr on the ancestor walk, which was failing every check job; and the duplicate-key detector's stack pop, which was unguarded and relied on yaml-cpp emitting balanced events. Switch tables are the one place "the file loaded last wins" is false. The loader only ever writes HIGH and never writes LOW back, so a HIGH from an earlier file survives a later file that clears it and the radio drives the OR of every table loaded. Confirmed with --output-yaml. Reported as an error for now; the loader itself is left alone, as that changes RF behaviour. * fix(portduino): report CH341 pins as adapter indexes, not gpiochip lines --check printed "Resolved GPIO lines (what meshtasticd will try to claim)" for every config, listing a gpiochip and line for each Lora pin and advising they be confirmed against gpiodetect and gpioinfo. For spidev: ch341 every part of that is false. portduinoSetup() skips initGPIOPin() for every Lora pin when spidev is ch341 and hands the raw numbers to Ch341Hal, so nothing is claimed from a gpiochip -- and on Windows and macOS, where a USB adapter is the only way to attach a radio, there is no gpiochip, gpiodetect or gpioinfo to check against in the first place. The checker had no ch341 coverage at all: not one fixture used it, so the whole USB-SPI path went unexercised. The summary now splits on the transport. A ch341 device gets its pins listed as adapter indexes with the gpiod advice dropped, and a gpiochip or line mapping written alongside it is reported: those are read, stored, and never used. Also: "RF switch table: not set" read as a gap on an SX126x, where there is nothing to set. setRfSwitchTable() is only ever called for an LR11xx, so absence is now "not needed for this module" everywhere else, and "not resolved yet" for auto, which has no module to judge against. Fixtures: usb-ch341.yaml (clean, the meshstick shape) and ch341-gpiochip.yaml. CI fix ------ test-native was RED on "config.d overrides are reported", which wanted 2 warnings and got 1. The fixture's two config.d files name different modules, so which one wins -- and whether the LR11xx-without-a-switch-table warning fires -- depends on the order the filesystem returns them in. That is the very thing the fixture exists to demonstrate, so the count is no longer asserted; the report's own order caveat is asserted instead. Review fixes ------------ The unreadable-ConfigDirectory diagnostic was the one new print in PortduinoGlue.cpp not gated behind !configCheck, so it landed ahead of the report header and broke the clean output the rest of the change is careful to keep. Docs: rfswitch-valid.yaml carries seven modes, not eight, and empty-file.yaml is comments-only rather than zero bytes. * style(portduino): trim --check comment blocks and reconcile suite count Condense the multi-paragraph comment blocks in the --check validator to the one-to-two-line convention, and bump test/native-suite-count to 42 for the test_fuzz_config suite added here. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/test_native.yml | 8 + .trunk/trunk.yaml | 8 + bin/test-config-check.sh | 391 ++++++ src/platform/portduino/ConfigCheck.cpp | 1094 +++++++++++++++++ src/platform/portduino/ConfigCheck.h | 12 + src/platform/portduino/PortduinoGlue.cpp | 93 +- test/README.md | 14 + test/fixtures/portduino-config/README.md | 158 +++ .../portduino-config/ch341-gpiochip.yaml | 16 + .../configd-conflict/config.d/lora-a.yaml | 6 + .../configd-conflict/config.d/lora-b.yaml | 8 + .../configd-conflict/config.yaml | 12 + .../portduino-config/configdir-missing.yaml | 8 + .../portduino-config/duplicate-key.yaml | 8 + .../fixtures/portduino-config/empty-file.yaml | 2 + .../portduino-config/empty-sections.yaml | 5 + .../portduino-config/hub75-unknown-key.yaml | 11 + .../portduino-config/mac-conflict.yaml | 9 + .../portduino-config/mac-malformed.yaml | 9 + .../portduino-config/mac-source-missing.yaml | 7 + .../portduino-config/malformed-indent.yaml | 8 + .../portduino-config/module-auto.yaml | 3 + .../portduino-config/module-llcc68.yaml | 9 + .../portduino-config/module-lr1110.yaml | 18 + .../portduino-config/module-lr1120.yaml | 18 + .../portduino-config/module-lr1121.yaml | 18 + .../module-mismatch-lr11xx.yaml | 7 + .../module-mismatch-sx126x.yaml | 13 + .../portduino-config/module-rf95.yaml | 10 + .../fixtures/portduino-config/module-sim.yaml | 3 + .../portduino-config/module-sx1262.yaml | 12 + .../portduino-config/module-sx1268.yaml | 9 + .../portduino-config/module-sx1280.yaml | 10 + .../portduino-config/module-unknown.yaml | 4 + .../portduino-config/module-wrong-case.yaml | 4 + .../portduino-config/nonmap-section.yaml | 4 + .../portduino-config/pin-unknown-subkey.yaml | 8 + .../portduino-config/pin-unreadable.yaml | 7 + .../portduino-config/port-out-of-range.yaml | 9 + .../portduino-config/rfswitch-bad-level.yaml | 9 + .../portduino-config/rfswitch-bad-pin.yaml | 6 + .../portduino-config/rfswitch-no-pins.yaml | 6 + .../portduino-config/rfswitch-not-a-map.yaml | 4 + .../portduino-config/rfswitch-partial.yaml | 12 + .../portduino-config/rfswitch-row-length.yaml | 8 + .../rfswitch-sticky/config.d/a-first.yaml | 6 + .../rfswitch-sticky/config.d/b-second.yaml | 9 + .../rfswitch-sticky/config.yaml | 4 + .../rfswitch-stranded-modes.yaml | 9 + .../rfswitch-too-many-pins.yaml | 7 + .../rfswitch-unknown-mode.yaml | 8 + .../portduino-config/rfswitch-valid.yaml | 17 + .../portduino-config/statusmessage-long.yaml | 7 + .../portduino-config/stranded-key.yaml | 6 + .../portduino-config/tcxo-millivolts.yaml | 9 + .../portduino-config/top-level-list.yaml | 3 + .../portduino-config/txgain-out-of-range.yaml | 5 + .../portduino-config/txgain-scalar.yaml | 8 + .../portduino-config/txgain-too-many.yaml | 5 + .../portduino-config/unknown-key.yaml | 9 + .../portduino-config/unknown-section.yaml | 6 + test/fixtures/portduino-config/usb-ch341.yaml | 15 + test/fixtures/portduino-config/valid.yaml | 8 + .../value-type-fatal-list.yaml | 5 + .../portduino-config/value-type-fatal.yaml | 8 + .../portduino-config/value-type-silent.yaml | 10 + test/native-suite-count | 2 +- test/test_fuzz_config/test_main.cpp | 267 ++++ 68 files changed, 2507 insertions(+), 24 deletions(-) create mode 100755 bin/test-config-check.sh create mode 100644 src/platform/portduino/ConfigCheck.cpp create mode 100644 src/platform/portduino/ConfigCheck.h create mode 100644 test/fixtures/portduino-config/README.md create mode 100644 test/fixtures/portduino-config/ch341-gpiochip.yaml create mode 100644 test/fixtures/portduino-config/configd-conflict/config.d/lora-a.yaml create mode 100644 test/fixtures/portduino-config/configd-conflict/config.d/lora-b.yaml create mode 100644 test/fixtures/portduino-config/configd-conflict/config.yaml create mode 100644 test/fixtures/portduino-config/configdir-missing.yaml create mode 100644 test/fixtures/portduino-config/duplicate-key.yaml create mode 100644 test/fixtures/portduino-config/empty-file.yaml create mode 100644 test/fixtures/portduino-config/empty-sections.yaml create mode 100644 test/fixtures/portduino-config/hub75-unknown-key.yaml create mode 100644 test/fixtures/portduino-config/mac-conflict.yaml create mode 100644 test/fixtures/portduino-config/mac-malformed.yaml create mode 100644 test/fixtures/portduino-config/mac-source-missing.yaml create mode 100644 test/fixtures/portduino-config/malformed-indent.yaml create mode 100644 test/fixtures/portduino-config/module-auto.yaml create mode 100644 test/fixtures/portduino-config/module-llcc68.yaml create mode 100644 test/fixtures/portduino-config/module-lr1110.yaml create mode 100644 test/fixtures/portduino-config/module-lr1120.yaml create mode 100644 test/fixtures/portduino-config/module-lr1121.yaml create mode 100644 test/fixtures/portduino-config/module-mismatch-lr11xx.yaml create mode 100644 test/fixtures/portduino-config/module-mismatch-sx126x.yaml create mode 100644 test/fixtures/portduino-config/module-rf95.yaml create mode 100644 test/fixtures/portduino-config/module-sim.yaml create mode 100644 test/fixtures/portduino-config/module-sx1262.yaml create mode 100644 test/fixtures/portduino-config/module-sx1268.yaml create mode 100644 test/fixtures/portduino-config/module-sx1280.yaml create mode 100644 test/fixtures/portduino-config/module-unknown.yaml create mode 100644 test/fixtures/portduino-config/module-wrong-case.yaml create mode 100644 test/fixtures/portduino-config/nonmap-section.yaml create mode 100644 test/fixtures/portduino-config/pin-unknown-subkey.yaml create mode 100644 test/fixtures/portduino-config/pin-unreadable.yaml create mode 100644 test/fixtures/portduino-config/port-out-of-range.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-bad-level.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-bad-pin.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-no-pins.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-not-a-map.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-partial.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-row-length.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-sticky/config.d/a-first.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-sticky/config.d/b-second.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-sticky/config.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-stranded-modes.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-too-many-pins.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-unknown-mode.yaml create mode 100644 test/fixtures/portduino-config/rfswitch-valid.yaml create mode 100644 test/fixtures/portduino-config/statusmessage-long.yaml create mode 100644 test/fixtures/portduino-config/stranded-key.yaml create mode 100644 test/fixtures/portduino-config/tcxo-millivolts.yaml create mode 100644 test/fixtures/portduino-config/top-level-list.yaml create mode 100644 test/fixtures/portduino-config/txgain-out-of-range.yaml create mode 100644 test/fixtures/portduino-config/txgain-scalar.yaml create mode 100644 test/fixtures/portduino-config/txgain-too-many.yaml create mode 100644 test/fixtures/portduino-config/unknown-key.yaml create mode 100644 test/fixtures/portduino-config/unknown-section.yaml create mode 100644 test/fixtures/portduino-config/usb-ch341.yaml create mode 100644 test/fixtures/portduino-config/valid.yaml create mode 100644 test/fixtures/portduino-config/value-type-fatal-list.yaml create mode 100644 test/fixtures/portduino-config/value-type-fatal.yaml create mode 100644 test/fixtures/portduino-config/value-type-silent.yaml create mode 100644 test/test_fuzz_config/test_main.cpp diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index f41a91199..c5186c9f1 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -81,6 +81,14 @@ jobs: lcov ${{ env.LCOV_CAPTURE_FLAGS }} --initial --output-file coverage_base.info sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative. + - name: Config check tests + # Drives the same binary against test/fixtures/portduino-config: asserts that + # `--check` reports each planted fault, and that a normal run still refuses the + # configs it should. Runs before the simulator test because it is seconds long + # and a failure here explains a lot of downstream weirdness. + timeout-minutes: 5 + run: ./bin/test-config-check.sh .pio/build/coverage/meshtasticd + - name: Integration test # Cap the whole step: if the simulator ever fails to exit (e.g. the # exit_simulator admin path regresses again) the job must fail fast, diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index ad8a3eed9..741a51e58 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -102,6 +102,14 @@ lint: - linters: [gitleaks] paths: - test/fixtures/nodedb/seed_v25_*.jsonl + # Deliberately broken YAML: these are the inputs to bin/test-config-check.sh, + # which asserts that `meshtasticd --check` reports each planted fault. Prettier + # rejects the duplicate keys and bad indentation that are the whole point of + # them, so the directory is exempt. The README there is normal prose and stays + # linted. + - linters: [ALL] + paths: + - test/fixtures/portduino-config/**/*.yaml # checkov/CKV_SECRET_6 flags the commented-out "large4cats" example, # which is the public default credential for the meshtastic.org MQTT # broker rather than a real secret. Ignore the whole file so the diff --git a/bin/test-config-check.sh b/bin/test-config-check.sh new file mode 100755 index 000000000..b3027fe7b --- /dev/null +++ b/bin/test-config-check.sh @@ -0,0 +1,391 @@ +#!/usr/bin/env bash +# Drive a built meshtasticd against the fixtures in test/fixtures/portduino-config +# and assert what it does with each one. +# +# Why this is a shell test and not a Unity suite: both behaviours under test are +# properties of the process, not of a function. `--check` is judged by its exit +# status and its printed report, and the "normal run rejects a bad config" path +# ends in exit(EXIT_FAILURE) inside portduinoSetup() - neither is reachable from +# a native unit test that links a single translation unit. +# +# Usage: +# ./bin/test-config-check.sh # auto-detect the binary +# ./bin/test-config-check.sh path/to/meshtasticd # explicit binary +# MESHTASTICD_BIN=... ./bin/test-config-check.sh +# +# Exit codes: 0 = GREEN, 1 = RED. The final line is machine-readable: +# RESULT: GREEN 42/42 assertions passed +# RESULT: RED 2 of 42 assertions failed + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +FIXTURES="$ROOT_DIR/test/fixtures/portduino-config" + +BIN="${1:-${MESHTASTICD_BIN-}}" +if [[ -z $BIN ]]; then + for candidate in "$ROOT_DIR/.pio/build/native/meshtasticd" "$ROOT_DIR/.pio/build/coverage/meshtasticd"; do + [[ -x $candidate ]] && BIN="$candidate" && break + done +fi +if [[ -z $BIN || ! -x $BIN ]]; then + echo "RESULT: RED no meshtasticd binary (build one with 'pio run -e native', or pass a path)" + exit 1 +fi +# Each case runs from a directory of its own choosing, so a relative path (CI passes +# .pio/build/coverage/meshtasticd) has to be resolved before the first cd. +BIN="$(cd "$(dirname "$BIN")" && pwd)/$(basename "$BIN")" + +# Note: `pio test -e native` writes its Unity test program to this same path, so after a test +# run the auto-detected binary is that program rather than the application. That turns out to be +# harmless here -- portduino's main() parses argv and runs portduinoSetup(), which prints the +# report and exit()s before setup() (Unity's entry point) is ever reached -- so every case below +# behaves identically either way. It stops being true only for a case that lets meshtasticd run +# past portduinoSetup(), which this suite deliberately never does: a config it accepts boots a +# node and blocks. + +# The coverage env links ASan/LSan. Every case here ends in exit(), so LSan would +# report the still-reachable config objects and turn a passing case into a non-zero +# exit that has nothing to do with what is being asserted. +export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=0}" + +# Keep the virtual filesystem out of the user's home, and run from a directory with +# no config.yaml in it so the fixture we name is the only config in play. +WORKDIR="$(mktemp -d -t meshcheck.XXXXXX)" +trap 'rm -rf "$WORKDIR"' EXIT + +PASS=0 +FAIL=0 + +# assert [expected-substring...] +# mode: "check" adds --check, "check-yaml" adds --check --output-yaml, +# "normal" runs with neither. +# fixture: a bare name runs from a scratch directory. A name containing a slash +# (configd-conflict/config.yaml) runs from that fixture's own directory, +# so a relative ConfigDirectory in it resolves the way it would in situ. +assert() { + local desc="$1" want_rc="$2" fixture="$3" mode="$4" + shift 4 + + local cwd="$WORKDIR" config="$FIXTURES/$fixture" + if [[ $fixture == */* ]]; then + cwd="$FIXTURES/$(dirname "$fixture")" + config="$(basename "$fixture")" + fi + + local args=(--config "$config" -d "$WORKDIR/fs") + case $mode in + check) args+=(--check) ;; + check-yaml) args+=(--check --output-yaml) ;; + esac + + local out rc + # A regression that hangs must fail the test, not stall CI. Every case here is + # expected to exit in well under a second. + out="$(cd "$cwd" && timeout 60 "$BIN" "${args[@]}" 2>&1)" + rc=$? + + local problems=() + [[ $rc -ne $want_rc ]] && problems+=("exit $rc, wanted $want_rc") + local needle + for needle in "$@"; do + grep -qF -- "$needle" <<<"$out" || problems+=("missing: $needle") + done + + if [[ ${#problems[@]} -eq 0 ]]; then + PASS=$((PASS + 1)) + echo " ok $desc" + else + FAIL=$((FAIL + 1)) + echo " FAIL $desc" + for problem in "${problems[@]}"; do + echo " $problem" + done + sed 's/^/ | /' <<<"$out" + fi +} + +# A config that meshtasticd fully accepts is asserted clean AND asserted to resolve +# to the module we asked for - otherwise a silent fallback to sim would still pass. +assert_clean_module() { + local fixture="$1" module="$2" + assert "$module" 0 "$fixture" check \ + "Result: 0 errors, 0 warnings" \ + "Module : $module" +} + +echo "meshtasticd config-check tests" +echo " binary: $BIN" +echo + +echo "a valid config for every radio module family is clean:" +assert_clean_module module-rf95.yaml RF95 +assert_clean_module module-sx1262.yaml sx1262 +assert_clean_module module-sx1268.yaml sx1268 +assert_clean_module module-llcc68.yaml LLCC68 +assert_clean_module module-sx1280.yaml sx1280 +assert_clean_module module-lr1110.yaml lr1110 +assert_clean_module module-lr1120.yaml lr1120 +assert_clean_module module-lr1121.yaml lr1121 +assert_clean_module module-sim.yaml sim +assert_clean_module module-auto.yaml auto + +echo +echo "other configs that must not be flagged:" +assert "minimal valid config" 0 valid.yaml check \ + "Result: 0 errors, 0 warnings" +assert "empty sections are not a fault" 0 empty-sections.yaml check \ + "Result: 0 errors, 0 warnings" +assert "warnings alone do not fail the run" 0 unknown-key.yaml check \ + "unknown key 'Lora.Frequency'" \ + "Result: 0 errors, 1 warning" + +echo +echo "module names are matched exactly:" +assert "unknown module names the valid set" 1 module-unknown.yaml check \ + "Lora.Module 'sx1263' is not a module meshtasticd knows" \ + "Result: 1 error, 0 warnings" +assert "wrong-case module suggests the right spelling" 1 module-wrong-case.yaml check \ + "did you mean 'LLCC68'?" \ + "Result: 1 error, 0 warnings" + +echo +echo "LR11xx rfswitch table:" +assert "unrecognised switch pin" 1 rfswitch-bad-pin.yaml check \ + "'DIO9' is not a recognised pin" \ + "Result: 1 error, 0 warnings" +assert "row length must match the pin count" 1 rfswitch-row-length.yaml check \ + "MODE_STBY has 2 values but 3 pins are declared" \ + "MODE_RX has 4 values but 3 pins are declared" \ + "Result: 2 errors, 0 warnings" +# Anything not exactly "HIGH" is silently treated as LOW, so lowercase "high" is a +# switch that never closes - the failure this check exists to catch. +assert "levels must be exactly HIGH or LOW" 1 rfswitch-bad-level.yaml check \ + "'high' is not HIGH or LOW" \ + "'On' is not HIGH or LOW" \ + "Result: 2 errors, 0 warnings" +assert "table with no pins list" 1 rfswitch-no-pins.yaml check \ + "has no 'pins' list, so no switch pins are driven" \ + "Result: 1 error, 0 warnings" +assert "only the first 5 pins are read" 1 rfswitch-too-many-pins.yaml check \ + "lists 6 pins but only the first 5 are read" \ + "Result: 1 error, 0 warnings" +assert "table must be a mapping" 1 rfswitch-not-a-map.yaml check \ + "Lora.rfswitch_table must be a mapping" \ + "Result: 1 error, 0 warnings" +assert "unknown MODE_ key" 1 rfswitch-unknown-mode.yaml check \ + "unknown key 'Lora.rfswitch_table.MODE_TRANSMIT'" \ + "Result: 1 error, 0 warnings" +# One level of over-indentation strands MODE_ rows under Lora: instead of under the +# table, where they do nothing. The hint is what makes that findable. +assert "MODE_ row stranded outside the table" 0 rfswitch-stranded-modes.yaml check \ + "unknown key 'Lora.MODE_RX'" \ + "It is a valid key of Lora.rfswitch_table" \ + "Result: 0 errors, 1 warning" +# A partial table is legal, but the modes left out are driven all-LOW, which for most +# modules is the shutdown state - so the report says which ones rather than leaving it +# to be discovered on air. +assert "omitted modes are called out" 0 rfswitch-partial.yaml check \ + "omits MODE_GNSS, MODE_TX_HF, MODE_TX_HP, MODE_WIFI" \ + "default to all pins LOW" \ + "Result: 0 errors, 0 warnings" + +echo +echo "radio module and switch table must agree:" +assert "LR11xx without a table cannot transmit" 0 module-mismatch-lr11xx.yaml check \ + "Module is lr1121 but no Lora.rfswitch_table is set" \ + "Result: 0 errors, 1 warning" +assert "table on a non-LR11xx radio is ignored" 0 module-mismatch-sx126x.yaml check \ + "the table is only applied to LR11xx radios" \ + "Result: 0 errors, 1 warning" + +echo +echo "PA gain table (TX_GAIN_LORA):" +# Both shapes are legal and they fail differently. The scalar case is a regression +# guard: an earlier version of the checker called this working config a fatal error. +assert "a bare scalar is legal" 0 txgain-scalar.yaml check \ + "Result: 0 errors, 0 warnings" +assert "a non-numeric list entry stops meshtasticd" 1 value-type-fatal-list.yaml check \ + "TX_GAIN_LORA entry 'high' is not a whole number" \ + "refuses to start" \ + "Result: 1 error, 0 warnings" +assert "entries outside the uint16 range wrap" 1 txgain-out-of-range.yaml check \ + "entry -5 does not fit the 0-65535 range" \ + "entry 70000 does not fit the 0-65535 range" \ + "Result: 2 errors, 0 warnings" +assert "points past the 22nd are dropped" 0 txgain-too-many.yaml check \ + "lists 25 points but only the first 22 are stored" \ + "Result: 0 errors, 1 warning" + +echo +echo "values of the wrong type:" +# The two settings read without a fallback: a bad value throws inside loadConfig() and +# meshtasticd will not start. Before this check, --check called these files clean. +assert "no-fallback read is fatal" 1 value-type-fatal.yaml check \ + "Logging.AsciiLogs is not a true/false value" \ + "refuses to start" \ + "Result: 1 error, 0 warnings" +assert "defaulted reads are silently ignored" 0 value-type-silent.yaml check \ + "Lora.spiSpeed is not a whole number" \ + "Lora.DIO2_AS_RF_SWITCH is not a true/false value" \ + "General.MaxNodes is not a whole number" \ + "silently replaced by the default" \ + "Result: 0 errors, 4 warnings" + +echo +echo "out-of-range and unit mistakes:" +# The volts/millivolts trap: every other Meshtastic surface says millivolts, so 1800 is +# the natural thing to write and it silently asks for 1800V. +assert "TCXO voltage written in millivolts" 1 tcxo-millivolts.yaml check \ + "resolves to 1800000 mV" \ + "The value is in VOLTS" \ + "Result: 1 error, 0 warnings" +assert "ports outside their usable range" 1 port-out-of-range.yaml check \ + "General.APIPort 80 is outside 1024-65535" \ + "Webserver.Port 99999 is not a usable TCP port" \ + "Result: 1 error, 1 warning" +assert "StatusMessage longer than its buffer" 0 statusmessage-long.yaml check \ + "is truncated to 79 when it is stored" \ + "Result: 0 errors, 1 warning" +# Regression guard for a crash: this used to abort meshtasticd (and --check with it) +# via an uncaught filesystem_error from directory_iterator. +assert "unreadable ConfigDirectory is reported, not fatal" 1 configdir-missing.yaml check \ + "is not a directory that can be read" \ + "Result: 1 error, 0 warnings" + +echo +echo "MAC address sources:" +assert "both MACAddress and MACAddressSource" 1 mac-conflict.yaml check \ + "General.MACAddress and General.MACAddressSource are both set" \ + "Result: 1 error, 0 warnings" +assert "MACAddress that is not 12 hex digits" 1 mac-malformed.yaml check \ + "is not 12 hex digits" \ + "Result: 1 error, 0 warnings" +assert "MACAddressSource naming no interface" 0 mac-source-missing.yaml check \ + "has no /sys/class/net/nosuchiface99/address" \ + "Result: 0 errors, 1 warning" + +echo +echo "pin mappings:" +assert "unknown pin sub-key" 1 pin-unknown-subkey.yaml check \ + "unknown key 'Lora.CS.chipline'" \ + "A pin mapping accepts only pin, gpiochip and line" \ + "Result: 1 error, 0 warnings" +assert "pin value that resolves to -1" 1 pin-unreadable.yaml check \ + "Lora.CS is set, but its value could not be read as a pin number" \ + "Result: 1 error, 0 warnings" + +echo +echo "CH341 USB-SPI adapters:" +# The Lora pins of a ch341 device are indexes on the adapter, driven by the usermode +# driver: portduinoSetup() skips initGPIOPin() for all of them. Reporting them as +# gpiochip lines to confirm with gpioinfo is wrong on Linux and meaningless on the +# Windows and macOS hosts where a USB adapter is the only way to attach a radio. +assert "adapter pins are not reported as GPIO lines" 0 usb-ch341.yaml check \ + "CH341 adapter pins (driven over USB, not claimed from a gpiochip)" \ + "pin indexes on the CH341 itself" \ + "Module : sx1262" \ + "Result: 0 errors, 0 warnings" +assert "gpiochip mapping on a ch341 device is ignored" 0 ch341-gpiochip.yaml check \ + "Lora.spidev is ch341" \ + "Lora.CS.gpiochip, Lora.CS.line, Lora.gpiochip are read but never used" \ + "Result: 0 errors, 1 warning" + +echo +echo "structural faults:" +assert "duplicate key" 1 duplicate-key.yaml check \ + "duplicate key 'Lora.CS'" \ + "yaml-cpp keeps the FIRST occurrence" \ + "Result: 1 error, 0 warnings" +assert "section body that is not a mapping" 1 nonmap-section.yaml check \ + "'Lora' is not a mapping" \ + "Result: 1 error, 0 warnings" +assert "unknown top-level section" 1 unknown-section.yaml check \ + "unknown top-level section 'Telemetry'" \ + "Result: 1 error, 0 warnings" +assert "key left at the top level" 1 stranded-key.yaml check \ + "'spidev' is a key of Display or Lora or Touchscreen" \ + "indent it one level" \ + "Result: 1 error, 0 warnings" +assert "top level is a list" 1 top-level-list.yaml check \ + "top level is not a mapping" \ + "Result: 1 error, 0 warnings" +assert "empty file" 0 empty-file.yaml check \ + "is empty" \ + "Result: 0 errors, 1 warning" +# Only the unknown key is asserted: the accompanying "built without HUB75 support" +# error depends on whether rgbmatrix was present at build time, so it is not a stable +# expectation across builds. +assert "unknown HUB75 option" 1 hub75-unknown-key.yaml check \ + "unknown key 'Display.HUB75.Colours'" +# The point of the case: a config that will not parse must still reach the report +# with a file and a line, rather than exiting early with just "Unable to use ...". +# The asserted line number counts the fixture's comment header - the only assertion +# here that moves if you edit those comments. +assert "unparseable config still reaches the report" 1 malformed-indent.yaml check \ + "malformed-indent.yaml" \ + "could not be parsed" \ + "error at line 7" \ + "Result: 1 error, 0 warnings" + +echo +echo "across a config directory:" +# Three files each opening a 'Lora:' section. Every key not repeated in the last +# one loaded is silently reset to its default - here config.yaml's TCXO voltage. +# The warning COUNT is deliberately not asserted: the two config.d files name +# different modules, so which one wins - and therefore whether the LR11xx-without-a +# switch-table warning fires - depends on the order the filesystem returns them in, +# which is the very thing this fixture exists to demonstrate. +assert "config.d overrides are reported" 0 configd-conflict/config.yaml check \ + "config.d/lora-a.yaml" \ + "config.d/lora-b.yaml" \ + "not necessarily alphabetical" \ + "files define a 'Lora:' section" \ + "The file loaded last wins" \ + "Result: 0 errors," +# Switch tables are the one place "last wins" is false: the loader only ever writes +# HIGH, so the effective table is the OR of every file. Proven with --output-yaml. +assert "switch tables across files do not override" 1 rfswitch-sticky/config.yaml check \ + "These do NOT override each other" \ + "a HIGH from an earlier file survives a later file that sets LOW" \ + "Enable exactly one" + +echo +echo "--check takes precedence over --output-yaml:" +assert "report wins over the yaml dump" 0 valid.yaml check-yaml \ + "meshtasticd configuration check" \ + "Result: 0 errors, 0 warnings" + +echo +echo "normal operation rejects a bad config:" +assert "unparseable config is refused" 1 malformed-indent.yaml normal \ + "Unable to use" \ + "as config file" +assert "non-mapping section is refused" 1 nonmap-section.yaml normal \ + "Unable to use" \ + "as config file" +assert "unknown module is refused" 1 module-unknown.yaml normal \ + "Unknown Lora.Module: sx1263" +assert "MACAddress conflict is refused" 1 mac-conflict.yaml normal \ + "Cannot set both MACAddress and MACAddressSource!" +# Only meaningful on a build without rgbmatrix. A build that supports HUB75 accepts +# the panel and goes on to start a node, which is not something to assert here. +# Captured rather than piped: this run exits non-zero by design, and pipefail would +# make the pipeline look failed however the grep went. +HUB75_PROBE="$("$BIN" --check --config "$FIXTURES/hub75-unknown-key.yaml" -d "$WORKDIR/fs" 2>&1)" +if grep -qF "built without HUB75 support" <<<"$HUB75_PROBE"; then + assert "unsupported display panel is refused" 1 hub75-unknown-key.yaml normal \ + "this build does not support HUB75" +else + echo " skip unsupported display panel is refused (this build has HUB75 support)" +fi + +echo +TOTAL=$((PASS + FAIL)) +if [[ $FAIL -eq 0 ]]; then + echo "RESULT: GREEN $PASS/$TOTAL assertions passed" + exit 0 +fi +echo "RESULT: RED $FAIL of $TOTAL assertions failed" +exit 1 diff --git a/src/platform/portduino/ConfigCheck.cpp b/src/platform/portduino/ConfigCheck.cpp new file mode 100644 index 000000000..aa0f0c4cb --- /dev/null +++ b/src/platform/portduino/ConfigCheck.cpp @@ -0,0 +1,1094 @@ +#include "ConfigCheck.h" + +#ifndef ARCH_PORTDUINO_WASM + +#include "configuration.h" + +#include "PortduinoGlue.h" + +#include "yaml-cpp/eventhandler.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- +// Mirrors the keys loadConfig() reads: meshtasticd silently ignores anything else, so +// an unlisted key is honestly "unknown". Teach loadConfig() a new key, add it here too -- +// CI runs --check over bin/config.d/**, so an omission fails the build rather than a user. + +const std::set kLoraPinKeys = {"CS", "IRQ", "Busy", "Reset", "TXen", "RXen", "SX126X_ANT_SW", "GPIO_DETECT_PA"}; + +const std::map> &schema() +{ + static const std::map> s = { + {"Lora", + {"Module", + "gpiochip", + "spidev", + "spiSpeed", + "DIO2_AS_RF_SWITCH", + "DIO3_TCXO_VOLTAGE", + "Enable_Pins", + "rfswitch_table", + "LR1110_MAX_POWER", + "LR1120_MAX_POWER", + "RF95_MAX_POWER", + "SX126X_MAX_POWER", + "SX128X_MAX_POWER", + "TX_GAIN_LORA", + "USB_PID", + "USB_VID", + "USB_Serialnum", + "CS", + "IRQ", + "Busy", + "Reset", + "TXen", + "RXen", + "SX126X_ANT_SW", + "GPIO_DETECT_PA"}}, + {"General", + {"MACAddress", "MACAddressSource", "MaxNodes", "MaxMessageQueue", "APIPort", "ConfigDirectory", "AvailableDirectory"}}, + {"Config", {"DisplayMode", "EnableUDP", "StatusMessage"}}, + {"Display", + {"Panel", "spidev", "BusFrequency", "Width", "Height", "Invert", "Rotate", "OffsetX", "OffsetY", "OffsetRotate", + "RGBOrder", "HUB75", "DC", "CS", "Backlight", "BacklightInvert", "BacklightPWMChannel", "Reset"}}, + {"Touchscreen", {"Module", "spidev", "BusFrequency", "I2CAddr", "Rotate", "CS", "IRQ"}}, + {"Input", + {"KeyboardDevice", "PointerDevice", "JoystickDevice", "JoystickButtons", "TrackballDirection", "User", "TrackballUp", + "TrackballDown", "TrackballLeft", "TrackballRight", "TrackballPress"}}, + {"GPIO", {"User", "ExtraPins"}}, + {"GPS", {"SerialPath", "GpsdHost", "GpsdPort"}}, + {"I2C", {"I2CDevice"}}, + {"Logging", {"LogLevel", "TraceFile", "JSONFile", "JSONFileRotate", "JSONFilter", "AsciiLogs"}}, + {"Webserver", {"Port", "RootPath", "SSLCert", "SSLKey"}}, + {"HostMetrics", {"ReportInterval", "Channel", "UserStringCommand"}}, + // Read by packaging/menu tooling rather than by meshtasticd itself. + {"Meta", {}}, + }; + return s; +} + +// Sections whose contents meshtasticd never reads, so unknown keys inside them are +// not worth reporting. +const std::set kFreeFormSections = {"Meta"}; + +const std::set kPinSubKeys = {"pin", "gpiochip", "line"}; + +const std::set kHub75Keys = { + "HardwareMapping", "Rows", "Cols", "ChainLength", "Parallel", "PWMBits", + "PWMLSBNanoseconds", "Brightness", "ScanMode", "RowAddressType", "Multiplexing", "DisableHardwarePulsing", + "ShowRefreshRate", "InverseColors", "RGBSequence", "PixelMapper", "PanelType", "LimitRefreshRateHz", + "GPIOSlowdown"}; + +const std::set kRfSwitchModes = {"MODE_STBY", "MODE_RX", "MODE_TX", "MODE_TX_HP", + "MODE_TX_HF", "MODE_GNSS", "MODE_WIFI"}; + +const std::set kRfSwitchPins = {"DIO5", "DIO6", "DIO7", "DIO8", "DIO10"}; + +// Reverse index: key name -> sections it is valid in. Powers the "you probably meant +// to nest this under X" hint that turns a silent no-op into an actionable message. +const std::map> &keyOwners() +{ + static const std::map> owners = [] { + std::map> m; + for (const auto §ion : schema()) + for (const auto &key : section.second) + m[key].insert(section.first); + // rfswitch_table's sub-keys are worth hinting on too: a table indented one + // level too far leaves MODE_* rows stranded directly under Lora. + for (const auto &mode : kRfSwitchModes) + m[mode].insert("Lora.rfswitch_table"); + m["pins"].insert("Lora.rfswitch_table"); + return m; + }(); + return owners; +} + +// --------------------------------------------------------------------------- +// Findings +// --------------------------------------------------------------------------- + +enum Level { kInfo, kWarn, kError }; + +struct Finding { + Level level; + std::string file; + int line; // 1-based; 0 when the finding is not tied to a line + std::string message; +}; + +const char *levelName(Level l) +{ + switch (l) { + case kError: + return "ERROR"; + case kWarn: + return "WARN "; + default: + return "INFO "; + } +} + +std::string joinSections(const std::set &s) +{ + std::string out; + for (const auto &item : s) { + if (!out.empty()) + out += " or "; + out += item; + } + return out; +} + +int lineOf(const YAML::Node &node) +{ + const YAML::Mark mark = node.Mark(); + return mark.is_null() ? 0 : mark.line + 1; +} + +// --------------------------------------------------------------------------- +// Duplicate key detection +// --------------------------------------------------------------------------- +// yaml-cpp silently keeps the FIRST of duplicate keys, so a later override looks applied +// but is not. The Node API sees an already-collapsed map, so walk the parser events. + +class DuplicateKeyFinder : public YAML::EventHandler +{ + public: + struct Duplicate { + std::string path; + int line; + int firstLine; + }; + std::vector duplicates; + + void OnDocumentStart(const YAML::Mark &) override {} + void OnDocumentEnd() override {} + void OnNull(const YAML::Mark &, YAML::anchor_t) override { advance(); } + void OnAlias(const YAML::Mark &, YAML::anchor_t) override { advance(); } + void OnAnchor(const YAML::Mark &, const std::string &) override {} + + void OnScalar(const YAML::Mark &mark, const std::string &, YAML::anchor_t, const std::string &value) override + { + if (!stack.empty() && stack.back().isMap && stack.back().expectKey) { + auto &seen = stack.back().seen; + const auto existing = seen.find(value); + if (existing != seen.end()) + duplicates.push_back({pathTo(value), static_cast(mark.line) + 1, existing->second}); + else + seen.emplace(value, static_cast(mark.line) + 1); + stack.back().key = value; + } + advance(); + } + + void OnSequenceStart(const YAML::Mark &, const std::string &, YAML::anchor_t, YAML::EmitterStyle::value) override + { + push(false); + } + void OnSequenceEnd() override { pop(); } + void OnMapStart(const YAML::Mark &, const std::string &, YAML::anchor_t, YAML::EmitterStyle::value) override { push(true); } + void OnMapEnd() override { pop(); } + + private: + struct Context { + bool isMap; + bool expectKey; + std::string key; + std::map seen; + }; + std::vector stack; + + // Inside a mapping the parser alternates key, value, key, value... + void advance() + { + if (!stack.empty() && stack.back().isMap) + stack.back().expectKey = !stack.back().expectKey; + } + void push(bool isMap) + { + advance(); // the collection itself occupies a slot in its parent + stack.push_back(Context{isMap, true, "", {}}); + } + // No advance(): push() already consumed the collection's slot in the parent. Guarded + // because balance rests on yaml-cpp emitting matched start/end events -- its invariant. + void pop() + { + if (!stack.empty()) + stack.pop_back(); + } + std::string pathTo(const std::string &leaf) const + { + std::string path; + for (size_t i = 0; i + 1 < stack.size(); i++) + if (stack[i].isMap && !stack[i].key.empty()) + path += stack[i].key + "."; + return path + leaf; + } +}; + +void checkDuplicateKeys(const std::string &file, std::vector &findings) +{ + std::ifstream stream(file); + if (!stream) + return; + DuplicateKeyFinder finder; + try { + YAML::Parser parser(stream); + while (parser.HandleNextDocument(finder)) { + } + } catch (const std::exception &) { + return; // reported separately by checkFile() + } + for (const auto &dup : finder.duplicates) + findings.push_back({kError, file, dup.line, + "duplicate key '" + dup.path + "' (first defined on line " + std::to_string(dup.firstLine) + + "). yaml-cpp keeps the FIRST occurrence, so this one is silently discarded"}); +} + +// --------------------------------------------------------------------------- +// Structural checks +// --------------------------------------------------------------------------- + +void checkPinNode(const std::string &file, const std::string &path, const YAML::Node &node, std::vector &findings) +{ + if (!node.IsMap()) + return; // plain scalar pin number, always fine + for (const auto &entry : node) { + const std::string key = entry.first.as(""); + if (!kPinSubKeys.count(key)) + findings.push_back({kError, file, lineOf(entry.first), + "unknown key '" + path + "." + key + "'. A pin mapping accepts only pin, gpiochip and line"}); + } +} + +void checkRfSwitchTable(const std::string &file, const YAML::Node &table, std::vector &findings) +{ + if (!table.IsMap()) { + findings.push_back({kError, file, lineOf(table), "Lora.rfswitch_table must be a mapping"}); + return; + } + + size_t pinCount = 0; + if (const YAML::Node pins = table["pins"]) { + if (!pins.IsSequence()) { + findings.push_back({kError, file, lineOf(pins), "Lora.rfswitch_table.pins must be a list"}); + } else { + pinCount = pins.size(); + for (const auto &pin : pins) { + const std::string name = pin.as(""); + if (!kRfSwitchPins.count(name)) + findings.push_back({kError, file, lineOf(pin), + "Lora.rfswitch_table.pins: '" + name + + "' is not a recognised pin. Valid values are DIO5, DIO6, DIO7, DIO8 and DIO10"}); + } + if (pinCount > 5) + findings.push_back( + {kError, file, lineOf(pins), + "Lora.rfswitch_table.pins lists " + std::to_string(pinCount) + " pins but only the first 5 are read"}); + } + } else { + findings.push_back({kError, file, lineOf(table), "Lora.rfswitch_table has no 'pins' list, so no switch pins are driven"}); + } + + for (const auto &entry : table) { + const std::string key = entry.first.as(""); + if (key == "pins") + continue; + if (!kRfSwitchModes.count(key)) { + findings.push_back({kError, file, lineOf(entry.first), "unknown key 'Lora.rfswitch_table." + key + "'"}); + continue; + } + const YAML::Node &row = entry.second; + if (!row.IsSequence()) { + findings.push_back({kError, file, lineOf(row), "Lora.rfswitch_table." + key + " must be a list"}); + continue; + } + if (pinCount && row.size() != pinCount) + findings.push_back({kError, file, lineOf(row), + "Lora.rfswitch_table." + key + " has " + std::to_string(row.size()) + " values but " + + std::to_string(pinCount) + " pins are declared"}); + for (const auto &value : row) { + const std::string level = value.as(""); + if (level != "HIGH" && level != "LOW") + findings.push_back({kError, file, lineOf(value), + "Lora.rfswitch_table." + key + ": '" + level + + "' is not HIGH or LOW. Anything that is not exactly \"HIGH\" is treated as LOW"}); + } + } + + // Every mode absent from the table defaults to all-LOW, which for most modules is + // the shutdown state. Worth saying out loud rather than leaving to be discovered. + std::vector missing; + for (const auto &mode : kRfSwitchModes) + if (!table[mode]) + missing.push_back(mode); + if (!missing.empty()) { + std::string list; + for (const auto &mode : missing) + list += (list.empty() ? "" : ", ") + mode; + findings.push_back( + {kInfo, file, lineOf(table), "Lora.rfswitch_table omits " + list + "; those modes default to all pins LOW"}); + } +} + +// --------------------------------------------------------------------------- +// Value types +// --------------------------------------------------------------------------- +// A wrong-typed value silently does nothing under .as(default), but the two reads with +// NO default (Logging.AsciiLogs, TX_GAIN_LORA) throw and stop meshtasticd. Conversions are +// tested by asking yaml-cpp, so this cannot drift from what loadConfig() accepts. + +enum ValueType { kBool, kInt, kFloat, kString, kIntList, kBoolOrFloat, kIntOrString }; + +struct ValueSpec { + ValueType type; + bool fatal; // read without a default: a bad value stops meshtasticd outright +}; + +const std::map &valueSpecs() +{ + static const std::map s = { + {"Lora.spiSpeed", {kInt, false}}, + {"Lora.gpiochip", {kInt, false}}, + {"Lora.spidev", {kString, false}}, + {"Lora.DIO2_AS_RF_SWITCH", {kBool, false}}, + // Accepts a float (volts) or `true` (meaning 1.8V), so both are allowed here. + {"Lora.DIO3_TCXO_VOLTAGE", {kBoolOrFloat, false}}, + {"Lora.LR1110_MAX_POWER", {kInt, false}}, + {"Lora.LR1120_MAX_POWER", {kInt, false}}, + {"Lora.RF95_MAX_POWER", {kInt, false}}, + {"Lora.SX126X_MAX_POWER", {kInt, false}}, + {"Lora.SX128X_MAX_POWER", {kInt, false}}, + // TX_GAIN_LORA is not in this table: it accepts a list OR a bare scalar, and + // only the list path is fatal. See checkTxGain(). + {"Lora.USB_PID", {kInt, false}}, + {"Lora.USB_VID", {kInt, false}}, + {"Lora.USB_Serialnum", {kString, false}}, + {"General.MaxNodes", {kInt, false}}, + {"General.MaxMessageQueue", {kInt, false}}, + {"General.APIPort", {kInt, false}}, + {"General.ConfigDirectory", {kString, false}}, + {"General.AvailableDirectory", {kString, false}}, + {"Config.DisplayMode", {kString, false}}, + {"Config.EnableUDP", {kBool, false}}, + {"Config.StatusMessage", {kString, false}}, + {"Display.Panel", {kString, false}}, + {"Display.spidev", {kString, false}}, + {"Display.BusFrequency", {kInt, false}}, + {"Display.Width", {kInt, false}}, + {"Display.Height", {kInt, false}}, + {"Display.Invert", {kBool, false}}, + {"Display.Rotate", {kInt, false}}, + {"Display.OffsetX", {kInt, false}}, + {"Display.OffsetY", {kInt, false}}, + {"Display.OffsetRotate", {kInt, false}}, + {"Display.RGBOrder", {kBool, false}}, + {"Display.BacklightInvert", {kBool, false}}, + {"Touchscreen.Module", {kString, false}}, + {"Touchscreen.spidev", {kString, false}}, + {"Touchscreen.BusFrequency", {kInt, false}}, + {"Touchscreen.I2CAddr", {kInt, false}}, + {"Touchscreen.Rotate", {kBool, false}}, + {"Input.KeyboardDevice", {kString, false}}, + {"Input.PointerDevice", {kString, false}}, + {"Input.JoystickDevice", {kString, false}}, + {"Input.TrackballDirection", {kString, false}}, + {"GPS.SerialPath", {kString, false}}, + {"GPS.GpsdHost", {kString, false}}, + {"GPS.GpsdPort", {kInt, false}}, + {"I2C.I2CDevice", {kString, false}}, + {"Logging.LogLevel", {kString, false}}, + {"Logging.TraceFile", {kString, false}}, + {"Logging.JSONFile", {kString, false}}, + {"Logging.JSONFileRotate", {kInt, false}}, + // Read as an int and as a string (the "textmessage"-style aliases). + {"Logging.JSONFilter", {kIntOrString, false}}, + {"Logging.AsciiLogs", {kBool, true}}, + {"Webserver.Port", {kInt, false}}, + {"Webserver.RootPath", {kString, false}}, + {"Webserver.SSLCert", {kString, false}}, + {"Webserver.SSLKey", {kString, false}}, + {"HostMetrics.ReportInterval", {kInt, false}}, + {"HostMetrics.Channel", {kInt, false}}, + {"HostMetrics.UserStringCommand", {kString, false}}, + {"Display.HUB75.Rows", {kInt, false}}, + {"Display.HUB75.Cols", {kInt, false}}, + {"Display.HUB75.ChainLength", {kInt, false}}, + {"Display.HUB75.Parallel", {kInt, false}}, + {"Display.HUB75.PWMBits", {kInt, false}}, + {"Display.HUB75.PWMLSBNanoseconds", {kInt, false}}, + {"Display.HUB75.Brightness", {kInt, false}}, + {"Display.HUB75.ScanMode", {kInt, false}}, + {"Display.HUB75.RowAddressType", {kInt, false}}, + {"Display.HUB75.Multiplexing", {kInt, false}}, + {"Display.HUB75.GPIOSlowdown", {kInt, false}}, + {"Display.HUB75.LimitRefreshRateHz", {kInt, false}}, + {"Display.HUB75.DisableHardwarePulsing", {kBool, false}}, + {"Display.HUB75.ShowRefreshRate", {kBool, false}}, + {"Display.HUB75.InverseColors", {kBool, false}}, + {"Display.HUB75.HardwareMapping", {kString, false}}, + {"Display.HUB75.RGBSequence", {kString, false}}, + {"Display.HUB75.PixelMapper", {kString, false}}, + {"Display.HUB75.PanelType", {kString, false}}, + }; + return s; +} + +const char *typeName(ValueType type) +{ + switch (type) { + case kBool: + return "a true/false value"; + case kInt: + return "a whole number"; + case kFloat: + return "a number"; + case kIntList: + return "a list of whole numbers"; + case kBoolOrFloat: + return "a number or true/false"; + case kIntOrString: + return "a whole number or a name"; + default: + return "a text value"; + } +} + +// Ask yaml-cpp to perform the same conversion loadConfig() will. +bool converts(const YAML::Node &node, ValueType type) +{ + try { + switch (type) { + case kBool: + node.as(); + return true; + case kInt: + node.as(); + return true; + case kFloat: + node.as(); + return true; + case kBoolOrFloat: + try { + node.as(); + return true; + } catch (const std::exception &) { + node.as(); + return true; + } + case kIntOrString: + try { + node.as(); + return true; + } catch (const std::exception &) { + node.as(); + return true; + } + case kIntList: + if (!node.IsSequence()) + return false; + for (const auto &item : node) + item.as(); + return true; + default: + node.as(); + return true; + } + } catch (const std::exception &) { + return false; + } +} + +void checkValueType(const std::string &file, const std::string &path, const YAML::Node &value, std::vector &findings) +{ + const auto spec = valueSpecs().find(path); + if (spec == valueSpecs().end() || converts(value, spec->second.type)) + return; + + if (spec->second.fatal) + findings.push_back({kError, file, lineOf(value), + path + " is not " + typeName(spec->second.type) + + ". This one is read without a fallback, so the conversion throws and meshtasticd " + "refuses to start on this file"}); + else + findings.push_back({kWarn, file, lineOf(value), + path + " is not " + typeName(spec->second.type) + + ", so it is silently replaced by the default and the setting does nothing"}); +} + +// The PA gain table's two shapes fail differently: a bad list entry stops meshtasticd (no +// default), a bad scalar falls back to 0. Backed by uint16_t[22], so extras drop and values wrap. +void checkTxGain(const std::string &file, const YAML::Node &node, std::vector &findings) +{ + constexpr size_t kMaxPaPoints = 22; + + if (node.IsSequence()) { + if (node.size() > kMaxPaPoints) + findings.push_back({kWarn, file, lineOf(node), + "Lora.TX_GAIN_LORA lists " + std::to_string(node.size()) + " points but only the first " + + std::to_string(kMaxPaPoints) + " are stored; the rest are dropped"}); + for (const auto &point : node) { + if (!converts(point, kInt)) { + findings.push_back({kError, file, lineOf(point), + "Lora.TX_GAIN_LORA entry '" + point.as("") + + "' is not a whole number. List entries are read without a fallback, so this " + "throws and meshtasticd refuses to start on this file"}); + continue; + } + const int value = point.as(); + // Stored into a uint16_t, so anything outside the range silently wraps. + if (value < 0 || value > 65535) + findings.push_back({kError, file, lineOf(point), + "Lora.TX_GAIN_LORA entry " + std::to_string(value) + + " does not fit the 0-65535 range it is stored in, so it wraps to a different " + "gain than the one written"}); + } + return; + } + + if (node.IsMap()) { + findings.push_back({kError, file, lineOf(node), "Lora.TX_GAIN_LORA must be a list of gain points, or a single number"}); + return; + } + + if (!converts(node, kInt)) + findings.push_back({kWarn, file, lineOf(node), + "Lora.TX_GAIN_LORA is not a whole number, so it is silently read as 0 and no PA gain is applied"}); +} + +// Module names match exactly and are inconsistently cased (RF95 upper, sx1262 lower), +// and loadConfig() exits on an unknown name without printing the valid set, so name it here. +void checkLoraModule(const std::string &file, const YAML::Node &module, std::vector &findings) +{ + const std::string name = module.as(""); + if (name.empty()) + return; + + std::string valid; + std::string caseHint; + for (const auto &known : portduino_config.loraModules) { + if (name == known.second) + return; + valid += (valid.empty() ? "" : ", ") + known.second; + if (caseHint.empty() && name.size() == known.second.size() && + std::equal(name.begin(), name.end(), known.second.begin(), [](char a, char b) { + // Cast first: tolower() on a negative char is undefined, and a stray + // non-ASCII byte in a hand-edited config is exactly how that happens. + return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); + })) + caseHint = known.second; + } + + std::string message = + "Lora.Module '" + name + "' is not a module meshtasticd knows, and it refuses to start. Valid: " + valid; + if (!caseHint.empty()) + message += ". The name is matched exactly -- did you mean '" + caseHint + "'?"; + findings.push_back({kError, file, lineOf(module), message}); +} + +// loadConfig() rejects a bad MACAddress or MACAddressSource silently, falling through to the +// BlueZ and LoRa-serial fallbacks; if those yield nothing, meshtasticd exits on a blank MAC. +void checkMacAddress(const std::string &file, const YAML::Node &general, std::vector &findings) +{ + const YAML::Node address = general["MACAddress"]; + const YAML::Node source = general["MACAddressSource"]; + const std::string addressText = address ? address.as("") : ""; + const std::string sourceText = source ? source.as("") : ""; + + if (!addressText.empty() && !sourceText.empty()) { + findings.push_back({kError, file, lineOf(source), + "General.MACAddress and General.MACAddressSource are both set. meshtasticd refuses to start " + "with both; keep whichever one you want the MAC to come from"}); + return; + } + + if (!addressText.empty()) { + std::string digits; + for (const char c : addressText) + if (c != ':' && c != '-') + digits += c; + const bool hex = digits.find_first_not_of("0123456789abcdefABCDEF") == std::string::npos; + // loadConfig() strips the colons and then requires more than 11 characters; + // anything shorter is dropped without a word and the MAC comes from elsewhere. + if (digits.size() < 12 || !hex) + findings.push_back({kError, file, lineOf(address), + "General.MACAddress '" + addressText + + "' is not 12 hex digits, so it is ignored and the MAC falls back to the Bluetooth " + "adapter or the LoRa device serial. If neither yields one, meshtasticd exits with " + "'Blank MAC Address not allowed!'"}); + } + + if (!sourceText.empty()) { + const std::string path = "/sys/class/net/" + sourceText + "/address"; + std::ifstream probe(path); + if (!probe.good()) + findings.push_back({kWarn, file, lineOf(source), + "General.MACAddressSource '" + sourceText + "' has no " + path + + " on this machine, so the MAC reads back empty and meshtasticd asks you to set one. " + "Expected if you are checking this config on a different host"}); + } +} + +void checkSection(const std::string &file, const std::string §ion, const YAML::Node &body, std::vector &findings) +{ + const auto &allowed = schema().at(section); + if (kFreeFormSections.count(section)) + return; + // An empty section (`Lora:` with no body) is null, not a map, and loadConfig() tests + // before reading it. Any other non-map shape is unreadable, so do not call the file clean. + if (body.IsNull()) + return; + if (!body.IsMap()) { + findings.push_back({kError, file, lineOf(body), "'" + section + "' is not a mapping, so nothing in it is read"}); + return; + } + + for (const auto &entry : body) { + const std::string key = entry.first.as(""); + const YAML::Node &value = entry.second; + + if (!allowed.count(key)) { + std::string message = "unknown key '" + section + "." + key + "', ignored by meshtasticd"; + const auto owner = keyOwners().find(key); + if (owner != keyOwners().end() && !owner->second.count(section)) + message += ". It is a valid key of " + joinSections(owner->second); + findings.push_back({kWarn, file, lineOf(entry.first), message}); + continue; + } + + checkValueType(file, section + "." + key, value, findings); + + if (section == "Lora" && key == "rfswitch_table") { + checkRfSwitchTable(file, value, findings); + } else if (section == "Lora" && key == "Module") { + checkLoraModule(file, value, findings); + } else if (section == "Lora" && key == "TX_GAIN_LORA") { + checkTxGain(file, value, findings); + } else if (section == "Display" && key == "HUB75") { + if (value.IsMap()) + for (const auto &hub : value) { + const std::string hubKey = hub.first.as(""); + if (!kHub75Keys.count(hubKey)) + findings.push_back( + {kWarn, file, lineOf(hub.first), "unknown key 'Display.HUB75." + hubKey + "', ignored"}); + else + checkValueType(file, "Display.HUB75." + hubKey, hub.second, findings); + } + } else if (key == "Enable_Pins" || key == "ExtraPins") { + if (value.IsSequence()) + for (const auto &pin : value) + checkPinNode(file, section + "." + key, pin, findings); + } else if (key == "JoystickButtons") { + // Free-form: any action name mapped to an evdev code. + } else if ((section == "Lora" && kLoraPinKeys.count(key)) || + (section == "Display" && + (key == "DC" || key == "CS" || key == "Backlight" || key == "BacklightPWMChannel" || key == "Reset")) || + (section == "Touchscreen" && (key == "CS" || key == "IRQ")) || + (section == "Input" && (key == "User" || key.rfind("Trackball", 0) == 0)) || + (section == "GPIO" && key == "User")) { + checkPinNode(file, section + "." + key, value, findings); + } + } +} + +// --------------------------------------------------------------------------- +// Cross-file overlap +// --------------------------------------------------------------------------- +// Every .yaml loads into the same portduino_config, so two files setting the same key +// are not merged: the one loaded LAST wins -- the opposite of the within-file rule. + +// path (below the top-level section) -> file -> line of its first appearance there +using PathIndex = std::map>; + +void collectPaths(const std::string &file, const YAML::Node &node, const std::string &prefix, int depth, PathIndex &index) +{ + if (!node.IsMap()) + return; + for (const auto &entry : node) { + const std::string key = entry.first.as(""); + const std::string path = prefix.empty() ? key : prefix + "." + key; + // depth 0 is the top-level section; several files each having a "Lora:" is + // normal, so only record what lives inside a section. + if (depth >= 1) + index[path].emplace(file, lineOf(entry.first)); + collectPaths(file, entry.second, path, depth + 1, index); + } +} + +void checkFile(const std::string &file, std::vector &findings, PathIndex &paths, + std::map> §ionOwners) +{ + YAML::Node doc; + try { + doc = YAML::LoadFile(file); + } catch (const std::exception &e) { + findings.push_back({kError, file, 0, std::string("could not be parsed, so it is being ignored entirely: ") + e.what()}); + return; + } + + if (doc.IsNull()) { + findings.push_back({kWarn, file, 0, "is empty"}); + return; + } + if (!doc.IsMap()) { + findings.push_back({kError, file, 0, "top level is not a mapping, so nothing in it is read"}); + return; + } + + checkDuplicateKeys(file, findings); + collectPaths(file, doc, "", 0, paths); + + for (const auto &entry : doc) { + const std::string sectionName = entry.first.as(""); + // A file that repeats a section still counts once here; the repeat itself is + // reported separately as a duplicate key. + auto &owners = sectionOwners[sectionName]; + if (schema().count(sectionName) && (owners.empty() || owners.back() != file)) + owners.push_back(file); + } + + for (const auto &entry : doc) { + const std::string section = entry.first.as(""); + if (schema().count(section)) { + checkSection(file, section, entry.second, findings); + if (section == "General" && entry.second.IsMap()) + checkMacAddress(file, entry.second, findings); + continue; + } + std::string message = "unknown top-level section '" + section + "', ignored by meshtasticd"; + const auto owner = keyOwners().find(section); + if (owner != keyOwners().end()) + message += ". '" + section + "' is a key of " + joinSections(owner->second) + + " -- indent it one level so it sits " + "inside that section"; + findings.push_back({kError, file, lineOf(entry.first), message}); + } +} + +std::string describeOwners(const std::map &owners) +{ + std::string out; + for (const auto &owner : owners) { + if (!out.empty()) + out += ", "; + out += owner.first; + if (owner.second) + out += " line " + std::to_string(owner.second); + } + return out; +} + +void checkCrossFileOverlap(const PathIndex &paths, const std::map> §ionOwners, + std::vector &findings) +{ + const std::string across = "(across configuration files)"; + + for (const auto &entry : paths) { + if (entry.second.size() < 2) + continue; + // If an ancestor also collides then the whole subtree is replaced together; + // reporting the parent once is clearer than repeating every leaf under it. + bool coveredByAncestor = false; + std::string ancestor = entry.first; + for (size_t dot = ancestor.rfind('.'); dot != std::string::npos; dot = ancestor.rfind('.')) { + ancestor.resize(dot); + const auto found = paths.find(ancestor); + if (found != paths.end() && found->second.size() >= 2) { + coveredByAncestor = true; + break; + } + } + if (coveredByAncestor) + continue; + + // The one place "last wins" is untrue: the loader only ever writes HIGH, so the effective + // table is the OR of every table loaded -- a switch state belonging to none of them. + if (entry.first == "Lora.rfswitch_table") { + findings.push_back({kError, across, 0, + "'Lora.rfswitch_table' is set in " + std::to_string(entry.second.size()) + " files (" + + describeOwners(entry.second) + + "). These do NOT override each other: the loader only ever writes HIGH, so a HIGH " + "from an earlier file survives a later file that sets LOW there, and the radio ends " + "up driving the OR of every table. Enable exactly one"}); + continue; + } + + findings.push_back({kInfo, across, 0, + "'" + entry.first + "' is set in " + std::to_string(entry.second.size()) + " files (" + + describeOwners(entry.second) + "). The file loaded last wins"}); + } + + // Sections do not merge, and these keys are assigned unconditionally with a default, so a + // later file with a Lora section that omits them silently resets them. + const auto loraOwners = sectionOwners.find("Lora"); + if (loraOwners != sectionOwners.end() && loraOwners->second.size() > 1) { + std::string files; + for (const auto &file : loraOwners->second) + files += (files.empty() ? "" : ", ") + file; + findings.push_back( + {kWarn, across, 0, + std::to_string(loraOwners->second.size()) + " files define a 'Lora:' section (" + files + + "). These keys are re-read with a default every time a Lora section is seen, so any of them not repeated " + "in the last file loaded is reset: spidev, spiSpeed, gpiochip, DIO2_AS_RF_SWITCH, DIO3_TCXO_VOLTAGE, " + "USB_PID, USB_VID, USB_Serialnum. Normally exactly one Lora config should be enabled at a time"}); + } +} + +// --------------------------------------------------------------------------- +// Semantic checks against the merged configuration +// --------------------------------------------------------------------------- + +bool isLR11xx(lora_module_enum module) +{ + return module == use_lr1110 || module == use_lr1120 || module == use_lr1121; +} + +std::string moduleName() +{ + const auto it = portduino_config.loraModules.find(portduino_config.lora_module); + return it == portduino_config.loraModules.end() ? "unknown" : it->second; +} + +// A pin whose value will not convert falls back to RADIOLIB_NC (-1) while still marked enabled, +// and initGPIOPin() then trips an assertion inside LinuxGPIOPin rather than failing cleanly. +void checkPinValues(std::vector &findings) +{ + const std::string merged = "(merged configuration)"; + + auto report = [&](const std::string &name) { + findings.push_back({kError, merged, 0, + name + " is set, but its value could not be read as a pin number so it resolves to -1. " + "meshtasticd aborts with an assertion when it tries to claim that line. Check for a " + "non-numeric value, or a stray line folded into it by YAML indentation"}); + }; + + for (const auto *pin : portduino_config.all_pins) + if (pin->enabled && pin->pin < 0) + report(pin->config_section + "." + pin->config_name); + for (const auto &pin : portduino_config.extra_pins) + if (pin.enabled && pin.pin < 0) + report(pin.config_section + "." + pin.config_name); +} + +void checkMergedConfig(const PathIndex &paths, std::vector &findings) +{ + const std::string merged = "(merged configuration)"; + + checkPinValues(findings); + + // portduinoSetup() skips initGPIOPin() for every Lora pin when spidev is ch341, so a + // gpiochip or line mapping written next to one is read, stored, and never used. + if (portduino_config.lora_spi_dev == "ch341") { + auto endsWith = [](const std::string &text, const std::string &suffix) { + return text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + std::string ignored; + for (const auto &entry : paths) { + if (entry.first.rfind("Lora.", 0) != 0) + continue; + if (entry.first == "Lora.gpiochip" || endsWith(entry.first, ".gpiochip") || endsWith(entry.first, ".line")) + ignored += (ignored.empty() ? "" : ", ") + entry.first; + } + if (!ignored.empty()) + findings.push_back({kWarn, merged, 0, + "Lora.spidev is ch341, so the Lora pins are indexes on the USB adapter and are driven by " + "the usermode driver rather than claimed from a gpiochip. " + + ignored + " are read but never used"}); + } + + if (isLR11xx(portduino_config.lora_module) && !portduino_config.has_rfswitch_table) + findings.push_back({kWarn, merged, 0, + "Module is " + moduleName() + + " but no Lora.rfswitch_table is set, so setRfSwitchTable() is never called. Most LR11xx " + "modules cannot transmit or receive without one"}); + + if (!isLR11xx(portduino_config.lora_module) && portduino_config.has_rfswitch_table) + findings.push_back( + {kWarn, merged, 0, + "a Lora.rfswitch_table is set but Module is " + moduleName() + ", and the table is only applied to LR11xx radios"}); + + // Either way -- the old uncaught filesystem_error abort or today's clean exit -- the files + // meant to configure the radio are not being loaded. + if (!portduino_config.config_directory.empty()) { + std::error_code error; + if (!std::filesystem::is_directory(portduino_config.config_directory, error)) + findings.push_back({kError, merged, 0, + "General.ConfigDirectory '" + portduino_config.config_directory + + "' is not a directory that can be read, so none of the files that were meant to be " + "loaded from it are being loaded and meshtasticd stops at startup"}); + } + + // strncpy into a char[80] that is then hard null-terminated: safe, but silently + // shortened, and the operator never sees the message they actually configured. + if (portduino_config.has_statusMessage && portduino_config.statusMessage.size() > 79) + findings.push_back({kWarn, merged, 0, + "Config.StatusMessage is " + std::to_string(portduino_config.statusMessage.size()) + + " characters and is truncated to 79 when it is stored"}); + + // DIO3_TCXO_VOLTAGE is in VOLTS while every other Meshtastic surface uses millivolts, so + // the natural "1800" silently asks for 1800V and nothing downstream range-checks it. + if (portduino_config.dio3_tcxo_voltage > 3600) + findings.push_back({kError, merged, 0, + "Lora.DIO3_TCXO_VOLTAGE resolves to " + std::to_string(portduino_config.dio3_tcxo_voltage) + + " mV, which no TCXO runs at. The value is in VOLTS and is multiplied by 1000, so write " + "1.8 (or true) for a 1.8V part, not 1800"}); + + // loadConfig() only adopts APIPort inside this range and otherwise keeps the + // default without a word, so an out-of-range port silently does nothing. + if (portduino_config.api_port != -1 && (portduino_config.api_port <= 1023 || portduino_config.api_port >= 65536)) + findings.push_back({kWarn, merged, 0, + "General.APIPort " + std::to_string(portduino_config.api_port) + + " is outside 1024-65535, so it is ignored and the default port is used instead"}); + + if (portduino_config.webserverport != -1 && (portduino_config.webserverport <= 0 || portduino_config.webserverport >= 65536)) + findings.push_back({kError, merged, 0, + "Webserver.Port " + std::to_string(portduino_config.webserverport) + " is not a usable TCP port"}); + + if (portduino_config.MaxNodes <= 0) + findings.push_back({kError, merged, 0, + "General.MaxNodes is " + std::to_string(portduino_config.MaxNodes) + + ", which leaves no room for even this node's own entry"}); + +#if !defined(HAS_HUB75_NATIVE) + // A build-time gap rather than a config error: the same file is valid on a + // meshtasticd built with rpi-rgb-led-matrix present. + if (portduino_config.displayPanel == hub75) + findings.push_back({kError, merged, 0, + "Display.Panel is HUB75 but this meshtasticd was built without HUB75 support, so it exits at " + "startup. Rebuild with hzeller/rpi-rgb-led-matrix installed (it provides rgbmatrix.pc)"}); +#endif + + if (portduino_config.lora_cs_pin.enabled && !portduino_config.lora_spi_dev.empty() && + portduino_config.lora_spi_dev != "ch341") + findings.push_back({kInfo, merged, 0, + "both Lora.spidev (" + portduino_config.lora_spi_dev + + ") and Lora.CS are set. If your device tree already assigns a chip select to that spidev " + "node, meshtasticd will fail to claim the CS line at startup; if it does not, CS must be " + "set here. Check with 'gpioinfo'"}); +} + +void printSummary() +{ + std::cout << "\nEffective radio configuration:\n"; + std::cout << " Module : " << moduleName() << "\n"; + if (!portduino_config.lora_spi_dev.empty()) + std::cout << " spidev : " << portduino_config.lora_spi_dev << "\n"; + std::cout << " SPI speed : " << portduino_config.spiSpeed << "\n"; + if (portduino_config.dio3_tcxo_voltage) + std::cout << " DIO3 TCXO voltage : " << portduino_config.dio3_tcxo_voltage << " mV\n"; + + // setRfSwitchTable() is only called for an LR11xx, so "not set" is no gap elsewhere; "auto" + // has not resolved to a module yet, so absence cannot be called either way. + const char *rfSwitch = "not needed for this module"; + if (portduino_config.has_rfswitch_table) + rfSwitch = "set"; + else if (isLR11xx(portduino_config.lora_module)) + rfSwitch = "not set"; + else if (portduino_config.lora_module == use_autoconf) + rfSwitch = "not set (module not resolved yet)"; + std::cout << " RF switch table : " << rfSwitch << "\n"; + + // A ch341 adapter's Lora pins are adapter indexes handed to Ch341Hal, not gpiochip lines + // (portduinoSetup() skips initGPIOPin()), so pointing the user at gpioinfo would be wrong. + const bool usbAdapter = portduino_config.lora_spi_dev == "ch341"; + + std::cout << (usbAdapter ? "\nCH341 adapter pins (driven over USB, not claimed from a gpiochip):\n" + : "\nResolved GPIO lines (what meshtasticd will try to claim):\n"); + bool any = false; + for (const auto *pin : portduino_config.all_pins) { + if (!pin->enabled || pin->config_section != "Lora") + continue; + any = true; + std::cout << " " << pin->config_name; + for (size_t i = pin->config_name.size(); i < 18; i++) + std::cout << ' '; + std::cout << ": pin " << pin->pin; + if (!usbAdapter) + std::cout << " gpiochip" << pin->gpiochip << " line " << pin->line; + std::cout << "\n"; + } + if (!any) + std::cout << " (none configured)\n"; + if (usbAdapter) + std::cout << "\n These are pin indexes on the CH341 itself, so 'gpiodetect' and 'gpioinfo' say\n" + " nothing about them. Lora.gpiochip and any per-pin gpiochip/line mapping are\n" + " ignored for a ch341 device.\n"; + else + std::cout << "\n Confirm these against 'gpiodetect' and 'gpioinfo' on this machine. A line that\n" + " exists on the wrong chip is claimed successfully and silently does nothing.\n"; +} + +} // namespace + +int runConfigCheck(const std::vector &configFiles) +{ + std::vector findings; + + std::cout << "meshtasticd configuration check\n"; + std::cout << "===============================\n\n"; + + if (configFiles.empty()) { + std::cout << "No configuration files were found.\n"; + return 1; + } + + std::cout << "Configuration files, in load order (later files override earlier ones):\n"; + for (size_t i = 0; i < configFiles.size(); i++) + std::cout << " " << (i + 1) << ". " << configFiles[i] << "\n"; + if (configFiles.size() > 1) + std::cout << "\n Files in the config directory are read in whatever order the filesystem\n" + " returns them, which is not necessarily alphabetical and can differ between\n" + " machines. Avoid relying on one file overriding another.\n"; + + PathIndex paths; + std::map> sectionOwners; + for (const auto &file : configFiles) + checkFile(file, findings, paths, sectionOwners); + checkCrossFileOverlap(paths, sectionOwners, findings); + checkMergedConfig(paths, findings); + + int errors = 0, warnings = 0; + std::string currentFile; + for (const auto &finding : findings) { + if (finding.level == kError) + errors++; + else if (finding.level == kWarn) + warnings++; + + if (finding.file != currentFile) { + currentFile = finding.file; + std::cout << "\n" << currentFile << "\n"; + } + std::cout << " " << levelName(finding.level) << ' '; + if (finding.line) + std::cout << "line " << finding.line << ": "; + std::cout << finding.message << "\n"; + } + + printSummary(); + + std::cout << "\nResult: " << errors << (errors == 1 ? " error, " : " errors, ") << warnings + << (warnings == 1 ? " warning" : " warnings") << "\n"; + if (errors == 0 && warnings == 0) + std::cout << "Configuration looks good.\n"; + + return errors ? 1 : 0; +} + +#endif // !ARCH_PORTDUINO_WASM diff --git a/src/platform/portduino/ConfigCheck.h b/src/platform/portduino/ConfigCheck.h new file mode 100644 index 000000000..77aeb53dc --- /dev/null +++ b/src/platform/portduino/ConfigCheck.h @@ -0,0 +1,12 @@ +#pragma once + +#ifndef ARCH_PORTDUINO_WASM + +#include +#include + +// Validates the loaded meshtasticd YAML, prints a report, and returns an exit code: 0 when +// nothing fatal was found, 1 otherwise. configFiles lists every attempted file in load order. +int runConfigCheck(const std::vector &configFiles); + +#endif // !ARCH_PORTDUINO_WASM diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index dd0f663f0..bc714fcbe 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -6,6 +6,7 @@ #include "sleep.h" #include "target_specific.h" +#include "ConfigCheck.h" #include "PortduinoGlue.h" #include "SHA256.h" #include "api/ServerAPI.h" @@ -65,6 +66,9 @@ char *configPath = nullptr; char *optionMac = nullptr; bool verboseEnabled = false; bool yamlOnly = false; +bool configCheck = false; +// Every config file we attempted to load, in load order, for --check to report on. +std::vector attemptedConfigFiles; const char *argp_program_version = optstr(APP_VERSION); @@ -86,9 +90,16 @@ void updateBatteryLevel(uint8_t level) NOT_IMPLEMENTED("updateBatteryLevel"); int TCPPort = SERVER_API_DEFAULT_PORT; bool checkConfigPort = true; +// Long-only option: argp treats any key above the printable ASCII range as having no +// single-character equivalent. +#define OPT_CONFIG_CHECK 1001 + static error_t parse_opt(int key, char *arg, struct argp_state *state) { switch (key) { + case OPT_CONFIG_CHECK: + configCheck = true; + break; case 'p': if (sscanf(arg, "%d", &TCPPort) < 1) { return ARGP_ERR_UNKNOWN; @@ -160,13 +171,15 @@ static void checkSpidevBufsiz() void portduinoCustomInit() { - static struct argp_option options[] = {{"port", 'p', "PORT", 0, "The TCP port to use."}, - {"config", 'c', "CONFIG_PATH", 0, "Full path of the .yaml config file to use."}, - {"hwid", 'h', "HWID", 0, "The mac address to assign to this virtual machine"}, - {"sim", 's', 0, 0, "Run in Simulated radio mode"}, - {"verbose", 'v', 0, 0, "Set log level to full debug"}, - {"output-yaml", 'y', 0, 0, "Output config yaml and exit"}, - {0}}; + static struct argp_option options[] = { + {"port", 'p', "PORT", 0, "The TCP port to use."}, + {"config", 'c', "CONFIG_PATH", 0, "Full path of the .yaml config file to use."}, + {"hwid", 'h', "HWID", 0, "The mac address to assign to this virtual machine"}, + {"sim", 's', 0, 0, "Run in Simulated radio mode"}, + {"verbose", 'v', 0, 0, "Set log level to full debug"}, + {"output-yaml", 'y', 0, 0, "Output config yaml and exit"}, + {"check", OPT_CONFIG_CHECK, 0, 0, "Check the configuration for problems, print a report, and exit"}, + {0}}; static void *childArguments; static char doc[] = "Meshtastic native build."; static char args_doc[] = "..."; @@ -310,40 +323,54 @@ void portduinoSetup() portduino_config.lora_module = use_simradio; } else if (configPath != nullptr) { if (loadConfig(configPath)) { - if (!yamlOnly) + if (!yamlOnly && !configCheck) std::cout << "Using " << configPath << " as config file" << std::endl; - } else { + } else if (!configCheck) { + // In check mode the path is already in attemptedConfigFiles, so fall through + // to runConfigCheck() and let it report the parse error with a file and line. std::cout << "Unable to use " << configPath << " as config file" << std::endl; exit(EXIT_FAILURE); } } else if (access("config.yaml", R_OK) == 0) { if (loadConfig("config.yaml")) { - if (!yamlOnly) + if (!yamlOnly && !configCheck) std::cout << "Using local config.yaml as config file" << std::endl; - } else { + } else if (!configCheck) { std::cout << "Unable to use local config.yaml as config file" << std::endl; exit(EXIT_FAILURE); } } else if (access("/etc/meshtasticd/config.yaml", R_OK) == 0) { if (loadConfig("/etc/meshtasticd/config.yaml")) { - if (!yamlOnly) + if (!yamlOnly && !configCheck) std::cout << "Using /etc/meshtasticd/config.yaml as config file" << std::endl; - } else { + } else if (!configCheck) { std::cout << "Unable to use /etc/meshtasticd/config.yaml as config file" << std::endl; exit(EXIT_FAILURE); } } else { - if (!yamlOnly) + if (!yamlOnly && !configCheck) std::cout << "No 'config.yaml' found..." << std::endl; portduino_config.lora_module = use_simradio; } if (portduino_config.config_directory != "") { - std::string filetype = ".yaml"; - for (const std::filesystem::directory_entry &entry : - std::filesystem::directory_iterator{portduino_config.config_directory}) { + // The throwing form of directory_iterator turns an unreadable ConfigDirectory into an + // uncaught filesystem_error and a SIGABRT, so take the error_code overload instead. + std::error_code dirError; + std::filesystem::directory_iterator entries{portduino_config.config_directory, dirError}; + if (dirError) { + // Half a configuration is worse than none. --check continues so the report can say + // so with the rest of the findings. + if (!configCheck) { + std::cout << "Unable to read ConfigDirectory " << portduino_config.config_directory << ": " << dirError.message() + << std::endl; + exit(EXIT_FAILURE); + } + } + for (const std::filesystem::directory_entry &entry : entries) { if (ends_with(entry.path().string(), ".yaml")) { - std::cout << "Also using " << entry << " as additional config file" << std::endl; + if (!configCheck) + std::cout << "Also using " << entry << " as additional config file" << std::endl; // .string() rather than .c_str(): path::value_type is wchar_t on // Windows, and loadConfig() takes a const char *. loadConfig(entry.path().string().c_str()); @@ -352,6 +379,11 @@ void portduinoSetup() } #ifndef ARCH_PORTDUINO_WASM + // --check wins over --output-yaml: asking for validation and getting a config dump + // with no report at all would be the more surprising of the two outcomes. + if (configCheck) + exit(runConfigCheck(attemptedConfigFiles)); + if (yamlOnly) { std::cout << portduino_config.emit_yaml() << std::endl; exit(EXIT_SUCCESS); @@ -828,6 +860,10 @@ bool loadConfig(const char *configPath) #else bool loadConfig(const char *configPath) { + // Recorded even when the load below fails: an unparseable config.d entry is skipped and its + // return value discarded by the caller, so --check needs to know it was attempted. + attemptedConfigFiles.push_back(configPath); + YAML::Node yamlConfig; try { yamlConfig = YAML::LoadFile(configPath); @@ -886,7 +922,9 @@ bool loadConfig(const char *configPath) break; } } - if (!found) { + if (!found && !configCheck) { + // --check names the valid modules in its report; exiting here would + // replace that with a bare one-liner. std::cerr << "Unknown Lora.Module: " << moduleName << std::endl; exit(EXIT_FAILURE); } @@ -1081,7 +1119,9 @@ bool loadConfig(const char *configPath) } } #if !defined(HAS_HUB75_NATIVE) - if (portduino_config.displayPanel == hub75) { + if (portduino_config.displayPanel == hub75 && !configCheck) { + // --check still validates the rest of the file and reports this as a + // finding, so it must not exit from inside the load. std::cerr << "HUB75 display panel selected, but this build does not support HUB75" << std::endl; exit(EXIT_FAILURE); } @@ -1225,8 +1265,12 @@ bool loadConfig(const char *configPath) (yamlConfig["General"]["AvailableDirectory"]).as("/etc/meshtasticd/available.d/"); if ((yamlConfig["General"]["MACAddress"]).as("") != "" && (yamlConfig["General"]["MACAddressSource"]).as("") != "") { - std::cout << "Cannot set both MACAddress and MACAddressSource!" << std::endl; - exit(EXIT_FAILURE); + // --check reports this as a finding against the file it came from, so + // exiting here would kill the report before it is printed. + if (!configCheck) { + std::cout << "Cannot set both MACAddress and MACAddressSource!" << std::endl; + exit(EXIT_FAILURE); + } } if (checkConfigPort) { portduino_config.api_port = (yamlConfig["General"]["APIPort"]).as(-1); @@ -1249,7 +1293,10 @@ bool loadConfig(const char *configPath) portduino_config.mac_address.end()); } } catch (YAML::Exception &e) { - std::cout << "*** Exception " << e.what() << std::endl; + // The check report repeats this against the file it came from, so printing it + // here too would only put a stray line above the report. + if (!configCheck) + std::cout << "*** Exception " << e.what() << std::endl; return false; } return true; diff --git a/test/README.md b/test/README.md index 68b271f1e..4961e36d7 100644 --- a/test/README.md +++ b/test/README.md @@ -386,6 +386,20 @@ A well-structured test suite follows this pattern: 4. **Lifecycle tests** - state persistence, startup from blank, restart recovery 5. **Summary test** (optional) - emits a scenario table into the log for quick CI review +## Not a Unity suite: `bin/test-config-check.sh` + +Portduino YAML validation is tested by driving a built `meshtasticd` rather than by a +Unity suite, because what it asserts - the exit status and printed report of +`meshtasticd --check`, and the fact that a normal run still refuses a bad config - are +properties of the process, not of a linkable function. Fixtures live in +`test/fixtures/portduino-config/` (see the README there); CI runs it in +`test_native.yml`. It is not counted in `native-suite-count`, which only tracks `test_*` +directories. + +```bash +pio run -e native && ./bin/test-config-check.sh +``` + ## Existing Test Suites | Suite | Module Under Test | diff --git a/test/fixtures/portduino-config/README.md b/test/fixtures/portduino-config/README.md new file mode 100644 index 000000000..3768f55b4 --- /dev/null +++ b/test/fixtures/portduino-config/README.md @@ -0,0 +1,158 @@ +# Portduino config fixtures + +Input files for `bin/test-config-check.sh`, which drives a built `meshtasticd` +binary and asserts what `meshtasticd --check` reports about each one. Every file +here is referenced by name from that script, so renaming one means editing it too. + +The theme is configuration that is accepted by the YAML parser but does not mean +what it looks like it means - the failures that otherwise only show up as a radio +that never transmits. + +Each file carries a comment header naming its planted fault and what the checker is +expected to say about it, so a fixture can be read on its own. One assertion, +`malformed-indent.yaml`'s reported line number, counts those header lines - editing +that file's comments means updating the expected line in the script. + +These files are exempt from `trunk fmt` (see `.trunk/trunk.yaml`): prettier rejects +the duplicate keys and bad indentation that are the entire point of them. + +## Valid, one per radio module family + +These must each report zero errors and zero warnings, and must resolve to the +module named in the file. A silent fallback to `sim` would otherwise pass. + +| File | Module | Family | +| -------------------- | -------- | ---------- | +| `module-rf95.yaml` | `RF95` | SX127x | +| `module-sx1262.yaml` | `sx1262` | SX126x | +| `module-sx1268.yaml` | `sx1268` | SX126x | +| `module-llcc68.yaml` | `LLCC68` | SX126x | +| `module-sx1280.yaml` | `sx1280` | SX128x | +| `module-lr1110.yaml` | `lr1110` | LR11xx | +| `module-lr1120.yaml` | `lr1120` | LR11xx | +| `module-lr1121.yaml` | `lr1121` | LR11xx | +| `module-sim.yaml` | `sim` | simulated | +| `module-auto.yaml` | `auto` | autodetect | + +`valid.yaml` is a minimal SX126x config, and `empty-sections.yaml` (`Lora:` with +no body) exists to prove the checker does not invent a finding for it. + +## Module naming + +Names are matched exactly and are not consistently cased - `RF95` and `LLCC68` +are upper, `sx1262` and `lr1121` lower. + +| File | Expected | +| ------------------------ | --------------------------------------------------- | +| `module-unknown.yaml` | `sx1263` is refused, and the valid set is listed. | +| `module-wrong-case.yaml` | `llcc68` is refused with a "did you mean" for case. | + +## LR11xx rfswitch table + +| File | Expected | +| ------------------------------ | ----------------------------------------------------------------- | +| `rfswitch-valid.yaml` | A full seven-mode table on an `lr1121` is clean. | +| `rfswitch-partial.yaml` | Legal, but the omitted modes are named - they are driven all-LOW. | +| `rfswitch-bad-pin.yaml` | `DIO9` is not one of DIO5/6/7/8/10. | +| `rfswitch-row-length.yaml` | Rows shorter and longer than the declared pin count. | +| `rfswitch-bad-level.yaml` | `high` and `On`: anything not exactly `HIGH` is silently LOW. | +| `rfswitch-no-pins.yaml` | No `pins` list, so no switch pin is ever driven. | +| `rfswitch-too-many-pins.yaml` | Six pins declared; only the first five are read. | +| `rfswitch-not-a-map.yaml` | `rfswitch_table` given a scalar. | +| `rfswitch-unknown-mode.yaml` | `MODE_TRANSMIT` is not a mode. | +| `rfswitch-stranded-modes.yaml` | A `MODE_` row one level out, sitting under `Lora:` doing nothing. | + +`rfswitch-partial.yaml` is legal but noted: the omitted modes are driven all-LOW. +`module-mismatch-lr11xx.yaml` (LR11xx with no table - cannot transmit) and +`module-mismatch-sx126x.yaml` (a table on a radio that never applies one) cover +the module/table disagreement in both directions. + +## PA gain table (`TX_GAIN_LORA`) + +Two shapes are accepted and they fail differently. A list is read element-by-element +with `.as()` and NO fallback, so one bad entry throws and meshtasticd will not +start. A bare scalar is read as `.as(0)` and merely falls back to 0. The table +is `uint16_t[22]`, so extra points are dropped and out-of-range values wrap. + +| File | Expected | +| ---------------------------- | ------------------------------------------------------------------------- | +| `txgain-scalar.yaml` | **Clean, and a regression guard** - an earlier checker called this fatal. | +| `value-type-fatal-list.yaml` | A non-numeric list entry: throws, so meshtasticd will not start. | +| `txgain-out-of-range.yaml` | `-5` and `70000` wrap to a different gain than written. | +| `txgain-too-many.yaml` | 25 points; everything past the 22nd is dropped. | + +## Value types, ranges and units + +| File | Expected | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `value-type-fatal.yaml` | `Logging.AsciiLogs` is the other no-fallback read: a bad value stops meshtasticd starting. | +| `value-type-silent.yaml` | Wrong-typed values where the read has a default: silently replaced, so the setting does nothing. | +| `tcxo-millivolts.yaml` | `DIO3_TCXO_VOLTAGE` is in VOLTS and multiplied by 1000, so `1800` silently asks for 1800V. Write `1.8`. | +| `port-out-of-range.yaml` | `APIPort` outside 1024-65535 is silently ignored; `Webserver.Port` has no guard at all. | +| `statusmessage-long.yaml` | Copied into a `char[80]`, so it is safe but silently shortened to 79 characters. | +| `configdir-missing.yaml` | **Crash regression guard** - an unreadable `ConfigDirectory` used to abort meshtasticd (and `--check`) with SIGABRT via an uncaught `filesystem_error`. | + +## MAC address + +The MAC no longer determines NodeNum - that comes from the public key - but a MAC +that fails to apply still falls through to the BlueZ and LoRa-serial fallbacks, and +if those yield nothing meshtasticd exits with "Blank MAC Address not allowed!". + +| File | Expected | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `mac-conflict.yaml` | Both `MACAddress` and `MACAddressSource`; meshtasticd refuses. | +| `mac-malformed.yaml` | `AA:BB:CC` is under 12 hex digits, so it is silently dropped. | +| `mac-source-missing.yaml` | Names an interface with no `/sys/class/net//address`. Warning, not an error: it is machine-dependent and may be checked on another host. | + +## CH341 USB-SPI adapters + +`spidev: ch341` is a different hardware model, not a variant of the same one. The Lora +pins become indexes on the adapter and are driven by the usermode USB driver - +`portduinoSetup()` skips `initGPIOPin()` for every one of them - so nothing is claimed +from a gpiochip. This is also the only shape that works on Windows and macOS, which have +no gpiochip, `gpiodetect` or `gpioinfo` to check anything against. + +| File | Expected | +| --------------------- | ----------------------------------------------------------------------------- | +| `usb-ch341.yaml` | Clean, and the report lists adapter pins rather than resolved gpiochip lines. | +| `ch341-gpiochip.yaml` | A gpiochip and line mapping alongside `ch341`: read, stored, and never used. | + +## Structure + +| File | Expected | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `duplicate-key.yaml` | yaml-cpp keeps the FIRST duplicate, so the later value is lost. | +| `nonmap-section.yaml` | `Lora: invalid` - a known section whose body cannot be read. | +| `unknown-section.yaml` | A top-level section meshtasticd never reads. | +| `stranded-key.yaml` | `spidev` left at the top level instead of inside a section. | +| `top-level-list.yaml` | Document root is a sequence. | +| `empty-file.yaml` | No content: comments only, which parse to a null document. A warning, not an error. | +| `malformed-indent.yaml` | Will not parse; the report must still name the file and line. | +| `pin-unknown-subkey.yaml` | A pin mapping accepts only `pin`, `gpiochip` and `line`. | +| `pin-unreadable.yaml` | A non-numeric pin resolves to -1 and trips an assertion at startup. | +| `hub75-unknown-key.yaml` | An unknown `Display.HUB75` option. On a build without rgbmatrix this also reports the missing HUB75 support, so the test asserts only the unknown key. | + +## Across a config directory + +`configd-conflict/` is a whole tree: a `config.yaml` pointing at a `config.d/` +holding two more `Lora:` sections. It covers the trap that a key not repeated in +the last-loaded file is reset to its default - here `config.yaml` sets +`DIO3_TCXO_VOLTAGE: 1800` and the effective configuration ends up without it. +The load order within `config.d/` comes from the filesystem, so the report warns +rather than assuming alphabetical order. + +`rfswitch-sticky/` covers the one place where "the file loaded last wins" is false, +and it documents a firmware bug rather than a configuration mistake. Its `config.d/` +holds two switch tables; the last one loaded sets `MODE_RX` LOW on both pins, but the +loader only ever writes HIGH and never writes LOW back, so the HIGH from the earlier +file survives and the effective table is the OR of both. Verified with +`meshtasticd --output-yaml`. Until the loader is fixed, `--check` reports this as an +error and tells you to enable exactly one. + +## Running these as a normal boot + +`malformed-indent.yaml`, `nonmap-section.yaml`, `module-unknown.yaml`, +`mac-conflict.yaml` and `hub75-unknown-key.yaml` are also run _without_ `--check`, +where each must be rejected with a non-zero exit. That is the guard on `--check` +mode not having quietly made the normal path permissive. No other fixture is run +that way: a config meshtasticd accepts makes it boot a node and block. diff --git a/test/fixtures/portduino-config/ch341-gpiochip.yaml b/test/fixtures/portduino-config/ch341-gpiochip.yaml new file mode 100644 index 000000000..f647ee714 --- /dev/null +++ b/test/fixtures/portduino-config/ch341-gpiochip.yaml @@ -0,0 +1,16 @@ +# FAULT: a CH341 config that also maps its pins onto a gpiochip. Every Lora pin is +# skipped by the GPIO claim loop when spidev is ch341, so the chip and line numbers below +# are read, stored, and never used - while reading as though they were authoritative. +Lora: + Module: sx1262 + spidev: ch341 + gpiochip: 4 + CS: + pin: 0 + gpiochip: 4 + line: 21 + IRQ: 6 + Reset: 2 + Busy: 4 + USB_PID: 0x5512 + USB_VID: 0x1A86 diff --git a/test/fixtures/portduino-config/configd-conflict/config.d/lora-a.yaml b/test/fixtures/portduino-config/configd-conflict/config.d/lora-a.yaml new file mode 100644 index 000000000..d0e548ae4 --- /dev/null +++ b/test/fixtures/portduino-config/configd-conflict/config.d/lora-a.yaml @@ -0,0 +1,6 @@ +# Second of three files. Re-opens "Lora:", which is what resets the keys set in +# ../config.yaml that are not repeated here. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 diff --git a/test/fixtures/portduino-config/configd-conflict/config.d/lora-b.yaml b/test/fixtures/portduino-config/configd-conflict/config.d/lora-b.yaml new file mode 100644 index 000000000..0377d2a83 --- /dev/null +++ b/test/fixtures/portduino-config/configd-conflict/config.d/lora-b.yaml @@ -0,0 +1,8 @@ +# Third of three files, and the one that wins -- but only because the filesystem +# happens to return it last. config.d/ load order is not guaranteed to be +# alphabetical and can differ between machines, which is why the report warns +# rather than assuming an order. +Lora: + Module: lr1121 + CS: 99 + IRQ: 16 diff --git a/test/fixtures/portduino-config/configd-conflict/config.yaml b/test/fixtures/portduino-config/configd-conflict/config.yaml new file mode 100644 index 000000000..b5a8b91d3 --- /dev/null +++ b/test/fixtures/portduino-config/configd-conflict/config.yaml @@ -0,0 +1,12 @@ +# The primary config of a three-file set (see config.d/ alongside it). +# FAULT, and it is invisible in this file alone: two more files in config.d/ each open +# their own "Lora:" section, and the keys below that they do not repeat -- notably +# DIO3_TCXO_VOLTAGE -- are reset to their defaults when the last of them is loaded. +# The effective configuration ends up with no TCXO voltage at all. +General: + ConfigDirectory: config.d/ +Lora: + Module: sx1262 + spidev: spidev0.0 + DIO3_TCXO_VOLTAGE: 1800 + CS: 21 diff --git a/test/fixtures/portduino-config/configdir-missing.yaml b/test/fixtures/portduino-config/configdir-missing.yaml new file mode 100644 index 000000000..17eaaaf22 --- /dev/null +++ b/test/fixtures/portduino-config/configdir-missing.yaml @@ -0,0 +1,8 @@ +# FAULT, and it used to be a hard crash: a ConfigDirectory that cannot be read made +# std::filesystem::directory_iterator throw an uncaught filesystem_error, aborting +# meshtasticd with SIGABRT -- and aborting --check along with it, so the one tool meant +# to diagnose the problem died on it. It now fails cleanly and is reported here. +Lora: + Module: sx1262 +General: + ConfigDirectory: /nonexistent/does-not-exist/ diff --git a/test/fixtures/portduino-config/duplicate-key.yaml b/test/fixtures/portduino-config/duplicate-key.yaml new file mode 100644 index 000000000..ec5bfbfe4 --- /dev/null +++ b/test/fixtures/portduino-config/duplicate-key.yaml @@ -0,0 +1,8 @@ +# FAULT: Lora.CS is defined twice. YAML forbids this but yaml-cpp does not complain: +# it keeps the FIRST occurrence and silently discards the rest, so the CS: 22 below +# looks applied and is not. Expect an ERROR naming both lines. +Lora: + Module: sx1262 + CS: 21 + CS: 22 + IRQ: 16 diff --git a/test/fixtures/portduino-config/empty-file.yaml b/test/fixtures/portduino-config/empty-file.yaml new file mode 100644 index 000000000..158da87e2 --- /dev/null +++ b/test/fixtures/portduino-config/empty-file.yaml @@ -0,0 +1,2 @@ +# FAULT: a zero-content file. Only comments here, which yaml-cpp parses to a null +# document -- the same as truly empty. Expect a WARNING, not an ERROR. diff --git a/test/fixtures/portduino-config/empty-sections.yaml b/test/fixtures/portduino-config/empty-sections.yaml new file mode 100644 index 000000000..fb374ce70 --- /dev/null +++ b/test/fixtures/portduino-config/empty-sections.yaml @@ -0,0 +1,5 @@ +# CLEAN, and deliberately so. A section with no body is a null node, not a map. +# loadConfig() tests the section node before reading it, so this is harmless -- the +# checker must not report it. Regression guard for the null-vs-non-map distinction. +Lora: +Display: diff --git a/test/fixtures/portduino-config/hub75-unknown-key.yaml b/test/fixtures/portduino-config/hub75-unknown-key.yaml new file mode 100644 index 000000000..fc5e9e474 --- /dev/null +++ b/test/fixtures/portduino-config/hub75-unknown-key.yaml @@ -0,0 +1,11 @@ +# FAULT: Colours is not a HUB75 option (the real ones are listed in ConfigCheck.cpp). +# On a meshtasticd built without rgbmatrix this also reports the missing HUB75 +# support, so the test asserts only the unknown key -- the second finding depends on +# how the binary was built. +Lora: + Module: sx1262 +Display: + Panel: HUB75 + HUB75: + Rows: 64 + Colours: 3 diff --git a/test/fixtures/portduino-config/mac-conflict.yaml b/test/fixtures/portduino-config/mac-conflict.yaml new file mode 100644 index 000000000..ed4431cb2 --- /dev/null +++ b/test/fixtures/portduino-config/mac-conflict.yaml @@ -0,0 +1,9 @@ +# FAULT: MACAddress and MACAddressSource are mutually exclusive; meshtasticd exits +# rather than pick one. That exit is inside loadConfig(), so --check has to report it +# without letting the exit kill the report. Also run without --check, where +# meshtasticd must refuse to start. +General: + MACAddress: AA:BB:CC:DD:EE:FF + MACAddressSource: eth0 +Lora: + Module: sx1262 diff --git a/test/fixtures/portduino-config/mac-malformed.yaml b/test/fixtures/portduino-config/mac-malformed.yaml new file mode 100644 index 000000000..3311fa7c0 --- /dev/null +++ b/test/fixtures/portduino-config/mac-malformed.yaml @@ -0,0 +1,9 @@ +# FAULT: AA:BB:CC is three octets, not six. loadConfig() strips the colons and then +# requires more than 11 characters, so this is dropped without a word and the MAC +# comes from the Bluetooth adapter or the LoRa serial instead. NodeNum is unaffected +# (that comes from the public key), but if no fallback yields a MAC, meshtasticd +# exits with "Blank MAC Address not allowed!". +General: + MACAddress: AA:BB:CC +Lora: + Module: sx1262 diff --git a/test/fixtures/portduino-config/mac-source-missing.yaml b/test/fixtures/portduino-config/mac-source-missing.yaml new file mode 100644 index 000000000..e7fc527c7 --- /dev/null +++ b/test/fixtures/portduino-config/mac-source-missing.yaml @@ -0,0 +1,7 @@ +# FAULT: names a network interface that does not exist, so /sys/class/net//address +# reads back empty and no MAC is set. A WARNING rather than an ERROR: which interfaces +# exist is a property of the machine, and a config is often checked on another host. +General: + MACAddressSource: nosuchiface99 +Lora: + Module: sx1262 diff --git a/test/fixtures/portduino-config/malformed-indent.yaml b/test/fixtures/portduino-config/malformed-indent.yaml new file mode 100644 index 000000000..03a1198d0 --- /dev/null +++ b/test/fixtures/portduino-config/malformed-indent.yaml @@ -0,0 +1,8 @@ +# FAULT: three-space indent on the CS line below, so the file will not parse at all. +# The point of this fixture is that --check still reaches its report and names the +# file and line, rather than exiting early with a bare "Unable to use ...". +# Also run without --check, where meshtasticd must refuse to start. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 diff --git a/test/fixtures/portduino-config/module-auto.yaml b/test/fixtures/portduino-config/module-auto.yaml new file mode 100644 index 000000000..eb5f51f1b --- /dev/null +++ b/test/fixtures/portduino-config/module-auto.yaml @@ -0,0 +1,3 @@ +# CLEAN. Autodetect: the module is probed at startup, so no pins are required here. +Lora: + Module: auto diff --git a/test/fixtures/portduino-config/module-llcc68.yaml b/test/fixtures/portduino-config/module-llcc68.yaml new file mode 100644 index 000000000..38dc23288 --- /dev/null +++ b/test/fixtures/portduino-config/module-llcc68.yaml @@ -0,0 +1,9 @@ +# CLEAN. Valid SX126x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: LLCC68 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 diff --git a/test/fixtures/portduino-config/module-lr1110.yaml b/test/fixtures/portduino-config/module-lr1110.yaml new file mode 100644 index 000000000..e3f5233fc --- /dev/null +++ b/test/fixtures/portduino-config/module-lr1110.yaml @@ -0,0 +1,18 @@ +# CLEAN. Valid LR11xx config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: lr1110 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8] + MODE_STBY: [LOW, LOW, LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] + MODE_TX: [HIGH, HIGH, LOW, LOW] + MODE_TX_HP: [LOW, HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH, LOW] + MODE_WIFI: [LOW, LOW, LOW, HIGH] diff --git a/test/fixtures/portduino-config/module-lr1120.yaml b/test/fixtures/portduino-config/module-lr1120.yaml new file mode 100644 index 000000000..de6261184 --- /dev/null +++ b/test/fixtures/portduino-config/module-lr1120.yaml @@ -0,0 +1,18 @@ +# CLEAN. Valid LR11xx config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: lr1120 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8] + MODE_STBY: [LOW, LOW, LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] + MODE_TX: [HIGH, HIGH, LOW, LOW] + MODE_TX_HP: [LOW, HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH, LOW] + MODE_WIFI: [LOW, LOW, LOW, HIGH] diff --git a/test/fixtures/portduino-config/module-lr1121.yaml b/test/fixtures/portduino-config/module-lr1121.yaml new file mode 100644 index 000000000..5beed4b59 --- /dev/null +++ b/test/fixtures/portduino-config/module-lr1121.yaml @@ -0,0 +1,18 @@ +# CLEAN. Valid LR11xx config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: lr1121 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8] + MODE_STBY: [LOW, LOW, LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] + MODE_TX: [HIGH, HIGH, LOW, LOW] + MODE_TX_HP: [LOW, HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH, LOW] + MODE_WIFI: [LOW, LOW, LOW, HIGH] diff --git a/test/fixtures/portduino-config/module-mismatch-lr11xx.yaml b/test/fixtures/portduino-config/module-mismatch-lr11xx.yaml new file mode 100644 index 000000000..f2486297d --- /dev/null +++ b/test/fixtures/portduino-config/module-mismatch-lr11xx.yaml @@ -0,0 +1,7 @@ +# FAULT: an LR11xx with no rfswitch_table. setRfSwitchTable() is never called, and +# most LR11xx modules cannot transmit or receive without one. Expect a WARNING from +# the merged-configuration checks rather than from any single file. +Lora: + Module: lr1121 + CS: 21 + IRQ: 16 diff --git a/test/fixtures/portduino-config/module-mismatch-sx126x.yaml b/test/fixtures/portduino-config/module-mismatch-sx126x.yaml new file mode 100644 index 000000000..881afa827 --- /dev/null +++ b/test/fixtures/portduino-config/module-mismatch-sx126x.yaml @@ -0,0 +1,13 @@ +# FAULT: the opposite direction -- a switch table on a radio that never applies one. +# The table is only handed to LR11xx parts, so this one is silently inert. +Lora: + Module: sx1262 + rfswitch_table: + pins: [DIO5] + MODE_STBY: [LOW] + MODE_RX: [HIGH] + MODE_TX: [HIGH] + MODE_TX_HP: [HIGH] + MODE_TX_HF: [LOW] + MODE_GNSS: [LOW] + MODE_WIFI: [LOW] diff --git a/test/fixtures/portduino-config/module-rf95.yaml b/test/fixtures/portduino-config/module-rf95.yaml new file mode 100644 index 000000000..483ae7bc4 --- /dev/null +++ b/test/fixtures/portduino-config/module-rf95.yaml @@ -0,0 +1,10 @@ +# CLEAN. Valid SX127x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: RF95 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + RF95_MAX_POWER: 20 diff --git a/test/fixtures/portduino-config/module-sim.yaml b/test/fixtures/portduino-config/module-sim.yaml new file mode 100644 index 000000000..f4bc6be65 --- /dev/null +++ b/test/fixtures/portduino-config/module-sim.yaml @@ -0,0 +1,3 @@ +# CLEAN. The simulated radio takes no pins or SPI device at all. +Lora: + Module: sim diff --git a/test/fixtures/portduino-config/module-sx1262.yaml b/test/fixtures/portduino-config/module-sx1262.yaml new file mode 100644 index 000000000..9023f41a3 --- /dev/null +++ b/test/fixtures/portduino-config/module-sx1262.yaml @@ -0,0 +1,12 @@ +# CLEAN. Valid SX126x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: sx1262 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + DIO2_AS_RF_SWITCH: true + DIO3_TCXO_VOLTAGE: 1.8 + SX126X_MAX_POWER: 22 diff --git a/test/fixtures/portduino-config/module-sx1268.yaml b/test/fixtures/portduino-config/module-sx1268.yaml new file mode 100644 index 000000000..e4cb1651f --- /dev/null +++ b/test/fixtures/portduino-config/module-sx1268.yaml @@ -0,0 +1,9 @@ +# CLEAN. Valid SX126x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: sx1268 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 diff --git a/test/fixtures/portduino-config/module-sx1280.yaml b/test/fixtures/portduino-config/module-sx1280.yaml new file mode 100644 index 000000000..21d244a82 --- /dev/null +++ b/test/fixtures/portduino-config/module-sx1280.yaml @@ -0,0 +1,10 @@ +# CLEAN. Valid SX128x config -- must report no findings AND must resolve to this +# module in the effective configuration, so a silent fallback to sim cannot pass. +Lora: + Module: sx1280 + spidev: spidev0.0 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + SX128X_MAX_POWER: 13 diff --git a/test/fixtures/portduino-config/module-unknown.yaml b/test/fixtures/portduino-config/module-unknown.yaml new file mode 100644 index 000000000..b2026bc92 --- /dev/null +++ b/test/fixtures/portduino-config/module-unknown.yaml @@ -0,0 +1,4 @@ +# FAULT: sx1263 is not a module meshtasticd knows. loadConfig() exits on an unknown +# name, so --check has to report it instead and list the valid set. +Lora: + Module: sx1263 diff --git a/test/fixtures/portduino-config/module-wrong-case.yaml b/test/fixtures/portduino-config/module-wrong-case.yaml new file mode 100644 index 000000000..84b9bbafa --- /dev/null +++ b/test/fixtures/portduino-config/module-wrong-case.yaml @@ -0,0 +1,4 @@ +# FAULT: module names are matched EXACTLY and are not consistently cased -- the +# accepted spelling is LLCC68, not llcc68. Expect an ERROR with a "did you mean". +Lora: + Module: llcc68 diff --git a/test/fixtures/portduino-config/nonmap-section.yaml b/test/fixtures/portduino-config/nonmap-section.yaml new file mode 100644 index 000000000..aead4153d --- /dev/null +++ b/test/fixtures/portduino-config/nonmap-section.yaml @@ -0,0 +1,4 @@ +# FAULT: a known section whose body is a scalar. loadConfig() throws when it indexes +# into it ("operator[] call on a scalar"), so nothing in the file is read. Expect an +# ERROR. Also run without --check, where meshtasticd must refuse to start. +Lora: invalid diff --git a/test/fixtures/portduino-config/pin-unknown-subkey.yaml b/test/fixtures/portduino-config/pin-unknown-subkey.yaml new file mode 100644 index 000000000..1c5ffae96 --- /dev/null +++ b/test/fixtures/portduino-config/pin-unknown-subkey.yaml @@ -0,0 +1,8 @@ +# FAULT: chipline is not a pin-mapping sub-key -- only pin, gpiochip and line are +# read. The typo means the line number is never applied. Expect an ERROR. +Lora: + Module: sx1262 + CS: + pin: 21 + gpiochip: 0 + chipline: 4 diff --git a/test/fixtures/portduino-config/pin-unreadable.yaml b/test/fixtures/portduino-config/pin-unreadable.yaml new file mode 100644 index 000000000..4d87da5e2 --- /dev/null +++ b/test/fixtures/portduino-config/pin-unreadable.yaml @@ -0,0 +1,7 @@ +# FAULT: a pin value that is not a number. It is still marked enabled but resolves to +# -1 (RADIOLIB_NC), and initGPIOPin() then trips an assertion inside LinuxGPIOPin at +# startup. Expect an ERROR -- catching it here is the difference between a report and +# a stack trace from a library file. +Lora: + Module: sx1262 + CS: not-a-number diff --git a/test/fixtures/portduino-config/port-out-of-range.yaml b/test/fixtures/portduino-config/port-out-of-range.yaml new file mode 100644 index 000000000..db6914229 --- /dev/null +++ b/test/fixtures/portduino-config/port-out-of-range.yaml @@ -0,0 +1,9 @@ +# FAULT: loadConfig() only adopts APIPort when it is inside 1024-65535 and otherwise +# keeps the default without a word, so this is silently ignored. Webserver.Port has no +# such guard at all, so an unusable port there is an error rather than a warning. +Lora: + Module: sx1262 +General: + APIPort: 80 +Webserver: + Port: 99999 diff --git a/test/fixtures/portduino-config/rfswitch-bad-level.yaml b/test/fixtures/portduino-config/rfswitch-bad-level.yaml new file mode 100644 index 000000000..efcab98fa --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-bad-level.yaml @@ -0,0 +1,9 @@ +# FAULT: anything that is not the exact string "HIGH" is treated as LOW, so the +# lowercase "high" and the "On" below are silently switches that never close -- the +# quietest way to end up with a radio that cannot transmit. Expect an ERROR each. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6] + MODE_STBY: [LOW, LOW] + MODE_RX: [high, On] diff --git a/test/fixtures/portduino-config/rfswitch-bad-pin.yaml b/test/fixtures/portduino-config/rfswitch-bad-pin.yaml new file mode 100644 index 000000000..110bbebe4 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-bad-pin.yaml @@ -0,0 +1,6 @@ +# FAULT: DIO9 is not a switch pin. Only DIO5, DIO6, DIO7, DIO8 and DIO10 exist. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO9, DIO7] + MODE_STBY: [LOW, LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-no-pins.yaml b/test/fixtures/portduino-config/rfswitch-no-pins.yaml new file mode 100644 index 000000000..fd6805ac9 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-no-pins.yaml @@ -0,0 +1,6 @@ +# FAULT: no pins list, so no switch pin is ever driven and the mode rows below have +# nothing to apply to. +Lora: + Module: lr1121 + rfswitch_table: + MODE_STBY: [LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-not-a-map.yaml b/test/fixtures/portduino-config/rfswitch-not-a-map.yaml new file mode 100644 index 000000000..c0bb8c454 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-not-a-map.yaml @@ -0,0 +1,4 @@ +# FAULT: rfswitch_table given a scalar instead of a mapping. +Lora: + Module: lr1121 + rfswitch_table: DIO5 diff --git a/test/fixtures/portduino-config/rfswitch-partial.yaml b/test/fixtures/portduino-config/rfswitch-partial.yaml new file mode 100644 index 000000000..244410bc7 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-partial.yaml @@ -0,0 +1,12 @@ +# CLEAN but NOTED. A partial table is legal. The modes left out (MODE_TX_HP, +# MODE_TX_HF, MODE_GNSS, MODE_WIFI) are driven all-LOW, which on most modules is the +# shutdown state -- so the report names them rather than leaving it to be found on air. +Lora: + Module: lr1121 + CS: 21 + IRQ: 16 + rfswitch_table: + pins: [DIO5, DIO6] + MODE_STBY: [LOW, LOW] + MODE_RX: [HIGH, LOW] + MODE_TX: [HIGH, HIGH] diff --git a/test/fixtures/portduino-config/rfswitch-row-length.yaml b/test/fixtures/portduino-config/rfswitch-row-length.yaml new file mode 100644 index 000000000..a56c1007b --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-row-length.yaml @@ -0,0 +1,8 @@ +# FAULT: three pins are declared, but MODE_STBY gives two values and MODE_RX gives +# four. Expect one ERROR per mismatched row. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6, DIO7] + MODE_STBY: [LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-sticky/config.d/a-first.yaml b/test/fixtures/portduino-config/rfswitch-sticky/config.d/a-first.yaml new file mode 100644 index 000000000..2c60dfecc --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-sticky/config.d/a-first.yaml @@ -0,0 +1,6 @@ +# Loaded first. Sets MODE_RX HIGH on both pins. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6] + MODE_RX: [HIGH, HIGH] diff --git a/test/fixtures/portduino-config/rfswitch-sticky/config.d/b-second.yaml b/test/fixtures/portduino-config/rfswitch-sticky/config.d/b-second.yaml new file mode 100644 index 000000000..1832c7625 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-sticky/config.d/b-second.yaml @@ -0,0 +1,9 @@ +# FAULT, and it is a firmware bug rather than a typo. This file is loaded LAST and says +# MODE_RX is LOW on both pins, but the loader only ever writes HIGH and never writes +# LOW back, so the HIGH from a-first.yaml survives. The effective table is the OR of +# both files -- a switch state that neither file asked for. Verified with --output-yaml. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6] + MODE_RX: [LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-sticky/config.yaml b/test/fixtures/portduino-config/rfswitch-sticky/config.yaml new file mode 100644 index 000000000..ccc3ab988 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-sticky/config.yaml @@ -0,0 +1,4 @@ +# Primary config for the sticky-switch-table case. The two files in config.d/ each +# define a Lora.rfswitch_table. +General: + ConfigDirectory: config.d/ diff --git a/test/fixtures/portduino-config/rfswitch-stranded-modes.yaml b/test/fixtures/portduino-config/rfswitch-stranded-modes.yaml new file mode 100644 index 000000000..dcfeeb898 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-stranded-modes.yaml @@ -0,0 +1,9 @@ +# FAULT: MODE_RX below is indented one level short, so it sits under Lora: rather than +# inside rfswitch_table and does nothing at all. Expect a WARNING that says it is a +# valid key of Lora.rfswitch_table -- the hint is what makes this findable. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5] + MODE_STBY: [LOW] + MODE_RX: [HIGH] diff --git a/test/fixtures/portduino-config/rfswitch-too-many-pins.yaml b/test/fixtures/portduino-config/rfswitch-too-many-pins.yaml new file mode 100644 index 000000000..9a69e1977 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-too-many-pins.yaml @@ -0,0 +1,7 @@ +# FAULT: six pins declared but only the first five are read (rfswitch_dio_pins[5]), +# so the last one is silently dropped. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8, DIO10, DIO5] + MODE_STBY: [LOW, LOW, LOW, LOW, LOW, LOW] diff --git a/test/fixtures/portduino-config/rfswitch-unknown-mode.yaml b/test/fixtures/portduino-config/rfswitch-unknown-mode.yaml new file mode 100644 index 000000000..5bb603704 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-unknown-mode.yaml @@ -0,0 +1,8 @@ +# FAULT: MODE_TRANSMIT is not a mode. The real ones are MODE_STBY, MODE_RX, MODE_TX, +# MODE_TX_HP, MODE_TX_HF, MODE_GNSS and MODE_WIFI. +Lora: + Module: lr1121 + rfswitch_table: + pins: [DIO5] + MODE_STBY: [LOW] + MODE_TRANSMIT: [HIGH] diff --git a/test/fixtures/portduino-config/rfswitch-valid.yaml b/test/fixtures/portduino-config/rfswitch-valid.yaml new file mode 100644 index 000000000..fad8c34f0 --- /dev/null +++ b/test/fixtures/portduino-config/rfswitch-valid.yaml @@ -0,0 +1,17 @@ +# CLEAN. A full seven-mode table on an LR11xx: every mode present, every row the same +# length as the pins list, every value exactly HIGH or LOW. +Lora: + Module: lr1121 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + rfswitch_table: + pins: [DIO5, DIO6, DIO7, DIO8] + MODE_STBY: [LOW, LOW, LOW, LOW] + MODE_RX: [HIGH, LOW, LOW, LOW] + MODE_TX: [HIGH, HIGH, LOW, LOW] + MODE_TX_HP: [LOW, HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH, LOW] + MODE_WIFI: [LOW, LOW, LOW, HIGH] diff --git a/test/fixtures/portduino-config/statusmessage-long.yaml b/test/fixtures/portduino-config/statusmessage-long.yaml new file mode 100644 index 000000000..8517c6ceb --- /dev/null +++ b/test/fixtures/portduino-config/statusmessage-long.yaml @@ -0,0 +1,7 @@ +# FAULT: StatusMessage is copied into a char[80] and hard null-terminated, so it is +# safe but silently shortened to 79 characters. The operator never sees the message +# they configured. Expect a WARNING. +Lora: + Module: sx1262 +Config: + StatusMessage: "This status message is comfortably longer than the eighty character buffer it is copied into, so it gets cut off" diff --git a/test/fixtures/portduino-config/stranded-key.yaml b/test/fixtures/portduino-config/stranded-key.yaml new file mode 100644 index 000000000..4f159ee80 --- /dev/null +++ b/test/fixtures/portduino-config/stranded-key.yaml @@ -0,0 +1,6 @@ +# FAULT: spidev left at the top level instead of inside a section, where it does +# nothing. Expect an ERROR that names the sections it IS valid in, so the fix is to +# indent it one level. +Lora: + Module: sx1262 +spidev: /dev/spidev0.0 diff --git a/test/fixtures/portduino-config/tcxo-millivolts.yaml b/test/fixtures/portduino-config/tcxo-millivolts.yaml new file mode 100644 index 000000000..3442bb911 --- /dev/null +++ b/test/fixtures/portduino-config/tcxo-millivolts.yaml @@ -0,0 +1,9 @@ +# FAULT: DIO3_TCXO_VOLTAGE is in VOLTS and is multiplied by 1000, but every other +# Meshtastic surface discusses this setting in millivolts -- so "1800" is the natural +# thing to write and silently asks for 1800V. Nothing downstream range-checks it. +# The correct spelling for a 1.8V part is 1.8, or just true. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + DIO3_TCXO_VOLTAGE: 1800 diff --git a/test/fixtures/portduino-config/top-level-list.yaml b/test/fixtures/portduino-config/top-level-list.yaml new file mode 100644 index 000000000..1007d78d1 --- /dev/null +++ b/test/fixtures/portduino-config/top-level-list.yaml @@ -0,0 +1,3 @@ +# FAULT: the document root is a sequence, not a mapping, so nothing in it is read. +- Lora +- General diff --git a/test/fixtures/portduino-config/txgain-out-of-range.yaml b/test/fixtures/portduino-config/txgain-out-of-range.yaml new file mode 100644 index 000000000..ece546d91 --- /dev/null +++ b/test/fixtures/portduino-config/txgain-out-of-range.yaml @@ -0,0 +1,5 @@ +# FAULT: the gain table is uint16_t[22], so a negative or >65535 entry silently wraps +# to a completely different gain rather than being rejected. Expect an ERROR each. +Lora: + Module: sx1262 + TX_GAIN_LORA: [0, -5, 70000] diff --git a/test/fixtures/portduino-config/txgain-scalar.yaml b/test/fixtures/portduino-config/txgain-scalar.yaml new file mode 100644 index 000000000..93050386d --- /dev/null +++ b/test/fixtures/portduino-config/txgain-scalar.yaml @@ -0,0 +1,8 @@ +# CLEAN, and a regression guard. TX_GAIN_LORA accepts a bare scalar as well as a list: +# loadConfig() has an else branch reading .as(0). An earlier version of the checker +# treated the list shape as the only legal one and reported this working config as a +# fatal error -- a false positive is worse than a missing check, so this file must stay +# clean. +Lora: + Module: sx1262 + TX_GAIN_LORA: 5 diff --git a/test/fixtures/portduino-config/txgain-too-many.yaml b/test/fixtures/portduino-config/txgain-too-many.yaml new file mode 100644 index 000000000..4bff9062b --- /dev/null +++ b/test/fixtures/portduino-config/txgain-too-many.yaml @@ -0,0 +1,5 @@ +# FAULT: only the first 22 PA points are stored (num_pa_points is min(size, 22)), so +# everything past the 22nd is silently dropped. +Lora: + Module: sx1262 + TX_GAIN_LORA: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] diff --git a/test/fixtures/portduino-config/unknown-key.yaml b/test/fixtures/portduino-config/unknown-key.yaml new file mode 100644 index 000000000..1ab2cc7de --- /dev/null +++ b/test/fixtures/portduino-config/unknown-key.yaml @@ -0,0 +1,9 @@ +# FAULT: Lora.Frequency is not a key meshtasticd reads, so setting it does nothing. +# Expect a WARNING and exit 0 -- warnings alone must not fail the run. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 + Frequency: 868.0 diff --git a/test/fixtures/portduino-config/unknown-section.yaml b/test/fixtures/portduino-config/unknown-section.yaml new file mode 100644 index 000000000..7c0cb0154 --- /dev/null +++ b/test/fixtures/portduino-config/unknown-section.yaml @@ -0,0 +1,6 @@ +# FAULT: Telemetry is not a section meshtasticd reads, so the whole block is ignored. +# Expect an ERROR. +Lora: + Module: sx1262 +Telemetry: + Enabled: true diff --git a/test/fixtures/portduino-config/usb-ch341.yaml b/test/fixtures/portduino-config/usb-ch341.yaml new file mode 100644 index 000000000..0c75aecdd --- /dev/null +++ b/test/fixtures/portduino-config/usb-ch341.yaml @@ -0,0 +1,15 @@ +# CLEAN. A CH341 USB-SPI adapter, the meshstick/pinedio shape. The pin numbers here are +# indexes on the adapter, not gpiochip lines: portduinoSetup() skips initGPIOPin() for +# every Lora pin when spidev is ch341 and hands the raw numbers to Ch341Hal. Expect the +# report to say so and to stop citing 'gpiodetect'/'gpioinfo', which do not exist on the +# Windows and macOS hosts where this is the only shape that works. +Lora: + Module: sx1262 + CS: 0 + IRQ: 6 + Reset: 2 + Busy: 4 + spidev: ch341 + DIO3_TCXO_VOLTAGE: true + USB_PID: 0x5512 + USB_VID: 0x1A86 diff --git a/test/fixtures/portduino-config/valid.yaml b/test/fixtures/portduino-config/valid.yaml new file mode 100644 index 000000000..c867d4fbf --- /dev/null +++ b/test/fixtures/portduino-config/valid.yaml @@ -0,0 +1,8 @@ +# CLEAN. Minimal SX126x config, used as the baseline: the checker must not invent a +# finding for it. Also the fixture for --check winning over --output-yaml. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 diff --git a/test/fixtures/portduino-config/value-type-fatal-list.yaml b/test/fixtures/portduino-config/value-type-fatal-list.yaml new file mode 100644 index 000000000..77175004a --- /dev/null +++ b/test/fixtures/portduino-config/value-type-fatal-list.yaml @@ -0,0 +1,5 @@ +# FAULT: the other no-fallback read. Each TX_GAIN_LORA entry is .as() with no +# default, so one non-numeric entry throws and stops meshtasticd starting. +Lora: + Module: sx1262 + TX_GAIN_LORA: [0, high, 3] diff --git a/test/fixtures/portduino-config/value-type-fatal.yaml b/test/fixtures/portduino-config/value-type-fatal.yaml new file mode 100644 index 000000000..89ecb64e2 --- /dev/null +++ b/test/fixtures/portduino-config/value-type-fatal.yaml @@ -0,0 +1,8 @@ +# FAULT: Logging.AsciiLogs is one of only two settings read WITHOUT a fallback +# (.as() with no default), so a value that will not convert throws, loadConfig() +# returns false and meshtasticd refuses to start. Before this check existed --check +# called this file clean while the daemon would not boot on it. +Lora: + Module: sx1262 +Logging: + AsciiLogs: yes-please diff --git a/test/fixtures/portduino-config/value-type-silent.yaml b/test/fixtures/portduino-config/value-type-silent.yaml new file mode 100644 index 000000000..d0739893b --- /dev/null +++ b/test/fixtures/portduino-config/value-type-silent.yaml @@ -0,0 +1,10 @@ +# FAULT: values of the wrong type where loadConfig() reads .as(default). yaml-cpp +# hands back the default on a failed conversion, so meshtasticd starts happily and +# every one of these settings silently does nothing. Expect a WARNING each. +Lora: + Module: sx1262 + spiSpeed: fast + SX126X_MAX_POWER: high + DIO2_AS_RF_SWITCH: maybe +General: + MaxNodes: lots diff --git a/test/native-suite-count b/test/native-suite-count index 87523dd7a..d81cc0710 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -41 +42 diff --git a/test/test_fuzz_config/test_main.cpp b/test/test_fuzz_config/test_main.cpp new file mode 100644 index 000000000..5919ebfe9 --- /dev/null +++ b/test/test_fuzz_config/test_main.cpp @@ -0,0 +1,267 @@ +// Adversarial fuzzing of `meshtasticd --check`, above all DuplicateKeyFinder, whose stack balance +// rests on yaml-cpp emitting matched start/end events. The contract is crash-freedom and +// termination only (what the report *says* is asserted by bin/test-config-check.sh); inputs come +// from a seeded LCG, so a failure reproduces from the printed seed. + +// configuration.h pulls in Arduino.h, whose Common.h declares setup()/loop() inside an extern "C" +// block. Must come BEFORE TestUtil.h, same as the other suites. +#include "configuration.h" + +#include "TestUtil.h" +#include + +#include "ConfigCheck.h" +#include "support/DeterministicRng.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr uint64_t BASE_SEED = 0xC0FFEE01ULL; + +// Where the checked-in fixtures live, relative to the repo root that `pio test` runs from. +static const char *kFixtureDir = "test/fixtures/portduino-config"; + +// Thousands of full reports would bury the CI log, so stdout goes to /dev/null for the suite. +// Restored by duplicated fd: CI has no controlling terminal, so reopening /dev/tty would lose it. +class StdoutSilencer +{ + public: + StdoutSilencer() + { + fflush(stdout); + savedFd = dup(fileno(stdout)); + const int devNull = open("/dev/null", O_WRONLY); + if (devNull >= 0) { + dup2(devNull, fileno(stdout)); + close(devNull); + } + } + ~StdoutSilencer() + { + fflush(stdout); + if (savedFd >= 0) { + dup2(savedFd, fileno(stdout)); + close(savedFd); + } + } + + private: + int savedFd = -1; +}; + +static std::string tempPath(int n) +{ + return "/tmp/meshtastic-fuzz-config-" + std::to_string(n) + ".yaml"; +} + +/// Write `bytes` to a scratch file and hand it to the checker. Returns nothing: the assertion is +/// that we get here at all, without a crash, a hang, or an escaped exception. +static void runOn(const std::string &bytes, int slot) +{ + const std::string path = tempPath(slot); + { + std::ofstream out(path, std::ios::binary); + out.write(bytes.data(), (std::streamsize)bytes.size()); + } + try { + runConfigCheck({path}); + } catch (const std::exception &) { + // An escaped exception is a defect in its own right: portduinoSetup() calls this on the way + // to exit() with nothing above it to catch. + TEST_FAIL_MESSAGE("runConfigCheck() let an exception escape"); + } + std::error_code ec; + std::filesystem::remove(path, ec); +} + +static std::vector loadSeedCorpus() +{ + std::vector corpus; + std::error_code ec; + for (const auto &entry : std::filesystem::recursive_directory_iterator(kFixtureDir, ec)) { + if (!entry.is_regular_file() || entry.path().extension() != ".yaml") + continue; + std::ifstream in(entry.path(), std::ios::binary); + std::ostringstream buf; + buf << in.rdbuf(); + corpus.push_back(buf.str()); + } + return corpus; +} + +// --------------------------------------------------------------------------- +// C1 - the seed corpus itself +// --------------------------------------------------------------------------- + +void test_seed_corpus_survives(void) +{ + StdoutSilencer quiet; + const auto corpus = loadSeedCorpus(); + // A corpus that failed to load would make every later group vacuous, so prove it is there. + TEST_ASSERT_TRUE_MESSAGE(corpus.size() >= 20, "fixture corpus not found - is the test running from the repo root?"); + int slot = 0; + for (const auto &seed : corpus) + runOn(seed, slot++); +} + +// --------------------------------------------------------------------------- +// C2 - byte mutation of the corpus +// --------------------------------------------------------------------------- + +void test_mutated_corpus(void) +{ + StdoutSilencer quiet; + const auto corpus = loadSeedCorpus(); + TEST_ASSERT_TRUE(corpus.size() >= 20); + rngSeed(BASE_SEED + 1); + + for (int i = 0; i < 3000; i++) { + std::string s = corpus[rngRange((uint32_t)corpus.size())]; + if (s.empty()) + continue; + + switch (rngRange(5)) { + case 0: // flip a byte + s[rngRange((uint32_t)s.size())] = (char)rngByte(); + break; + case 1: // truncate + s.resize(rngRange((uint32_t)s.size())); + break; + case 2: // insert a run of one byte + s.insert(rngRange((uint32_t)s.size()), std::string(1 + rngRange(64), (char)rngByte())); + break; + case 3: // splice two fixtures together + s += corpus[rngRange((uint32_t)corpus.size())]; + break; + default: // delete a span + if (s.size() > 1) { + const uint32_t at = rngRange((uint32_t)s.size() - 1); + s.erase(at, 1 + rngRange((uint32_t)(s.size() - at))); + } + break; + } + runOn(s, i % 8); + } +} + +// --------------------------------------------------------------------------- +// C3 - structural torture +// --------------------------------------------------------------------------- +// Shapes chosen to stress the event-stream walker rather than the parser. + +void test_structural_torture(void) +{ + StdoutSilencer quiet; + rngSeed(BASE_SEED + 2); + int slot = 0; + + // Deep nesting, both flow and block style. + for (int depth : {1, 2, 8, 64, 512, 4096}) { + runOn("Lora:\n rfswitch_table: " + std::string(depth, '[') + std::string(depth, ']'), slot++); + std::string block = "Lora:\n"; + for (int i = 0; i < depth; i++) + block += std::string(2 + i * 2, ' ') + "k:\n"; + runOn(block, slot++); + } + + // Duplicate keys at every depth, which is what DuplicateKeyFinder's stack is for. + for (int repeat : {2, 16, 256}) { + std::string dup = "Lora:\n"; + for (int i = 0; i < repeat; i++) + dup += " CS: " + std::to_string(i) + "\n"; + runOn(dup, slot++); + std::string nested = "Lora:\n rfswitch_table:\n"; + for (int i = 0; i < repeat; i++) + nested += " MODE_RX: [HIGH]\n"; + runOn(nested, slot++); + } + + // Anchors and aliases: one node reachable from many paths, including a merge key. + runOn("Lora: &a\n Module: sx1262\nDisplay: *a\n", slot++); + runOn("a: &x [1, 2]\nLora:\n Enable_Pins: *x\n rfswitch_table: *x\n", slot++); + runOn("base: &b {CS: 1}\nLora:\n <<: *b\n IRQ: 2\n", slot++); + + // Collections where a scalar is expected and vice versa, across every known section. + for (const char *section : {"Lora", "General", "Display", "Logging", "Webserver", "Meta", "GPIO"}) { + runOn(std::string(section) + ": [1, 2, 3]\n", slot++); + runOn(std::string(section) + ": ~\n", slot++); + runOn(std::string(section) + ":\n ? [complex, key]\n : value\n", slot++); + } + + // Very long keys and scalars. + runOn("Lora:\n " + std::string(64 * 1024, 'k') + ": 1\n", slot++); + runOn("Lora:\n Module: " + std::string(256 * 1024, 'v') + "\n", slot++); + + // Multiple documents in one file, which HandleNextDocument() loops over. + runOn("Lora:\n CS: 1\n---\nLora:\n CS: 2\n---\n[]\n", slot++); +} + +// --------------------------------------------------------------------------- +// C4 - random bytes (DISABLED) +// --------------------------------------------------------------------------- +// Off for CI cost: half the inputs and half the runtime for the least return, since uniform noise +// is almost always rejected on the first token. Flip to 1 to restore it. +#define FUZZ_CONFIG_RANDOM_BYTES 0 + +#if FUZZ_CONFIG_RANDOM_BYTES +void test_random_bytes(void) +{ + StdoutSilencer quiet; + rngSeed(BASE_SEED + 3); + + for (int i = 0; i < 1500; i++) { + std::string s; + s.resize(rngRange(2048)); + for (auto &c : s) + c = (char)rngByte(); + runOn(s, i % 8); + } + + // Bytes drawn only from YAML's structural alphabet get much deeper into the parser than + // uniform random noise, which is usually rejected on the first token. + static const char kYamlChars[] = " \t\n-:[]{}#&*!|>'\"%@`,?abcLora0123"; + for (int i = 0; i < 1500; i++) { + std::string s; + s.resize(rngRange(2048)); + for (auto &c : s) + c = kYamlChars[rngRange(sizeof(kYamlChars) - 1)]; + runOn(s, i % 8); + } +} +#endif // FUZZ_CONFIG_RANDOM_BYTES + +void setUp(void) {} +void tearDown(void) {} + +// The portduino framework supplies main() and calls setup()/loop(), so the suite's entry point is +// setup() rather than main() -- defining main() here collides with the framework's. +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Group C1: seed corpus ===\n"); + RUN_TEST(test_seed_corpus_survives); + + printf("\n=== Group C2: mutated corpus ===\n"); + RUN_TEST(test_mutated_corpus); + + printf("\n=== Group C3: structural torture ===\n"); + RUN_TEST(test_structural_torture); + +#if FUZZ_CONFIG_RANDOM_BYTES + printf("\n=== Group C4: random bytes ===\n"); + RUN_TEST(test_random_bytes); +#endif + + exit(UNITY_END()); +} + +void loop() {} From fc67590317b39f2640ad832c88e47c89b2c53aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 30 Jul 2026 13:13:11 +0200 Subject: [PATCH 25/30] Lockdown PIN redaction, a build guard, and favorite compaction (#11285) * Redact the pairing PIN from unauthorized lockdown clients * Fail the build when PacketAPI would bypass the lockdown gate * Compact kept favorites when resetting the node database * Make the compaction loop reference const --- src/mesh/NodeDB.cpp | 18 +++++++++++------- src/mesh/PhoneAPI.cpp | 6 ++++++ src/mesh/api/PacketAPI.cpp | 6 ++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index cdd61d15f..3ba46ccf2 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1607,15 +1607,19 @@ void NodeDB::resetNodes(bool keepFavorites) numMeshNodes = 1; if (keepFavorites) { LOG_INFO("Clearing node database - preserving favorites"); - for (size_t i = 0; i < meshNodes->size(); i++) { - meshtastic_NodeInfoLite &node = meshNodes->at(i); - if (i > 0 && !nodeInfoLiteIsFavorite(&node)) { - eraseNodeSatellites(node.num); - node = meshtastic_NodeInfoLite(); - } else { + // Compact favorites into contiguous low slots: zeroing in place leaves one above + // numMeshNodes, invisible to every `i < numMeshNodes` scan yet still serialized to flash. + for (size_t i = 1; i < meshNodes->size(); i++) { + const meshtastic_NodeInfoLite &node = meshNodes->at(i); + if (nodeInfoLiteIsFavorite(&node)) { + if (numMeshNodes != i) + meshNodes->at(numMeshNodes) = node; numMeshNodes += 1; + } else if (node.num) { + eraseNodeSatellites(node.num); } - }; + } + std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite()); } else { LOG_INFO("Clearing node database - removing favorites"); for (size_t i = 1; i < meshNodes->size(); i++) { diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 572b79242..f86d25773 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -767,6 +767,12 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) LOG_DEBUG("Send config: bluetooth"); fromRadioScratch.config.which_payload_variant = meshtastic_Config_bluetooth_tag; fromRadioScratch.config.payload_variant.bluetooth = config.bluetooth; +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL + if (!getAdminAuthorized()) { + // The pairing PIN is a shared secret; never expose it to an unauthenticated client. + fromRadioScratch.config.payload_variant.bluetooth.fixed_pin = 0; + } +#endif break; case meshtastic_Config_security_tag: LOG_DEBUG("Send config: security"); diff --git a/src/mesh/api/PacketAPI.cpp b/src/mesh/api/PacketAPI.cpp index 3f145bfe3..9408e9266 100644 --- a/src/mesh/api/PacketAPI.cpp +++ b/src/mesh/api/PacketAPI.cpp @@ -6,6 +6,12 @@ #include "RadioInterface.h" #include "modules/NodeInfoModule.h" +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL +// receivePacket() dispatches ToRadio straight to MeshService, bypassing handleToRadioPacket and so +// the lockdown admin gate. Fail the build rather than silently ship an admin-auth bypass. +#error "USE_PACKET_API is incompatible with MESHTASTIC_PHONEAPI_ACCESS_CONTROL (PacketAPI bypasses the lockdown admin gate)" +#endif + PacketAPI *packetAPI = nullptr; PacketAPI *PacketAPI::create(PacketServer *_server) From a5940b4c6c26e3aaaaebbc9da912df2aa40fc31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 30 Jul 2026 13:13:37 +0200 Subject: [PATCH 26/30] Guard the deferred local queue and depth counter (#11284) * Guard the deferred local queue and depth counter * Close the drain and enqueue race on the deferred queue * Route the raced loopback through handleReceived * Make the last-frame check and depth decrement atomic * Correct the handleReceived doc comment --- src/mesh/Router.cpp | 69 +++++++++++++++++++++++++++++++++------------ src/mesh/Router.h | 15 ++++++---- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index b60942bcd..38048fddb 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -1249,7 +1249,12 @@ bool Router::dequeueDeferredLocal(DeferredLocal &out) void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src) { // Top level: handle synchronously, exactly as before the depth guard existed. - if (handleDepth == 0) { + bool nested; + { + concurrency::LockGuard g(&deferredLock); + nested = handleDepth > 0; + } + if (!nested) { handleReceived(p, src); return; } @@ -1258,8 +1263,26 @@ void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src) // handleReceived() drains it once the current dispatch unwinds, instead of stacking another // handleReceived() frame on top of the module handler (nRF52 stack overflow on config save). meshtastic_MeshPacket *copy = packetPool.allocCopy(*p); - if (copy && enqueueDeferredLocal(copy, src)) - return; + if (copy) { + // Re-check depth under the lock that also gates the drain's decrement, so a drain finishing + // while we allocated cannot leave this copy stranded in the ring. + bool stillNested = false, queued = false; + { + concurrency::LockGuard g(&deferredLock); + stillNested = handleDepth > 0; + if (stillNested) + queued = enqueueDeferredLocal(copy, src); + } + if (queued) + return; + if (!stillNested) { + // The drain finished first, so nothing would pick this up. Go through handleReceived() + // rather than dispatchReceived() so a loopback from its modules still defers. + handleReceived(copy, src); + packetPool.release(copy); + return; + } + } // Pool exhausted or queue full: drop the deferral. Leak-free and degraded but safe - the // packet still followed its normal non-loopback path (SHOULD_RELEASE, or the TX path for a @@ -1278,29 +1301,39 @@ void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src) */ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) { - handleDepth++; + { + concurrency::LockGuard g(&deferredLock); + handleDepth++; #ifdef PIO_UNIT_TESTING - if (handleDepth > maxHandleDepthObserved) - maxHandleDepthObserved = handleDepth; + if (handleDepth > maxHandleDepthObserved) + maxHandleDepthObserved = handleDepth; #endif + } dispatchReceived(p, src); - // Only the outermost frame drains. Deferred packets were produced by modules sending from - // inside dispatchReceived()'s callModules(); process them here, after the triggering frame has - // unwound, so a second handleReceived() never sits on top of a module handler. handleDepth - // stays >= 1 through the drain, so a drained packet whose own modules send more loopback - // packets enqueues them for this same loop rather than recursing: the stack stays flat and - // processing is breadth-first. - if (handleDepth == 1) { + // Decide "am I the last frame" and drop the depth in one critical section. Splitting them lets + // two frames both read the same pre-decrement value, skip the drain, and strand the ring. + for (;;) { DeferredLocal d; - while (dequeueDeferredLocal(d)) { - dispatchReceived(d.p, d.src); - packetPool.release(d.p); + { + concurrency::LockGuard g(&deferredLock); + if (handleDepth > 1) { + // Another frame is still live and will own the drain once it is last. + handleDepth--; + return; + } + if (!dequeueDeferredLocal(d)) { + // Last frame and nothing queued, so zero is reached only with the ring empty. + handleDepth--; + return; + } } + // Depth stays at 1 across the drain, so a loopback from these modules defers instead of + // recursing, and dispatch runs outside the lock. + dispatchReceived(d.p, d.src); + packetPool.release(d.p); } - - handleDepth--; } void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) diff --git a/src/mesh/Router.h b/src/mesh/Router.h index be686ee15..2a4d979c2 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -7,6 +7,7 @@ #include "PacketHistory.h" #include "PointerQueue.h" #include "RadioInterface.h" +#include "concurrency/LockGuard.h" #include "concurrency/OSThread.h" #include @@ -162,12 +163,8 @@ class Router : protected concurrency::OSThread, protected PacketHistory void perhapsHandleReceived(meshtastic_MeshPacket *p); /** - * Called from perhapsHandleReceived() - allows subclass message delivery behavior. - * Handle any packet that is received by an interface on this node. - * Note: some packets may merely being passed through this node and will be forwarded elsewhere. - * - * Note: this packet will never be called for messages sent/generated by this node. - * Note: this method will free the provided packet. + * Called from perhapsHandleReceived() for radio ingress and from deliverLocal() for our own + * loopback, so p may be locally generated. Does NOT free p; the caller still owns it. */ void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO); @@ -192,6 +189,10 @@ class Router : protected concurrency::OSThread, protected PacketHistory /// so a locally-sent loopback packet must be deferred rather than handled synchronously. uint8_t handleDepth = 0; + /// Guards handleDepth and the deferred ring below. nRF52 drives the router from the BLE task as + /// well as the loop task, so both are read-modify-written from two tasks. + concurrency::Lock deferredLock; + /// A local loopback packet whose handleReceived() was deferred because it was produced from /// inside callModules(). The queue owns the packet; its RxSource travels with it so the drain /// dispatches it with the origin the sender intended (RX_SRC_LOCAL stays local). @@ -210,8 +211,10 @@ class Router : protected concurrency::OSThread, protected PacketHistory uint8_t deferredLocalCount = 0; // entries currently queued /// Queue a deferred local packet. Returns false (and queues nothing) when full. + /// Caller must hold deferredLock: the enqueue decision is atomic with the drain's depth update. bool enqueueDeferredLocal(meshtastic_MeshPacket *p, RxSource src); /// Pop the oldest deferred local packet into out. Returns false when empty. + /// Caller must hold deferredLock. bool dequeueDeferredLocal(DeferredLocal &out); /** Frees the provided packet, and generates a NAK indicating the specifed error while sending */ From 06577e6b36c20eb32d44cc84298bcea9dfb87353 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:33:31 +0100 Subject: [PATCH 27/30] tying up some loose ends (#11292) --- .gitpod.yml | 2 - docs/node_info_stores.md | 126 ++++++++++++++++++++---------- docs/traffic_management_module.md | 27 +++++++ 3 files changed, 112 insertions(+), 43 deletions(-) delete mode 100644 .gitpod.yml diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 0af235a3a..000000000 --- a/.gitpod.yml +++ /dev/null @@ -1,2 +0,0 @@ -tasks: - - init: pip install platformio && pip install --upgrade pip diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md index 33b0c366d..71266d2a1 100644 --- a/docs/node_info_stores.md +++ b/docs/node_info_stores.md @@ -18,6 +18,22 @@ its 4-bit cached role acts as a final fallback when all three identity tiers mis Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, `src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`. +**Memory classes.** The warm tier (§2) and unified cache (§3) size themselves from +`MESHTASTIC_MEM_CLASS` (`src/memory/MemClass.h`), which ranks a build by _usable app heap after +platform overheads_ (SoftDevice, WiFi+BLE stacks) rather than by raw RAM or chip family. The hot +store (§1) is flash-shaped and the NodeInfo cache (§4) is present-or-absent, so neither is classed: + +| Class | Heap | Parts | +| ------ | --------------------- | -------------------------------------------- | +| LARGE | PSRAM or host | ESP32-S3 with PSRAM, portduino/native | +| MEDIUM | ~250-500 KB, no PSRAM | ESP32-S3/C6/P4 without PSRAM | +| SMALL | ~100-250 KB | classic ESP32/S2/C3, nRF52840, RP2040/RP2350 | +| TINY | <32 KB | STM32WL | + +An unclassified chip lands in SMALL on purpose: small caches are a recoverable default, an +exhausted heap is not. Where a capacity table names a specific part beside these classes, that +part is deliberately class-deviant and the reason is given under the table. + --- ## 1. NodeDB hot store (authoritative) @@ -26,8 +42,6 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, flattened fields (names, role, public key, bitfield flags such as `HAS_XEDDSA_SIGNED`; position/telemetry live in satellite stores reached via copy-out accessors, not nested members). Everything else in this document is a cache or a fallback for it. -- **Capacity:** `MAX_NUM_NODES`, per platform - 250 on native, 120 on nRF52840/generic - ESP32, 10 on STM32WL (see `mesh-pb-constants.h`). - **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction the node's essentials are **absorbed into the warm tier** (see §2); on re-admission the warm record is rehydrated back (`take()`), including the signer bit. @@ -45,6 +59,17 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, warm-evicted signer be impersonated with unsigned frames. - `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`. +**Capacity** - `MAX_NUM_NODES` (`mesh-pb-constants.h`): + +| ESP32-S3 | Native | nRF52840, generic ESP32 | STM32WL | +| --------------- | ------ | ----------------------- | ------- | +| 250 / 200 / 100 | 250 | 120 | 10 | + +This one is flash-shaped rather than heap-shaped, so it is unclassed: `nodes.proto` has to fit the +filesystem. ESP32-S3 is the only runtime tier, picked at boot from the flash chip (>=15 MB />=7 MB +/ smaller); the 120 covers nRF52840 plus generic ESP32 including C3, and is what keeps `nodes.proto` +inside the stock 28 KB LittleFS. + ## 2. Warm tier - `WarmNodeStore` (NodeDB-owned) - **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal @@ -53,7 +78,6 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, - **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits of `last_heard` are omitted, and replaced with metadata (role: 4 bits, protected category: 2, signer bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking. -- **Capacity:** `WARM_NODE_COUNT` (100 on constrained parts; platform-tiered). - **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless candidates never displace keyed entries. - **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring below LittleFS @@ -61,6 +85,18 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, - **Membership invariant:** a node lives in the hot **XOR** warm tier. `take()` removes the warm record when the node is re-admitted hot, restoring role/protected/signer bits. +**Capacity** - `WARM_NODE_COUNT` (`mesh-pb-constants.h`): + +| LARGE | MEDIUM | RP2040 / RP2350 | nRF52840 | SMALL | TINY | +| ----- | ------ | --------------- | -------- | ----- | ---- | +| 2000 | 150 | 150 | 100 | 100 | 0 | + +TINY's 0 disables the tier outright. At 40 B/entry, LARGE costs ~80 KB and lives in PSRAM, MEDIUM +~6 KB of heap. Both named parts are class-deviant on purpose: RP2040/RP2350 is bounded so the +`warm.dat` write fits the 8 s watchdog (#10746) rather than by RAM, and nRF52840 dropped from 200 to +100 because its RAM cache is calloc'd from the ~115 KB heap arena shared with SoftDevice, which +2.8.0 field reports showed at 99% use. + ## 3. TMM unified cache (base, traffic state) - **What:** TMM's own flat array of packed 10-byte `UnifiedCacheEntry` records - the @@ -76,15 +112,22 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, `node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | pos_time(1) | rate_unknown_time(1) | next_hop(1)` = 10 bytes, all platforms. Timestamps are free-running modular ticks (uint8 / nibbles) with presence carried by non-zero sentinels - no epochs, no absolute time. -- **Capacity:** `TRAFFIC_MANAGEMENT_CACHE_SIZE`, per memory class: 2048 (PSRAM S3 / - native), 500 (medium), 400 (small), 250 (nRF52840 - deliberately class-deviant for heap - headroom), 0 when `HAS_TRAFFIC_MANAGEMENT=0`. Variant-overridable. - **Eviction:** linear scan; insertion on a full cache evicts the stalest entry, preferring to keep entries with a `next_hop` hint **or** a cached special (non-`CLIENT`) role - the long-tail state this cache exists to retain (`findOrCreateEntry`'s `preferred` test covers both, not just `next_hop`). - **Persistence:** none - RAM/PSRAM only, rebuilt from traffic. +**Capacity** - `TRAFFIC_MANAGEMENT_CACHE_SIZE` (`mesh-pb-constants.h`), variant-overridable: + +| LARGE | MEDIUM | SMALL | nRF52840 | `HAS_TRAFFIC_MANAGEMENT=0` | +| ----- | ------ | ----- | -------- | -------------------------- | +| 2048 | 500 | 400 | 250 | 0 | + +At 10 B/entry that is ~5 KB on MEDIUM and ~2.5 KB on nRF52840, which is class-deviant for the same +heap reason as the warm tier (its class would give 400); 250 entries still tracks over 2x the +120-node hot store, and LRU victim recycling absorbs busier meshes. + ## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier) - **What:** a flat array of `NodeInfoPayloadEntry` (PSRAM-backed on hardware; see @@ -101,11 +144,20 @@ Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, `keySignerProven`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle no longer keeps per-entry state here - it is a pair of separate RAM tables; see the module doc.) -- **Capacity:** `kNodeInfoCacheEntries = 2000`, linear scan (NodeInfo traffic is - low-rate). - **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB seeding plus observed traffic after every boot. +**Capacity** - `kNodeInfoCacheEntries` (`TrafficManagementModule.h`), gated by +`TMM_HAS_NODEINFO_CACHE`: + +| ESP32 + PSRAM | Native unit-test builds | Everything else | +| ------------- | ----------------------- | --------------- | +| 2000 | 2000 | not compiled | + +Not class-tiered: the array is either compiled or it isn't. ESP32+PSRAM is the production home (in +PSRAM); native test builds put the same 2000 entries on the plain heap so the trust and retention +paths run in CI. Linear scan in every build - NodeInfo traffic is low-rate. + ### Trust & provenance model - **Key pin, three layers deep:** an incoming NodeInfo key is checked against @@ -172,22 +224,14 @@ rehydration. ### Tick clocks and wrap safety -All TMM timestamps are free-running modular ticks (uint8 or nibble) from `clockMs()`; modular -subtraction is correct only while the true age stays below the counter period, so every clock -needs something to clear expired state before it aliases. - -| Clock | Tick / period | Window | Kept honest by | -| ------------------ | -------------- | --------------- | -------------------------------------------------- | -| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) | -| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) | -| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset | -| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only | - -`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the -_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a -compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never -`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`: -a build that has the cache always has its sweep. +This cache's `obsTick` recency stamp, like the unified cache's pos/rate/unknown stamps, is a +free-running modular tick rather than an absolute time, and depends on the maintenance sweep to +clear expired state before it aliases. The per-clock periods, windows, and what keeps each honest +are documented with the module in +[traffic_management_module.md](traffic_management_module.md#tick-clocks-and-wrap-safety). The sharp +case for this tier is `obsTick`: the sweep clearing `hasObserved` is the _sole_ guarantee the 6 h +serve gate never reads an aliased stamp, which is why it is a compile-time invariant guarded by +`TMM_HAS_NODEINFO_CACHE` alone. The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds timestamp (128 s quantised), so it cannot wrap until 2106 and needs no sweep - the TMM caches @@ -207,23 +251,23 @@ behaviour - is documented with the module in Side-by-side view of what each store actually holds ("-" = not held). Details and rationale live in the per-store sections above. -| Property | 1. Hot store (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) | -| ------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------- | -| Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) | -| Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - | -| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - | -| Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - | -| Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) | -| Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks | -| Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) | -| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - | -| Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) | -| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - | -| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint | -| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact | -| Capacity | `MAX_NUM_NODES` (250/120/10) | `WARM_NODE_COUNT` (~100) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (2048/500/400/250/0) | -| Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none | -| Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap | +| Property | 1. Hot store (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) | +| ------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------- | +| Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) | +| Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - | +| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - | +| Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - | +| Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) | +| Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks | +| Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) | +| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - | +| Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) | +| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - | +| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint | +| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact | +| Capacity | `MAX_NUM_NODES` (10-250) | `WARM_NODE_COUNT` (0-2000) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (0-2048) | +| Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none | +| Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap | ## How a lookup falls through the tiers diff --git a/docs/traffic_management_module.md b/docs/traffic_management_module.md index bb533edbe..99305009d 100644 --- a/docs/traffic_management_module.md +++ b/docs/traffic_management_module.md @@ -177,6 +177,33 @@ carries throttle state. --- +## Tick clocks and wrap safety + +Every per-node timestamp in TMM's caches is a free-running modular tick (uint8 or nibble) taken +from `clockMs()` - never an absolute time. That is what keeps `UnifiedCacheEntry` at 10 bytes +across up to 2048 entries. The cost is that modular subtraction is only correct while the true age +stays below the counter's period, so every clock needs something to clear expired state before it +aliases. (The direct-serve throttle above is the deliberate exception: full `uint32` milliseconds +compared by wrap-safe subtraction, hence no tick and no sweep.) + +| Clock | Tick / period | Window | Kept honest by | +| ------------------ | -------------- | --------------- | -------------------------------------------------- | +| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) | +| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) | +| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset | +| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only | + +`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the +_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a +compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never +`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`: +a build that has the cache always has its sweep. + +The stores these clocks stamp, and the warm tier's contrasting absolute timestamps, are described +in [node_info_stores.md](node_info_stores.md). + +--- + ## Configuration All tunables live under `moduleConfig.traffic_management`; the whole module is gated by the From 84322af4e0ba1ce70f6d861e0a7cae892e864a35 Mon Sep 17 00:00:00 2001 From: brad112358 Date: Thu, 30 Jul 2026 10:46:59 -0500 Subject: [PATCH 28/30] Add support for a new nRF variant constructed by soldering an E22 (#7579) * Add support for an alternate pin assignment to the nrf52_promicro_diy variant constructed by soldering a Pro Micro type nRF52840 board directly to an E22 module. * Fix GPS connection documentation and Buzzer pin --------- Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> --- .../nrf52_promicro_diy_tcxo/platformio.ini | 14 +++ .../diy/nrf52_promicro_diy_tcxo/variant.h | 85 ++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index c4cff6b13..51d058bee 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -39,3 +39,17 @@ lib_deps = extra_scripts = ${nrf52840_base.extra_scripts} variants/nrf52840/diy/nrf52_promicro_diy_tcxo/custom_build_tasks.py ; Add to PIO's Project Tasks pane: preset builds for common displays + + +; NRF52 ProMicro w/ EasyProMicro pinout +[env:nrf52_promicro_diy-easypromicro] +board_level = extra +extends = nrf52840_base +board = promicro-nrf52840 +build_flags = + ${nrf52840_base.build_flags} + -I variants/nrf52840/diy/nrf52_promicro_diy_tcxo + -D NRF52_PROMICRO_DIY + -D EASYPROMICRO ; For EasyProMicro pinout (instead of ProMicro pinout) +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> +debug_tool = jlink \ No newline at end of file diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h index 5b580f684..9220b9c30 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h @@ -44,6 +44,32 @@ NRF52 PRO MICRO PIN ASSIGNMENT | P1.07 | Free pin   |   |         |             | | */ +/* +Alternative pin assignement for Easy E22 Promicro build +https://github.com/brad112358/easy_E22 + +| Pin   | Function   |   | Pin     | Function     | +| ----- | ----------- | --- | -------- | ------------ | +| Gnd   |             |   | vbat     |             | +| P0.06 | BUTTON_PIN |   | vbat     |             | +| P0.08 | PIN_BUZZER |   | Gnd     |             | +| Gnd   |             |   | reset   |             | +| Gnd   |             |   | ext_vcc | *see 0.13   | +| P0.17 | Serial2 TX |   | P0.31   | BATTERY_PIN | +| P0.20 | Rotary B   |   | P0.29   | DIO1         | +| P0.22 | Rotary A   |   | P0.02   | BUSY | +| P0.24 | Rotary press|   | P1.15   | NRST         | +| P1.00 | TXEN |   | P1.13   | MISO         | +| P0.11 | RXEN        |   | P1.11   | MOSI        | +| P1.04 | SDA         |   | P0.10   | SCK     | +| P1.06 | SCL   |   | P0.09   | CS       | +|       |             |   |         |             | +|       | Mid board   |   |         | Internal     | +| P1.01 | Serial2 RX |   | 0.15     | LED         | +| P1.02 | TX to GPS   |   | 0.13     | 3V3_EN       | +| P1.07 | RX from GPS |   |         |             | +*/ + // Number of pins defined in PinDescription array #define PINS_COUNT (48) #define NUM_DIGITAL_PINS (48) @@ -76,8 +102,13 @@ NRF52 PRO MICRO PIN ASSIGNMENT // WIRE IC AND IIC PINS #define WIRE_INTERFACES_COUNT 1 +#ifndef EASYPROMICRO #define PIN_WIRE_SDA (32 + 4) // P1.04 #define PIN_WIRE_SCL (0 + 11) // P0.11 +#else // Easy E22 Promicro arrangement +#define PIN_WIRE_SDA (32 + 4) // P1.04 +#define PIN_WIRE_SCL (32 + 6) // P1.06 +#endif // LED #define PIN_LED1 (0 + 15) // P0.15 @@ -86,13 +117,23 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define LED_STATE_ON 1 // State when LED is lit // Button +#ifndef EASYPROMICRO #define BUTTON_PIN (32 + 0) // P1.00 +#else // Easy E22 Promicro arrangement +#define BUTTON_PIN (0 + 6) // P0.06 +#endif // GPS +#ifndef EASYPROMICRO #define GPS_TX_PIN (0 + 20) // P0.20 - This is data from the MCU #define GPS_RX_PIN (0 + 22) // P0.22 - This is data from the GNSS - #define PIN_GPS_EN (0 + 24) // P0.24 +#else // Easy E22 Promicro arrangement +#define GPS_TX_PIN (32 + 2) // P1.02 - This is data from the MCU +#define GPS_RX_PIN (32 + 7) // P1.07 - This is data from the GNSS +#define PIN_GPS_EN (0 + 13) // P0.13 - Use power control for GPS enable +#endif + #define GPS_UBLOX // define GPS_DEBUG @@ -100,12 +141,20 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define PIN_SERIAL1_TX GPS_TX_PIN #define PIN_SERIAL1_RX GPS_RX_PIN -#define PIN_SERIAL2_RX (0 + 6) // P0.06 -#define PIN_SERIAL2_TX (0 + 8) // P0.08 +#ifndef EASYPROMICRO +#define PIN_SERIAL2_RX (0 + 6) // P0.06 +#define PIN_SERIAL2_TX (0 + 8) // P0.08 +#else // Easy E22 Promicro arrangement +#define PIN_SERIAL2_RX (32 + 1) // P1.01 +#define PIN_SERIAL2_TX (0 + 17) // P0.17 +// Buzzer - PWM +#define PIN_BUZZER (0 + 8) // p0.08 +#endif // Serial interfaces #define SPI_INTERFACES_COUNT 1 +#ifndef EASYPROMICRO #define PIN_SPI_MISO (0 + 2) // P0.02 #define PIN_SPI_MOSI (32 + 15) // P1.15 #define PIN_SPI_SCK (32 + 11) // P1.11 @@ -166,7 +215,35 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define LR2021_IRQ_DIO_NUM 9 // DIO9 → P0.10 #endif -// #define SX126X_MAX_POWER 8 set this if using a high-power board! +#else // Easy E22 Promicro arrangement +#define USE_SSD1306 +// #define USE_SH1106 + +#define USE_SX1262 +#define PIN_SPI_MISO (32 + 13) // P1.13 +#define PIN_SPI_MOSI (32 + 11) // P1.11 +#define PIN_SPI_SCK (0 + 10) // P0.10 + +#define LORA_MISO PIN_SPI_MISO +#define LORA_MOSI PIN_SPI_MOSI +#define LORA_SCK PIN_SPI_SCK +#define LORA_CS (0 + 9) // P0.09 NSS +#define LORA_DIO0 (0 + 2) // P0.02 BUSY +#define LORA_DIO1 (0 + 29) // P0.29 IRQ +#define LORA_RESET (32 + 15) // P1.15 NRST + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO0 +#define SX126X_RESET LORA_RESET +#define SX126X_RXEN (0 + 11) // P0.11 +#define SX126X_TXEN (32 + 0) // P1.00 +#define SX126X_MAX_POWER 8 // Default to prevent damage to E22_900M33S; Comment out for others +#undef TX_GAIN_LORA +#define TX_GAIN_LORA 22 // 8 for E22 900M30S, 25 for 900M33S, 22 for 3.7V battery powered 900M33S, 0 for 900M22S +#endif + +// #define SX126X_MAX_POWER 8 // set this if using a high-power board! /* On the SX1262, DIO3 sets the voltage for an external TCXO, if one is present. If one is not present, use TCXO_OPTIONAL to try both From 597f6767b5e0c37758444071477d70c74657eecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 30 Jul 2026 17:59:35 +0200 Subject: [PATCH 29/30] Add Elecrow ThinkNode M8 board support (thinknode_m8) (#11226) * Add Elecrow ThinkNode M8 variant scaffold (thinknode_m8) nRF52840 + SX1262 + 2.4" e-paper + ATGM336H-5NR32 GPS. All pins resolved from ThinkNode_M8_V0.3.sch; cross-checked against meshtastic/firmware#9181 (Elecrow V0.1 reference). Co-Authored-By: Claude Sonnet 4.6 * Add Elecrow ThinkNode M8 board support (nRF52840/SX1262, 1.54in e-ink, ATGM336H GNSS, SC7A20, EC04 encoder) * Address review: keep the stored backlight level out of blanking, match only the SC7A20 WHO_AM_I byte, and transfer detents atomically * Use std::atomic for the press-and-turn detent counter so native builds compile * Drop the ThinkNode M8 LED_BUILTIN redefinition that warned on every translation unit --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- boards/ThinkNode-M8.json | 53 +++++ src/Power.cpp | 2 +- src/detect/ScanI2C.cpp | 6 +- src/detect/ScanI2C.h | 1 + src/detect/ScanI2CTwoWire.cpp | 4 + src/graphics/Backlight.cpp | 71 +++++++ src/graphics/Backlight.h | 46 +++++ src/graphics/EInkDisplay2.cpp | 6 +- src/graphics/Screen.cpp | 37 +++- src/graphics/draw/MenuHandler.cpp | 8 +- src/input/ButtonThread.cpp | 8 +- src/input/InputBroker.cpp | 46 ++++- src/input/RotaryEncoderInterruptBase.cpp | 50 ++++- src/input/RotaryEncoderInterruptBase.h | 15 +- src/input/RotaryEncoderInterruptImpl1.cpp | 3 + src/mesh/NodeDB.cpp | 9 + src/motion/AccelerometerThread.h | 1 + src/motion/LIS3DHSensor.cpp | 8 +- src/platform/nrf52/architecture.h | 2 + .../ELECROW-ThinkNode-M8/platformio.ini | 41 ++++ .../nrf52840/ELECROW-ThinkNode-M8/variant.cpp | 75 ++++++++ .../nrf52840/ELECROW-ThinkNode-M8/variant.h | 182 ++++++++++++++++++ 22 files changed, 641 insertions(+), 33 deletions(-) create mode 100644 boards/ThinkNode-M8.json create mode 100644 src/graphics/Backlight.cpp create mode 100644 src/graphics/Backlight.h create mode 100644 variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini create mode 100644 variants/nrf52840/ELECROW-ThinkNode-M8/variant.cpp create mode 100644 variants/nrf52840/ELECROW-ThinkNode-M8/variant.h diff --git a/boards/ThinkNode-M8.json b/boards/ThinkNode-M8.json new file mode 100644 index 000000000..acd7f89c0 --- /dev/null +++ b/boards/ThinkNode-M8.json @@ -0,0 +1,53 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DARDUINO_NRF52840_ThinkNode_M8 -DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + ["0x239A", "0x4405"], + ["0x239A", "0x0029"], + ["0x239A", "0x002A"] + ], + "usb_product": "elecrow_thinknode_m8", + "mcu": "nrf52840", + "variant": "ELECROW-ThinkNode-M8", + "variants_dir": "variants", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "6.1.1", + "sd_fwid": "0x00B6" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": ["bluetooth"], + "debug": { + "jlink_device": "nRF52840_xxAA", + "onboard_tools": ["jlink"], + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52840-mdk-rs" + }, + "frameworks": ["arduino"], + "name": "elecrow thinknode m8", + "upload": { + "maximum_ram_size": 248832, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "", + "vendor": "ELECROW" +} diff --git a/src/Power.cpp b/src/Power.cpp index 350208dcd..a886082c6 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -566,7 +566,7 @@ class AnalogBatteryLevel : public HasBatteryLevel // technically speaking this should work for all(?) NRF52 boards // but needs testing across multiple devices. NRF52 USB would not even work if // VBUS was not properly connected and detected by the CPU -#elif defined(MUZI_BASE) || defined(PROMICRO_DIY_TCXO) +#elif defined(MUZI_BASE) || defined(PROMICRO_DIY_TCXO) || defined(ELECROW_ThinkNode_M8) return powerHAL_isVBUSConnected(); #endif return getBattVoltage() > chargingVolt; diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index a147194a7..a919bd105 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -37,9 +37,9 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const { - ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX, - ICM20948, QMA6100P, BMM150, BMI270, ICM42607P, ISM330DHCX}; - return firstOfOrNONE(12, types); + ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX, + ICM20948, BMM150, BMI270, ICM42607P, ISM330DHCX, QMA6100P}; + return firstOfOrNONE(13, types); } ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 3dcbe3c6a..87965245a 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -46,6 +46,7 @@ class ScanI2C QMA6100P, MPU6050, LIS3DH, + SC7A20, // LIS3DH register map, different WHO_AM_I BMA423, BQ24295, LSM6DS3, diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index c543aac85..be326f4db 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -691,6 +691,10 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) if (registerValue == 0x3300 || registerValue == 0x3333) { // RAK4631 WisBlock has LIS3DH register at 0x3333 type = LIS3DH; logFoundDevice("LIS3DH", (uint8_t)addr.address); + } else if ((registerValue & 0xFF00) == 0x1100) { + // Silan SC7A20: LIS3DH register map, but answers 0x11 here. + type = SC7A20; + logFoundDevice("SC7A20", (uint8_t)addr.address); } else { type = BMA423; logFoundDevice("BMA423", (uint8_t)addr.address); diff --git a/src/graphics/Backlight.cpp b/src/graphics/Backlight.cpp new file mode 100644 index 000000000..111af66d6 --- /dev/null +++ b/src/graphics/Backlight.cpp @@ -0,0 +1,71 @@ +#include "graphics/Backlight.h" + +#if HAS_PWM_BACKLIGHT + +#include "mesh/NodeDB.h" + +namespace graphics +{ +namespace +{ +bool pinConfigured = false; + +// Level restored when the backlight is switched back on after being toggled off. +uint8_t lastOnLevel = PWM_BACKLIGHT_DEFAULT; + +void drive(uint8_t level) +{ + if (!pinConfigured) { + pinMode(PIN_PWM_BACKLIGHT, OUTPUT); + pinConfigured = true; + } + analogWrite(PIN_PWM_BACKLIGHT, level); +} +} // namespace + +void backlightSet(uint8_t level) +{ + if (level > 0) + lastOnLevel = level; + uiconfig.screen_brightness = level; + drive(level); +} + +uint8_t backlightGet() +{ + return uiconfig.screen_brightness; +} + +void backlightOn() +{ + drive(uiconfig.screen_brightness); +} + +void backlightOff() +{ + drive(0); +} + +void backlightToggle() +{ + backlightSet(uiconfig.screen_brightness > 0 ? 0 : lastOnLevel); +} + +void backlightStepUp() +{ + uint16_t raised = (uint16_t)uiconfig.screen_brightness + PWM_BACKLIGHT_STEP; + backlightSet(raised > PWM_BACKLIGHT_MAX ? PWM_BACKLIGHT_MAX : (uint8_t)raised); +} + +void backlightStepDown() +{ + // Leave an off backlight off; otherwise clamp at the minimum. + if (uiconfig.screen_brightness == 0) + return; + backlightSet(uiconfig.screen_brightness <= PWM_BACKLIGHT_MIN + PWM_BACKLIGHT_STEP + ? PWM_BACKLIGHT_MIN + : uiconfig.screen_brightness - PWM_BACKLIGHT_STEP); +} +} // namespace graphics + +#endif // HAS_PWM_BACKLIGHT diff --git a/src/graphics/Backlight.h b/src/graphics/Backlight.h new file mode 100644 index 000000000..449fd5736 --- /dev/null +++ b/src/graphics/Backlight.h @@ -0,0 +1,46 @@ +#pragma once + +#include "configuration.h" + +// PWM backlight control. A variant opts in by defining PIN_PWM_BACKLIGHT, optionally with +// PWM_BACKLIGHT_DEFAULT, _MIN, _MAX and _STEP. Levels are 0..255 in uiconfig.screen_brightness. + +#if defined(PIN_PWM_BACKLIGHT) +#define HAS_PWM_BACKLIGHT 1 +#else +#define HAS_PWM_BACKLIGHT 0 +#endif + +#if HAS_PWM_BACKLIGHT + +#ifndef PWM_BACKLIGHT_DEFAULT +#define PWM_BACKLIGHT_DEFAULT 128 +#endif +#ifndef PWM_BACKLIGHT_MIN +#define PWM_BACKLIGHT_MIN 8 +#endif +#ifndef PWM_BACKLIGHT_MAX +#define PWM_BACKLIGHT_MAX 248 +#endif +#ifndef PWM_BACKLIGHT_STEP +#define PWM_BACKLIGHT_STEP 20 +#endif + +namespace graphics +{ +void backlightSet(uint8_t level); + +uint8_t backlightGet(); + +void backlightOn(); // drive the stored level + +void backlightOff(); // drive 0, leaving the stored level alone + +void backlightToggle(); + +void backlightStepUp(); + +void backlightStepDown(); +} // namespace graphics + +#endif // HAS_PWM_BACKLIGHT diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index 7c8b6ab8e..a44e8ef4b 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -1,4 +1,5 @@ #include "configuration.h" +#include "graphics/Backlight.h" #if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY) #include "EInkDisplay2.h" @@ -158,13 +159,14 @@ bool EInkDisplay::connect() #endif #endif -#if defined(TTGO_T_ECHO) || defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(TTGO_T_ECHO_PLUS) +#if defined(TTGO_T_ECHO) || defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(TTGO_T_ECHO_PLUS) || \ + defined(ELECROW_ThinkNode_M8) { auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1); adafruitDisplay = new GxEPD2_BW(*lowLevel); adafruitDisplay->init(); -#if defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) +#if defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(ELECROW_ThinkNode_M8) adafruitDisplay->setRotation(4); #else adafruitDisplay->setRotation(3); diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 7892cbdda..9c1688db4 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -61,6 +61,7 @@ along with this program. If not, see . #include "error.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" +#include "graphics/Backlight.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/TFTPalette.h" @@ -691,7 +692,9 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) dispdev->displayOn(); #endif -#ifdef PIN_EINK_EN +#if HAS_PWM_BACKLIGHT + graphics::backlightOn(); +#elif defined(PIN_EINK_EN) if (uiconfig.screen_brightness == 1) digitalWrite(PIN_EINK_EN, HIGH); #elif defined(PCA_PIN_EINK_EN) @@ -751,7 +754,9 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) drawLockdownLockScreen(dispdev); #endif -#ifdef PIN_EINK_EN +#if HAS_PWM_BACKLIGHT + graphics::backlightOff(); +#elif defined(PIN_EINK_EN) digitalWrite(PIN_EINK_EN, LOW); #elif defined(PCA_PIN_EINK_EN) io.digitalWrite(PCA_PIN_EINK_EN, LOW); @@ -1848,8 +1853,12 @@ void Screen::handleStartFirmwareUpdateScreen() void Screen::increaseBrightness() { +#if HAS_PWM_BACKLIGHT + graphics::backlightStepUp(); + brightness = graphics::backlightGet(); +#else brightness = ((brightness + 62) > 254) ? brightness : (brightness + 62); - +#endif #if defined(ST7789_CS) // run the setDisplayBrightness function. This works on t-decks static_cast(dispdev)->setDisplayBrightness(brightness); @@ -1860,8 +1869,12 @@ void Screen::increaseBrightness() void Screen::decreaseBrightness() { +#if HAS_PWM_BACKLIGHT + graphics::backlightStepDown(); + brightness = graphics::backlightGet(); +#else brightness = (brightness < 70) ? brightness : (brightness - 62); - +#endif #if defined(ST7789_CS) static_cast(dispdev)->setDisplayBrightness(brightness); #endif @@ -2057,10 +2070,18 @@ int Screen::handleInputEvent(const InputEvent *event) } #ifdef USE_EINK // the screen is the last input handler, so if an event makes it here, we can assume it will prompt a screen draw. - EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please - EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update - handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?) - setFastFramerate(); // Draw ASAP +#if HAS_PWM_BACKLIGHT + // A PWM backlight change leaves the frame identical, and the refresh is blocking. + const bool backlightOnly = + event->kbchar == INPUT_BROKER_MSG_BRIGHTNESS_UP || event->kbchar == INPUT_BROKER_MSG_BRIGHTNESS_DOWN; + if (!backlightOnly) +#endif + { + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please + EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update + handleSetOn(true); // Ensure power-on to receive deep-sleep screensaver (PowerFSM should handle?) + setFastFramerate(); // Draw ASAP + } #endif if (NotificationRenderer::isOverlayBannerShowing()) { NotificationRenderer::inEvent = *event; diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 6625d4303..d4ff187ec 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -10,6 +10,7 @@ #include "MessageStore.h" #include "NodeDB.h" #include "buzz.h" +#include "graphics/Backlight.h" #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" #include "graphics/TFTColorRegions.h" @@ -1108,7 +1109,7 @@ void menuHandler::homeBaseMenu() } optionsEnumArray[options++] = Mute; } -#if defined(PIN_EINK_EN) || defined(PCA_PIN_EINK_EN) +#if HAS_PWM_BACKLIGHT || defined(PIN_EINK_EN) || defined(PCA_PIN_EINK_EN) optionsArray[options] = "Toggle Backlight"; optionsEnumArray[options++] = Backlight; #else @@ -1138,7 +1139,10 @@ void menuHandler::homeBaseMenu() } } else if (selected == Backlight) { screen->setOn(false); -#if defined(PIN_EINK_EN) +#if HAS_PWM_BACKLIGHT + graphics::backlightToggle(); + saveUIConfig(); +#elif defined(PIN_EINK_EN) if (uiconfig.screen_brightness == 1) { uiconfig.screen_brightness = 0; digitalWrite(PIN_EINK_EN, LOW); diff --git a/src/input/ButtonThread.cpp b/src/input/ButtonThread.cpp index 481b245da..bf0883691 100644 --- a/src/input/ButtonThread.cpp +++ b/src/input/ButtonThread.cpp @@ -227,7 +227,13 @@ int32_t ButtonThread::runOnce() case BUTTON_EVENT_DOUBLE_PRESSED: { // not wired in if screen detected LOG_INFO("Double press!"); - +#if defined(ELECROW_ThinkNode_M8) + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) + config.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED; + else if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED) + config.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_ALL_ENABLED; + service->reloadConfig(SEGMENT_CONFIG); +#endif // Reset combination tracking waitingForLongPress = false; diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index d6c8fe832..4f09e3224 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -35,11 +35,12 @@ #endif #if HAS_BUTTON || defined(ARCH_PORTDUINO) +#include "graphics/Backlight.h" #include "input/ButtonThread.h" #if defined(BUTTON_PIN_TOUCH) ButtonThread *TouchButtonThread = nullptr; -#if defined(PIN_EINK_EN) +#if HAS_PWM_BACKLIGHT || defined(PIN_EINK_EN) static bool touchBacklightWasOn = false; static bool touchBacklightActive = false; #endif @@ -241,20 +242,28 @@ void InputBroker::Init() }; touchConfig.singlePress = INPUT_BROKER_NONE; touchConfig.longPress = INPUT_BROKER_BACK; -#if defined(PIN_EINK_EN) - // Touch pad drives the backlight on devices with e-ink backlight pin +#if HAS_PWM_BACKLIGHT || defined(PIN_EINK_EN) + // Touch pad drives the backlight on devices that have one touchConfig.longPress = INPUT_BROKER_NONE; touchConfig.suppressLeadUpSound = true; touchConfig.onPress = []() { - touchBacklightWasOn = uiconfig.screen_brightness == 1; + touchBacklightWasOn = uiconfig.screen_brightness > 0; if (!touchBacklightWasOn) { +#if HAS_PWM_BACKLIGHT + graphics::backlightOn(); +#else digitalWrite(PIN_EINK_EN, HIGH); +#endif } touchBacklightActive = true; }; touchConfig.onRelease = []() { if (touchBacklightActive && !touchBacklightWasOn) { +#if HAS_PWM_BACKLIGHT + graphics::backlightOff(); +#else digitalWrite(PIN_EINK_EN, LOW); +#endif } touchBacklightActive = false; }; @@ -327,6 +336,30 @@ void InputBroker::Init() #define BUTTON_ACTIVE_PULLUP true #endif +#if defined(ELECROW_ThinkNode_M8) + // Rotary encoder drives the UI, so the function button keeps a fixed map. + LOG_DEBUG("ThinkNode_M8 button"); + UserButtonThread = new ButtonThread("FunctionButton"); + { + ButtonConfig userConfig; + userConfig.pinNumber = (uint8_t)_pinNum; + userConfig.activeLow = BUTTON_ACTIVE_LOW; + userConfig.activePullup = BUTTON_ACTIVE_PULLUP; + userConfig.pullupSense = pullup_sense; + userConfig.intRoutine = []() { + UserButtonThread->userButton.tick(); + UserButtonThread->setIntervalFromNow(0); + runASAP = true; + BaseType_t higherWake = 0; + concurrency::mainDelay.interruptFromISR(&higherWake); + }; + userConfig.singlePress = INPUT_BROKER_SEND_PING; + userConfig.longPress = INPUT_BROKER_SHUTDOWN; + userConfig.longPressTime = 5000; + userConfig.doublePress = INPUT_BROKER_GPS_TOGGLE; + UserButtonThread->initButton(userConfig); + } +#else // Buttons. Moved here cause we need NodeDB to be initialized // If your variant.h has a BUTTON_PIN defined, go ahead and define BUTTON_ACTIVE_LOW and BUTTON_ACTIVE_PULLUP UserButtonThread = new ButtonThread("UserButton"); @@ -378,8 +411,9 @@ void InputBroker::Init() userConfigNoScreen.triplePress = INPUT_BROKER_GPS_TOGGLE; UserButtonThread->initButton(userConfigNoScreen); } -#endif -#endif +#endif // ELECROW_ThinkNode_M8 +#endif // BUTTON_PIN +#endif // HAS_BUTTON #if (HAS_BUTTON || ARCH_PORTDUINO) && !MESHTASTIC_EXCLUDE_INPUTBROKER if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { diff --git a/src/input/RotaryEncoderInterruptBase.cpp b/src/input/RotaryEncoderInterruptBase.cpp index 80ac08175..c177403bf 100644 --- a/src/input/RotaryEncoderInterruptBase.cpp +++ b/src/input/RotaryEncoderInterruptBase.cpp @@ -56,24 +56,50 @@ int32_t RotaryEncoderInterruptBase::runOnce() if (!pressDetected && buttonPressed) { pressDetected = true; pressStartTime = now; + pressAndTurnFired = false; } if (pressDetected) { + // Press-and-turn takes precedence over the press itself. + if (pressAndTurnEnabled() && pressAndTurnDelta.load(std::memory_order_relaxed) != 0) { + // Drain in one pass: releasing the button would discard anything left over. + // Exchange, so a detent arriving from the ISR mid-drain is not lost. + int32_t pending = pressAndTurnDelta.exchange(0, std::memory_order_relaxed); + LOG_DEBUG("Rotary event Press %s (%d detents)", pending > 0 ? "CW" : "CCW", pending); + while (pending != 0) { + bool cw = pending > 0; + InputEvent turn = {}; + turn.source = this->_originName; + turn.inputEvent = INPUT_BROKER_NONE; + turn.kbchar = cw ? _pressAndTurnCw : _pressAndTurnCcw; + pending -= cw ? 1 : -1; + this->notifyObservers(&turn); + } + pressAndTurnFired = true; + } + uint32_t duration = now - pressStartTime; if (!buttonPressed) { // released -> if short press, send short, else already sent long - if (duration < LONG_PRESS_DURATION && now - lastPressKeyTime >= pressDebounceMs) { + if (!pressAndTurnFired && duration < LONG_PRESS_DURATION && now - lastPressKeyTime >= pressDebounceMs) { lastPressKeyTime = now; LOG_DEBUG("Rotary event Press short"); e.inputEvent = this->_eventPressed; + } else if (pressAndTurnEnabled() && !pressAndTurnFired && duration >= LONG_PRESS_DURATION && + this->_eventPressedLong != INPUT_BROKER_NONE) { + // Held long enough and no turn came, so the long press stands + LOG_DEBUG("Rotary event Press long"); + e.inputEvent = this->_eventPressedLong; } pressDetected = false; pressStartTime = 0; lastPressLongEventTime = 0; + pressAndTurnDelta.store(0, std::memory_order_relaxed); + pressAndTurnFired = false; this->action = ROTARY_ACTION_NONE; - } else if (duration >= LONG_PRESS_DURATION && this->_eventPressedLong != INPUT_BROKER_NONE && - lastPressLongEventTime == 0) { - // fire single-shot long press + } else if (!pressAndTurnEnabled() && duration >= LONG_PRESS_DURATION && + this->_eventPressedLong != INPUT_BROKER_NONE && lastPressLongEventTime == 0) { + // fire single-shot long press; press-and-turn encoders defer this to release lastPressLongEventTime = now; LOG_DEBUG("Rotary event Press long"); e.inputEvent = this->_eventPressedLong; @@ -87,7 +113,7 @@ int32_t RotaryEncoderInterruptBase::runOnce() e.inputEvent = this->_eventCcw; } - if (e.inputEvent != INPUT_BROKER_NONE) { + if (e.inputEvent != INPUT_BROKER_NONE || e.kbchar != 0) { this->notifyObservers(&e); } @@ -95,11 +121,20 @@ int32_t RotaryEncoderInterruptBase::runOnce() this->action = ROTARY_ACTION_NONE; } else if (now - pressStartTime < LONG_PRESS_DURATION) { return (20); // keep checking for long/short until time expires + } else if (pressAndTurnEnabled()) { + // Keep polling while held, rather than relying on intHandler()'s reschedule from ISR. + return (20); } return INT32_MAX; } +void RotaryEncoderInterruptBase::setPressAndTurnChars(unsigned char cw, unsigned char ccw) +{ + this->_pressAndTurnCw = cw; + this->_pressAndTurnCcw = ccw; +} + void RotaryEncoderInterruptBase::intPressHandler() { this->action = ROTARY_ACTION_PRESSED; @@ -145,7 +180,10 @@ RotaryEncoderInterruptBaseStateType RotaryEncoderInterruptBase::intHandler(bool if (actualPinRaising && (otherPinLevel == LOW)) { if (state == ROTARY_EVENT_CLEARED) { newState = ROTARY_EVENT_OCCURRED; - if ((this->action != ROTARY_ACTION_PRESSED) && (this->action != action)) { + if (this->action == ROTARY_ACTION_PRESSED) { + // Turning while held; runOnce() ignores this unless press-and-turn is enabled. + pressAndTurnDelta.fetch_add((action == ROTARY_ACTION_CW) ? 1 : -1, std::memory_order_relaxed); + } else { this->action = action; } } diff --git a/src/input/RotaryEncoderInterruptBase.h b/src/input/RotaryEncoderInterruptBase.h index 4f9757609..c6bd79e5c 100644 --- a/src/input/RotaryEncoderInterruptBase.h +++ b/src/input/RotaryEncoderInterruptBase.h @@ -3,9 +3,9 @@ #include "InputBroker.h" #include "concurrency/OSThread.h" #include "mesh/NodeDB.h" +#include enum RotaryEncoderInterruptBaseStateType { ROTARY_EVENT_OCCURRED, ROTARY_EVENT_CLEARED }; - enum RotaryEncoderInterruptBaseActionType { ROTARY_ACTION_NONE, ROTARY_ACTION_PRESSED, ROTARY_ACTION_CW, ROTARY_ACTION_CCW }; class RotaryEncoderInterruptBase : public Observable, public concurrency::OSThread @@ -20,6 +20,10 @@ class RotaryEncoderInterruptBase : public Observable, public void intAHandler(); void intBHandler(); + // Turning while the button is held emits `cw` / `ccw` as kbchar and suppresses the press + // event. Both default to 0, leaving rotation-while-pressed ignored. + void setPressAndTurnChars(unsigned char cw, unsigned char ccw); + protected: virtual int32_t runOnce() override; RotaryEncoderInterruptBaseStateType intHandler(bool actualPinRaising, int otherPinLevel, @@ -31,6 +35,8 @@ class RotaryEncoderInterruptBase : public Observable, public volatile int rotaryLevelA = LOW; volatile int rotaryLevelB = LOW; volatile RotaryEncoderInterruptBaseActionType action = ROTARY_ACTION_NONE; + // Detents counted while the button is held: positive clockwise, negative counter-clockwise. + std::atomic pressAndTurnDelta{0}; private: // pins and events @@ -43,9 +49,16 @@ class RotaryEncoderInterruptBase : public Observable, public input_broker_event _eventPressedLong = INPUT_BROKER_NONE; const char *_originName; + // Press-and-turn characters, 0 when the gesture is not in use + unsigned char _pressAndTurnCw = 0; + unsigned char _pressAndTurnCcw = 0; + bool pressAndTurnEnabled() const { return _pressAndTurnCw != 0 || _pressAndTurnCcw != 0; } + // Long press detection variables uint32_t pressStartTime = 0; bool pressDetected = false; + // Suppresses the press event on release once press-and-turn has fired. + bool pressAndTurnFired = false; uint32_t lastPressLongEventTime = 0; unsigned long lastPressKeyTime = 0; static const uint32_t LONG_PRESS_DURATION = 300; // ms diff --git a/src/input/RotaryEncoderInterruptImpl1.cpp b/src/input/RotaryEncoderInterruptImpl1.cpp index 1da2ea008..4c1685d5a 100644 --- a/src/input/RotaryEncoderInterruptImpl1.cpp +++ b/src/input/RotaryEncoderInterruptImpl1.cpp @@ -26,6 +26,9 @@ bool RotaryEncoderInterruptImpl1::init() RotaryEncoderInterruptBase::init(pinA, pinB, pinPress, eventCw, eventCcw, eventPressed, eventPressedLong, RotaryEncoderInterruptImpl1::handleIntA, RotaryEncoderInterruptImpl1::handleIntB, RotaryEncoderInterruptImpl1::handleIntPressed); +#if defined(INPUTDRIVER_PRESS_AND_TURN_CW) && defined(INPUTDRIVER_PRESS_AND_TURN_CCW) + setPressAndTurnChars(INPUTDRIVER_PRESS_AND_TURN_CW, INPUTDRIVER_PRESS_AND_TURN_CCW); +#endif inputBroker->registerSource(this); #ifndef HAS_PHYSICAL_KEYBOARD osk_found = true; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 3ba46ccf2..f1a32b4b9 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1270,6 +1270,15 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.external_notification.active = true; #endif // NANO_G2_ULTRA +#ifdef ELECROW_ThinkNode_M8 + moduleConfig.canned_message.rotary1_enabled = true; + moduleConfig.canned_message.inputbroker_pin_a = PIN_BUTTON_EC04_A; + moduleConfig.canned_message.inputbroker_pin_b = PIN_BUTTON_EC04_B; + moduleConfig.canned_message.inputbroker_pin_press = PIN_BUTTON_EC04; + moduleConfig.canned_message.inputbroker_event_cw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT; + moduleConfig.canned_message.inputbroker_event_ccw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT; + moduleConfig.canned_message.inputbroker_event_press = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; +#endif #ifdef T_LORA_PAGER moduleConfig.canned_message.updown1_enabled = true; moduleConfig.canned_message.inputbroker_pin_a = ROTARY_A; diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 6847b9aa4..571767715 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -106,6 +106,7 @@ class AccelerometerThread : public concurrency::OSThread break; #if __has_include() case ScanI2C::DeviceType::LIS3DH: + case ScanI2C::DeviceType::SC7A20: sensor = new LIS3DHSensor(device); break; #endif diff --git a/src/motion/LIS3DHSensor.cpp b/src/motion/LIS3DHSensor.cpp index 903cc92f7..a29ff2b85 100755 --- a/src/motion/LIS3DHSensor.cpp +++ b/src/motion/LIS3DHSensor.cpp @@ -7,14 +7,16 @@ LIS3DHSensor::LIS3DHSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::Mot bool LIS3DHSensor::init() { - if (sensor.begin(deviceAddress())) { + // The SC7A20 shares the register map but identifies as 0x11. + const uint8_t whoAmI = (deviceType() == ScanI2C::DeviceType::SC7A20) ? 0x11 : 0x33; + if (sensor.begin(deviceAddress(), whoAmI)) { sensor.setRange(LIS3DH_RANGE_2_G); // Adjust threshold, higher numbers are less sensitive sensor.setClick(config.device.double_tap_as_button_press ? 2 : 1, MOTION_SENSOR_CHECK_INTERVAL_MS); - LOG_DEBUG("LIS3DH init ok"); + LOG_DEBUG("%s init ok", deviceType() == ScanI2C::DeviceType::SC7A20 ? "SC7A20" : "LIS3DH"); return true; } - LOG_DEBUG("LIS3DH init failed"); + LOG_DEBUG("%s init failed", deviceType() == ScanI2C::DeviceType::SC7A20 ? "SC7A20" : "LIS3DH"); return false; } diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index 600fb8192..0193eeb5c 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -99,6 +99,8 @@ #define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M6 #elif defined(ELECROW_ThinkNode_M4) #define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M4 +#elif defined(ELECROW_ThinkNode_M8) +#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M8 #elif defined(NANO_G2_ULTRA) #define HW_VENDOR meshtastic_HardwareModel_NANO_G2_ULTRA #elif defined(CANARYONE) diff --git a/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini new file mode 100644 index 000000000..6b845b2ef --- /dev/null +++ b/variants/nrf52840/ELECROW-ThinkNode-M8/platformio.ini @@ -0,0 +1,41 @@ +; First prototype eink/nrf52840/sx1262 device +[env:thinknode_m8] +custom_meshtastic_support_level = 1 +custom_meshtastic_images = thinknode_m8.svg +custom_meshtastic_tags = Elecrow +custom_meshtastic_hw_model = 130 +custom_meshtastic_hw_model_slug = THINKNODE_M8 +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_display_name = Elecrow ThinkNode M8 +custom_meshtastic_actively_supported = true + +extends = nrf52840_base +board = ThinkNode-M8 +board_check = true +debug_tool = jlink +# add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling. +build_flags = + ${nrf52840_base.build_flags} + -I variants/nrf52840/ELECROW-ThinkNode-M8 + -D ELECROW_ThinkNode_M8 + -D EINK_DISPLAY_MODEL=GxEPD2_154_D67 + -D EINK_WIDTH=200 + -D EINK_HEIGHT=200 + -D USE_EINK_DYNAMICDISPLAY ; Enable Dynamic EInk + -D EINK_LIMIT_FASTREFRESH=20 ; How many consecutive fast-refreshes are permitted //20 + -D EINK_LIMIT_RATE_BACKGROUND_SEC=10 ; Minimum interval between BACKGROUND updates //30 + -D EINK_LIMIT_RATE_RESPONSIVE_SEC=1 ; Minimum interval between RESPONSIVE updates + -D EINK_BACKGROUND_USES_FAST ; (Optional) Use FAST refresh for both BACKGROUND and RESPONSIVE, until a limit is reached. +build_src_filter = + ${nrf52_base.build_src_filter} + +<../variants/nrf52840/ELECROW-ThinkNode-M8> +lib_deps = + ${nrf52840_base.lib_deps} + # renovate: datasource=git-refs depName=meshtastic-GxEPD2 packageName=https://github.com/meshtastic/GxEPD2 gitBranch=master + https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip + # renovate: datasource=custom.pio depName=nRF52_PWM packageName=khoih-prog/library/nRF52_PWM + khoih-prog/nRF52_PWM@1.0.1 + # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib + lewisxhe/SensorLib@0.3.4 + + diff --git a/variants/nrf52840/ELECROW-ThinkNode-M8/variant.cpp b/variants/nrf52840/ELECROW-ThinkNode-M8/variant.cpp new file mode 100644 index 000000000..99d6b8a91 --- /dev/null +++ b/variants/nrf52840/ELECROW-ThinkNode-M8/variant.cpp @@ -0,0 +1,75 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "main.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" +#include + +const uint32_t g_ADigitalPinMap[] = { + // P0 - pins 0 and 1 are hardwired for xtal and should never be enabled + 0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47}; + +void initVariant() +{ + pinMode(I2C_EN, OUTPUT); + digitalWrite(I2C_EN, HIGH); + pinMode(VCC_ELNK_EN, OUTPUT); + digitalWrite(VCC_ELNK_EN, HIGH); + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, HIGH); + pinMode(ADC_EN, OUTPUT); + digitalWrite(ADC_EN, HIGH); + Wire.setPins(PIN_WIRE_SDA, PIN_WIRE_SCL); +} + +void variant_shutdown() +{ + auto dispdev = screen->getDisplayDevice(); + dispdev->resetDisplay(); + screen->forceDisplay(); + delay(500); + digitalWrite(I2C_EN, LOW); + digitalWrite(VCC_ELNK_EN, LOW); + digitalWrite(PIN_GPS_EN, LOW); + digitalWrite(ADC_EN, LOW); + for (int pin = 0; pin < 48; pin++) { + if (pin == I2C_EN || pin == VCC_ELNK_EN || pin == PIN_GPS_EN || pin == ADC_EN || pin == PIN_BUTTON1 || + pin == SX1262_SPI_NSS_PIN || pin == SX1262_SPI_SCK_PIN || pin == SX1262_SPI_MOSI_PIN || pin == SX1262_SPI_MISO_PIN || + pin == SX1262_IRQ_PIN || pin == SX1262_NRESET_PIN || pin == SX126X_BUSY) { + continue; + } + pinMode(pin, OUTPUT); + digitalWrite(pin, LOW); + if (pin >= 32) { + NRF_P1->DIRCLR = (1 << (pin - 32)); + } else { + NRF_GPIO->DIRCLR = (1 << pin); + } + } + nrf_gpio_cfg_input(PIN_BUTTON1, NRF_GPIO_PIN_PULLUP); // Configure the pin to be woken up as an input + nrf_gpio_pin_sense_t sense1 = NRF_GPIO_PIN_SENSE_LOW; + nrf_gpio_cfg_sense_set(PIN_BUTTON1, sense1); +} diff --git a/variants/nrf52840/ELECROW-ThinkNode-M8/variant.h b/variants/nrf52840/ELECROW-ThinkNode-M8/variant.h new file mode 100644 index 000000000..bb9cd92dd --- /dev/null +++ b/variants/nrf52840/ELECROW-ThinkNode-M8/variant.h @@ -0,0 +1,182 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef _VARIANT_ELECROW_EINK_V1_0_ +#define _VARIANT_ELECROW_EINK_V1_0_ + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +#define PIN_NFC1 (9) +#define PIN_NFC2 (10) + +#define CANNED_MESSAGE_MODULE_ENABLE 1 + +/*Power*/ +#define I2C_EN (0 + 13) +#define VCC_ELNK_EN (32 + 10) +#define PIN_GPS_EN (0 + 16) // active high +#define ADC_EN (32 + 8) + +/*Buttons*/ +#define HAS_BUTTON 1 +#define PIN_BUTTON_E (0 + 12) +#define PIN_BUTTON_EC04_A (0 + 8) +#define PIN_BUTTON_EC04_B (32 + 9) +#define PIN_BUTTON_EC04 (0 + 6) +#define PIN_BUTTON1 PIN_BUTTON_E +// Press and turn the encoder to dim the frontlight +#define INPUTDRIVER_PRESS_AND_TURN_CW INPUT_BROKER_MSG_BRIGHTNESS_UP +#define INPUTDRIVER_PRESS_AND_TURN_CCW INPUT_BROKER_MSG_BRIGHTNESS_DOWN + +/*LED*/ +#define PIN_LED1 -1 +#define LED_STATE_ON HIGH // State when LED is lit +#define LED_BLUE PIN_LED1 + +/*BUZZER*/ +#define PIN_BUZZER (32 + 1) + +/*USB_CHECK*/ +// Reference only: sits on a divider from VBUS, too near the input threshold to read +// digitally. USB presence comes from USBREGSTATUS instead. +#define USB_VBUS (32 + 3) + +/*CHARGE_CHECK*/ +// Reference only: link resistors and pull-ups unpopulated on V0.3, so these never reach the MCU. +#define CHRG (32 + 5) +#define DONE (32 + 6) + +/*Wire Interfaces*/ +// Found by the I2C scan: PCF8563 RTC at 0x51, SC7A20 accelerometer at 0x19. +#define WIRE_INTERFACES_COUNT 1 +#define PIN_WIRE_SDA (0 + 26) +#define PIN_WIRE_SCL (0 + 27) + +/*GPS*/ +// ATGM336H-5NR32. Probes as GNSS_MODEL_MTK; both branches speak CASIC $PCAS. +#define HAS_GPS 1 +#define GPS_BAUDRATE 9600 +#define PIN_GPS_RESET (0 + 17) +#define PIN_GPS_STANDBY (0 + 15) // An output to wake GPS, low means allow sleep, high means force wake +#define PIN_GPS_PPS (0 + 14) // 1PPS output, configured as an input by GPS.cpp +#define PIN_SERIAL1_RX (32 + 2) +#define PIN_SERIAL1_TX (32 + 4) +#define GPS_TX_PIN PIN_SERIAL1_TX +#define GPS_RX_PIN PIN_SERIAL1_RX +#define GPS_THREAD_INTERVAL 50 + +/*FLASH*/ +#define PIN_QSPI_CS (32 + 15) +#define PIN_QSPI_SCK (32 + 14) +#define PIN_QSPI_IO0 (32 + 12) // MOSI if using two bit interface +#define PIN_QSPI_IO1 (32 + 13) // MISO if using two bit interface +#define PIN_QSPI_IO2 (0 + 7) // WP if using two bit interface (i.e. not used) +#define PIN_QSPI_IO3 (0 + 5) // HOLD if using two bit interface (i.e. not used) +#define EXTERNAL_FLASH_DEVICES MX25R1635F +#define EXTERNAL_FLASH_USE_QSPI + +/*SPI*/ +#define SPI_INTERFACES_COUNT 2 +#define PIN_SPI_NSS (0 + 21) +#define PIN_SPI_SCK (0 + 19) +#define PIN_SPI_MOSI (0 + 20) +#define PIN_SPI_MISO (0 + 22) + +#define PIN_SPI1_NSS (0 + 30) +#define PIN_SPI1_SCK (0 + 31) +#define PIN_SPI1_MOSI (0 + 29) +#define PIN_SPI1_MISO -1 +/*EINK*/ +#define MESHTASTIC_USE_EINK_UI 1 +#define USE_EINK 1 +#define PIN_EINK_CS PIN_SPI1_NSS +#define PIN_EINK_SCLK PIN_SPI1_SCK +#define PIN_EINK_MOSI PIN_SPI1_MOSI +#define PIN_EINK_BUSY (0 + 3) +#define PIN_EINK_DC (0 + 28) +#define PIN_EINK_RES (0 + 2) +// Panel supply is VCC_ELNK_EN; P1.11 is the dimmable frontlight. No PIN_EINK_EN on this board. +#define PIN_PWM_BACKLIGHT (32 + 11) +#define PWM_BACKLIGHT_DEFAULT 128 +#define PWM_BACKLIGHT_MIN 8 +#define PWM_BACKLIGHT_MAX 248 +#define PWM_BACKLIGHT_STEP 20 + +/*Lora radio*/ +#define USE_SX1262 +// PE4259 /CTRL pad, held high for single-pin mode; the SX1262 selects the path from DIO2. +#define SX126X_ANT_SW (0 + 23) +#define SX126X_RESET (0 + 24) // RST +#define SX126X_DIO1 (0 + 25) // IRQ +#define SX126X_DIO2 (32 + 0) // BUSY +#define SX126X_SCK PIN_SPI_SCK +#define SX126X_MISO PIN_SPI_MISO +#define SX126X_MOSI PIN_SPI_MOSI +#define SX126X_CS PIN_SPI_NSS + +#define SX1262_IRQ_PIN SX126X_DIO1 +#define SX1262_NRESET_PIN SX126X_RESET +#define SX126X_BUSY SX126X_DIO2 +#define SX1262_SPI_NSS_PIN SX126X_CS +#define SX1262_SPI_SCK_PIN SX126X_SCK +#define SX1262_SPI_MOSI_PIN SX126X_MOSI +#define SX1262_SPI_MISO_PIN SX126X_MISO +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 3.3 + +/*Serial*/ +#define SERIAL_PRINT_PORT 0 // USB CDC is the StreamAPI transport; no secondary UART + +/*RTC*/ +#define PCF8563_RTC 0x51 + +/*Battert*/ +#define BATTERY_PIN (0 + 4) +#define ADC_V (0 + 4) +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#define BATTERY_SENSE_SAMPLES 100 +#undef AREF_VOLTAGE +#define AREF_VOLTAGE 2.4 +#define VBAT_AR_INTERNAL AR_INTERNAL_2_4 +#define ADC_MULTIPLIER (1.75) + +#ifdef __cplusplus +} +#endif + +#endif \ No newline at end of file From a0c4987c751d23ceb921149a05bc5a126d2bfbf3 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 30 Jul 2026 17:20:54 -0400 Subject: [PATCH 30/30] Narrow "PR" candidates (now only 17) (#11294) These boards should be built when changes are made to the Meshtastic Core. Narrowed signficantly to stop wasting time. --- variants/esp32/rak11200/platformio.ini | 1 - variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini | 1 + variants/esp32s3/elecrow_panel/platformio.ini | 1 - variants/esp32s3/heltec_v4/platformio.ini | 1 - variants/esp32s3/meshnology-w10/platformio.ini | 1 - variants/esp32s3/meshnology-w12/platformio.ini | 1 - variants/esp32s3/rak3312/platformio.ini | 1 - variants/esp32s3/seeed-sensecap-indicator/platformio.ini | 1 + variants/esp32s3/seeed_xiao_s3/platformio.ini | 1 - variants/esp32s3/station-g2/platformio.ini | 1 - variants/esp32s3/station-g3/platformio.ini | 1 - variants/esp32s3/t-eth-elite/platformio.ini | 1 - variants/nrf52840/heltec_mesh_node_t096/platformio.ini | 1 - variants/nrf52840/heltec_mesh_node_t1/platformio.ini | 1 - variants/nrf52840/heltec_mesh_solar/platformio.ini | 1 - variants/nrf52840/heltec_mesh_tower_v2/platformio.ini | 1 - variants/nrf52840/rak_wismeshtag/platformio.ini | 1 - variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini | 1 - variants/nrf52840/t-echo/platformio.ini | 2 -- variants/nrf52840/t-impulse-plus/platformio.ini | 1 - variants/rp2040/rak11310/platformio.ini | 1 - variants/rp2040/rpipico/platformio.ini | 1 - variants/rp2040/seeed_xiao_rp2040/platformio.ini | 1 - variants/rp2350/diy/pico2_w5500_e22/platformio.ini | 2 +- variants/rp2350/diy/wiznet_5500_evb_pico2_e22p/platformio.ini | 2 +- variants/rp2350/rpipico2/platformio.ini | 1 - variants/rp2350/seeed_xiao_rp2350/platformio.ini | 1 - 27 files changed, 4 insertions(+), 26 deletions(-) diff --git a/variants/esp32/rak11200/platformio.ini b/variants/esp32/rak11200/platformio.ini index 2abb9730f..ee704a23e 100644 --- a/variants/esp32/rak11200/platformio.ini +++ b/variants/esp32/rak11200/platformio.ini @@ -10,7 +10,6 @@ custom_meshtastic_tags = RAK extends = esp32_base board = wiscore_rak11200 -board_level = pr board_check = true build_flags = ${esp32_base.build_flags} diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini index 4ee4590bf..ebe31fb52 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -1,6 +1,7 @@ [env:thinknode_m7] extends = esp32s3_base board = ThinkNode-M7 +board_level = pr build_flags = ${esp32s3_base.build_flags} diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index 70858f8a0..b0fc4bcf0 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -127,7 +127,6 @@ custom_meshtastic_partition_scheme = 16MB custom_meshtastic_has_mui = true extends = crowpanel_small_esp32s3_base -board_level = pr build_flags = ${crowpanel_small_esp32s3_base.build_flags} -D LV_CACHE_DEF_SIZE=2097152 diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 04a7bedcd..1cfe1ed14 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -24,7 +24,6 @@ custom_meshtastic_partition_scheme = 16MB custom_meshtastic_has_mui = true extends = heltec_v4_base -board_level = pr build_flags = ${heltec_v4_base.build_flags} -D HELTEC_V4_OLED diff --git a/variants/esp32s3/meshnology-w10/platformio.ini b/variants/esp32s3/meshnology-w10/platformio.ini index 27194a06c..cee86421c 100644 --- a/variants/esp32s3/meshnology-w10/platformio.ini +++ b/variants/esp32s3/meshnology-w10/platformio.ini @@ -10,7 +10,6 @@ custom_meshtastic_partition_scheme = 16MB ; ESP32-S3R8: 8 MB OPI PSRAM, W25Q128JVSIQ = 16 MB QIO flash (pg1) board = esp32-s3-devkitc-1 -board_level = pr board_build.partitions = default_16MB.csv board_upload.flash_size = 16MB board_build.flash_mode = qio diff --git a/variants/esp32s3/meshnology-w12/platformio.ini b/variants/esp32s3/meshnology-w12/platformio.ini index 01574b573..2942abc13 100644 --- a/variants/esp32s3/meshnology-w12/platformio.ini +++ b/variants/esp32s3/meshnology-w12/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_partition_scheme = 16MB extends = esp32s3_base board = esp32-s3-devkitc-1 -board_level = pr ; ESP32-S3R8: 8 MB OPI PSRAM, 16 MB QIO flash board_build.partitions = default_16MB.csv board_upload.flash_size = 16MB diff --git a/variants/esp32s3/rak3312/platformio.ini b/variants/esp32s3/rak3312/platformio.ini index dcdb2974c..fa097c897 100644 --- a/variants/esp32s3/rak3312/platformio.ini +++ b/variants/esp32s3/rak3312/platformio.ini @@ -13,7 +13,6 @@ custom_meshtastic_has_mui = false extends = esp32s3_base board = wiscore_rak3312 -board_level = pr board_check = true upload_protocol = esptool diff --git a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini index b750e7dec..77fb165c5 100644 --- a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini +++ b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini @@ -74,6 +74,7 @@ custom_sdkconfig = [env:seeed-sensecap-indicator-tft] extends = env:seeed-sensecap-indicator +board_level = pr board_check = true upload_speed = 460800 diff --git a/variants/esp32s3/seeed_xiao_s3/platformio.ini b/variants/esp32s3/seeed_xiao_s3/platformio.ini index ee457eaf0..85f2c1b9b 100644 --- a/variants/esp32s3/seeed_xiao_s3/platformio.ini +++ b/variants/esp32s3/seeed_xiao_s3/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_partition_scheme = 8MB extends = esp32s3_base board = seeed-xiao-s3 -board_level = pr board_check = true board_build.partitions = default_8MB.csv upload_protocol = esptool diff --git a/variants/esp32s3/station-g2/platformio.ini b/variants/esp32s3/station-g2/platformio.ini index 9c6b71cec..c874ce862 100755 --- a/variants/esp32s3/station-g2/platformio.ini +++ b/variants/esp32s3/station-g2/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_partition_scheme = 16MB extends = esp32s3_base board = station-g2 -board_level = pr board_check = true board_build.partitions = default_16MB.csv board_build.mcu = esp32s3 diff --git a/variants/esp32s3/station-g3/platformio.ini b/variants/esp32s3/station-g3/platformio.ini index f94809ddf..ee5ee307e 100644 --- a/variants/esp32s3/station-g3/platformio.ini +++ b/variants/esp32s3/station-g3/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_partition_scheme = 16MB extends = esp32s3_base board = station-g3 -board_level = pr board_check = true board_build.partitions = default_16MB.csv board_build.mcu = esp32s3 diff --git a/variants/esp32s3/t-eth-elite/platformio.ini b/variants/esp32s3/t-eth-elite/platformio.ini index 5b1f01db7..2ed698b0d 100644 --- a/variants/esp32s3/t-eth-elite/platformio.ini +++ b/variants/esp32s3/t-eth-elite/platformio.ini @@ -1,7 +1,6 @@ [env:t-eth-elite] extends = esp32s3_base board = esp32s3box -board_level = pr board_check = true board_build.partitions = default_16MB.csv build_flags = diff --git a/variants/nrf52840/heltec_mesh_node_t096/platformio.ini b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini index 03aa03310..98ca481c2 100644 --- a/variants/nrf52840/heltec_mesh_node_t096/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_tags = Heltec extends = nrf52840_base board = heltec_mesh_node_t096 -board_level = pr debug_tool = jlink build_flags = ${nrf52840_base.build_flags} diff --git a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini index 7d822685a..154fa2f35 100644 --- a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_tags = Heltec extends = nrf52840_base board = heltec_mesh_node_t1 -board_level = pr debug_tool = jlink build_flags = ${nrf52840_base.build_flags} diff --git a/variants/nrf52840/heltec_mesh_solar/platformio.ini b/variants/nrf52840/heltec_mesh_solar/platformio.ini index 536045cfa..01af41a5f 100644 --- a/variants/nrf52840/heltec_mesh_solar/platformio.ini +++ b/variants/nrf52840/heltec_mesh_solar/platformio.ini @@ -2,7 +2,6 @@ [heltec_mesh_solar_base] extends = nrf52840_base board = heltec_mesh_solar -board_level = pr debug_tool = jlink # add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling. diff --git a/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini b/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini index 05f3f49d4..9b7090677 100644 --- a/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini +++ b/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_tags = Heltec extends = nrf52840_base board = heltec_mesh_tower_v2 -board_level = pr debug_tool = jlink # add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling. diff --git a/variants/nrf52840/rak_wismeshtag/platformio.ini b/variants/nrf52840/rak_wismeshtag/platformio.ini index 9b0d665b3..9e819ab91 100644 --- a/variants/nrf52840/rak_wismeshtag/platformio.ini +++ b/variants/nrf52840/rak_wismeshtag/platformio.ini @@ -6,7 +6,6 @@ custom_meshtastic_tags = RAK extends = nrf52840_base board = wiscore_rak4631 -board_level = pr board_check = true custom_meshtastic_hw_model = 105 custom_meshtastic_hw_model_slug = WISMESH_TAG diff --git a/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini b/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini index 7018d054e..0fa09d407 100644 --- a/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini +++ b/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_tags = Seeed extends = nrf52840_base board = xiao_ble_sense -board_level = pr build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/seeed_xiao_nrf52840_kit -I src/platform/nrf52/softdevice diff --git a/variants/nrf52840/t-echo/platformio.ini b/variants/nrf52840/t-echo/platformio.ini index 58ad029ae..40396e1a5 100644 --- a/variants/nrf52840/t-echo/platformio.ini +++ b/variants/nrf52840/t-echo/platformio.ini @@ -12,7 +12,6 @@ custom_meshtastic_has_ink_hud = true extends = nrf52840_base board = t-echo -board_level = pr board_check = true debug_tool = jlink @@ -39,7 +38,6 @@ lib_deps = [env:t-echo-inkhud] extends = nrf52840_base, inkhud board = t-echo -board_level = pr board_check = true debug_tool = jlink build_flags = diff --git a/variants/nrf52840/t-impulse-plus/platformio.ini b/variants/nrf52840/t-impulse-plus/platformio.ini index 337b586dc..428724099 100644 --- a/variants/nrf52840/t-impulse-plus/platformio.ini +++ b/variants/nrf52840/t-impulse-plus/platformio.ini @@ -10,7 +10,6 @@ custom_meshtastic_requires_dfu = true extends = nrf52840_base board = t-impulse-plus -board_level = pr build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/t-impulse-plus diff --git a/variants/rp2040/rak11310/platformio.ini b/variants/rp2040/rak11310/platformio.ini index d9bd5cb16..1b1f636a6 100644 --- a/variants/rp2040/rak11310/platformio.ini +++ b/variants/rp2040/rak11310/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_requires_dfu = true extends = rp2040_base board = rakwireless_rak11300 -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths build_flags = diff --git a/variants/rp2040/rpipico/platformio.ini b/variants/rp2040/rpipico/platformio.ini index 4ae134b28..c07d2d24f 100644 --- a/variants/rp2040/rpipico/platformio.ini +++ b/variants/rp2040/rpipico/platformio.ini @@ -11,7 +11,6 @@ custom_meshtastic_requires_dfu = true extends = rp2040_base board = rpipico -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths build_flags = diff --git a/variants/rp2040/seeed_xiao_rp2040/platformio.ini b/variants/rp2040/seeed_xiao_rp2040/platformio.ini index 985b573a9..1aad30a3d 100644 --- a/variants/rp2040/seeed_xiao_rp2040/platformio.ini +++ b/variants/rp2040/seeed_xiao_rp2040/platformio.ini @@ -1,7 +1,6 @@ [env:seeed_xiao_rp2040] extends = rp2040_base board = seeed_xiao_rp2040 -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths build_src_filter = ${rp2040_base.build_src_filter} +<../variants/rp2040/seeed_xiao_rp2040/> diff --git a/variants/rp2350/diy/pico2_w5500_e22/platformio.ini b/variants/rp2350/diy/pico2_w5500_e22/platformio.ini index b62dbcfb5..1a7f51b7b 100644 --- a/variants/rp2350/diy/pico2_w5500_e22/platformio.ini +++ b/variants/rp2350/diy/pico2_w5500_e22/platformio.ini @@ -1,7 +1,7 @@ [env:pico2_w5500_e22] extends = rp2350_base board = rpipico2 -board_level = community +board_level = extra upload_protocol = picotool # Increase LittleFS from 0.5m to 0.75m so GZIP firmware (~614KB) fits for OTA staging diff --git a/variants/rp2350/diy/wiznet_5500_evb_pico2_e22p/platformio.ini b/variants/rp2350/diy/wiznet_5500_evb_pico2_e22p/platformio.ini index 62bde41eb..12cc37c38 100644 --- a/variants/rp2350/diy/wiznet_5500_evb_pico2_e22p/platformio.ini +++ b/variants/rp2350/diy/wiznet_5500_evb_pico2_e22p/platformio.ini @@ -1,7 +1,7 @@ [env:wiznet_5500_evb_pico2_e22p] extends = rp2350_base board = wiznet_5500_evb_pico2 -board_level = community +board_level = extra upload_protocol = picotool # Reuses the variant.h from variants/rp2350/diy/pico2_w5500_e22 - same LoRa diff --git a/variants/rp2350/rpipico2/platformio.ini b/variants/rp2350/rpipico2/platformio.ini index 30dc15256..2ff65c9cd 100644 --- a/variants/rp2350/rpipico2/platformio.ini +++ b/variants/rp2350/rpipico2/platformio.ini @@ -1,7 +1,6 @@ [env:pico2] extends = rp2350_base board = rpipico2 -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths diff --git a/variants/rp2350/seeed_xiao_rp2350/platformio.ini b/variants/rp2350/seeed_xiao_rp2350/platformio.ini index 20f85876f..c9d85af74 100644 --- a/variants/rp2350/seeed_xiao_rp2350/platformio.ini +++ b/variants/rp2350/seeed_xiao_rp2350/platformio.ini @@ -1,7 +1,6 @@ [env:seeed_xiao_rp2350] extends = rp2350_base board = seeed_xiao_rp2350 -board_level = pr upload_protocol = picotool # add our variants files to the include and src paths