From 80d16234e500ad9cfcf9e66393d2d3a0632e2cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 10 Aug 2026 14:39:25 +0200 Subject: [PATCH 001/109] fix(lora): skip DIO detach when no ISR is attached (#11386) * fix(lora): skip DIO detach when no ISR is attached Fixes #11371 * fix(lora): latch the ISR-armed flag instead of tracking attach state The flag is now written once from task context and only read from ISR context. --- src/mesh/LR11x0Interface.cpp | 2 +- src/mesh/LR11x0Interface.h | 4 ++-- src/mesh/LR20x0Interface.cpp | 2 +- src/mesh/LR20x0Interface.h | 4 ++-- src/mesh/RF95Interface.cpp | 2 +- src/mesh/RF95Interface.h | 4 ++-- src/mesh/RadioLibInterface.h | 24 ++++++++++++++++++++++-- src/mesh/SX126xInterface.cpp | 4 ++-- src/mesh/SX126xInterface.h | 4 ++-- src/mesh/SX128xInterface.cpp | 2 +- src/mesh/SX128xInterface.h | 4 ++-- 11 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index b8e18bf57..8be0b6413 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -318,7 +318,7 @@ template bool LR11x0Interface::reconfigure() return true; } -template void LR11x0Interface::disableInterrupt() +template void LR11x0Interface::clearRadioIsr() { lora.clearIrqAction(); } diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index 552dd5e5e..9280c05de 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -47,12 +47,12 @@ template class LR11x0Interface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) { lora.setIrqAction(callback); } + virtual void setRadioIsr(void (*callback)()) override { lora.setIrqAction(callback); } /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index fc6d12347..dcc514041 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -323,7 +323,7 @@ template bool LR20x0Interface::reconfigure() return success; } -template void LR20x0Interface::disableInterrupt() +template void LR20x0Interface::clearRadioIsr() { lora.clearIrqAction(); } diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index 263c83429..ed04dfb0e 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -42,12 +42,12 @@ template class LR20x0Interface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) { lora.setIrqAction(callback); } + virtual void setRadioIsr(void (*callback)()) override { lora.setIrqAction(callback); } /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 54fd6f109..6968b5654 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -201,7 +201,7 @@ bool RF95Interface::init() return res == RADIOLIB_ERR_NONE; } -void RF95Interface::disableInterrupt() +void RF95Interface::clearRadioIsr() { lora->clearDio0Action(); } diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index 222606764..e01dfe376 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -35,14 +35,14 @@ class RF95Interface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; int16_t getCurrentRSSI() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) { lora->setDio0Action(callback, RISING); } + virtual void setRadioIsr(void (*callback)()) override { lora->setDio0Action(callback, RISING); } /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 295ccc160..82471e760 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -99,6 +99,9 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified /// are _trying_ to receive a packet currently (note - we might just be waiting for one) bool isReceiving = false; + /// has the radio IRQ ever been armed? latches true and is never cleared, so ISR context only reads it + volatile bool isrEverArmed = false; + protected: // Noise floor tracking - rolling window of samples. static const uint8_t NOISE_FLOOR_SAMPLES = 20; @@ -144,13 +147,26 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified /** * Glue functions called from ISR land + * + * Skip the detach until the IRQ has been armed once: the first setStandby() runs before any + * enableInterrupt(), and ESP-IDF logs "GPIO isr service is not installed" for that call. */ - virtual void disableInterrupt() = 0; + void disableInterrupt() + { + if (!isrEverArmed) + return; + clearRadioIsr(); + } /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*)()) = 0; + void enableInterrupt(void (*callback)()) + { + // Latch before arming: the ISR can fire the moment the handler is installed. + isrEverArmed = true; + setRadioIsr(callback); + } /** * Poll as a backup to catch missed edge-triggered interrupts. @@ -300,6 +316,10 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ virtual void addReceiveMetadata(meshtastic_MeshPacket *mp) = 0; + /** Chip specific arm/disarm of the radio IRQ; call enableInterrupt()/disableInterrupt() instead */ + virtual void setRadioIsr(void (*callback)()) = 0; + virtual void clearRadioIsr() = 0; + /** * Subclasses must override, implement and then call into this base class implementation */ diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index a06d66cc4..e8d5baf10 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -250,7 +250,7 @@ template int16_t SX126xInterface::getCurrentRSSI() return (int16_t)round(rssi); } -template void SX126xInterface::enableInterrupt(void (*callback)()) +template void SX126xInterface::setRadioIsr(void (*callback)()) { #ifdef LORA_DIO1_SOFTWARE_POLL irqPollingActive = true; @@ -261,7 +261,7 @@ template void SX126xInterface::enableInterrupt(void (*callback)( #endif } -template void SX126xInterface::disableInterrupt() +template void SX126xInterface::clearRadioIsr() { #ifdef LORA_DIO1_SOFTWARE_POLL irqPollingActive = false; diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index 0bf977ba2..9465064b8 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -47,12 +47,12 @@ template class SX126xInterface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) override; + virtual void setRadioIsr(void (*callback)()) override; #ifdef LORA_DIO1_SOFTWARE_POLL void handleSoftwareLoraIrqPoll() override; diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 7848d51db..bb1d89024 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -156,7 +156,7 @@ template bool SX128xInterface::reconfigure() return true; } -template void SX128xInterface::disableInterrupt() +template void SX128xInterface::clearRadioIsr() { lora.clearDio1Action(); } diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index 1205087b7..3b9015249 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -43,12 +43,12 @@ template class SX128xInterface : public RadioLibInterface /** * Glue functions called from ISR land */ - virtual void disableInterrupt() override; + virtual void clearRadioIsr() override; /** * Enable a particular ISR callback glue function */ - virtual void enableInterrupt(void (*callback)()) { lora.setDio1Action(callback); } + virtual void setRadioIsr(void (*callback)()) override { lora.setDio1Action(callback); } /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; From b1132cfec978b1e4477947b2eb9606cb996c583a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 10 Aug 2026 14:39:01 +0200 Subject: [PATCH 002/109] fix static IP on wired ETH (#11385) * fix https://github.com/meshtastic/firmware/issues/11370 * address coderabbit review --- src/mesh/wifi/WiFiAPClient.cpp | 63 ++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index f166660d5..c7fc1b25f 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -98,21 +98,40 @@ static int32_t ethNetworkConnectedPoll() } #endif +#if defined(USE_WS5500) || defined(USE_CH390D) +// Needs the netif, so only valid after ETH.begin(). Failures fall back to DHCP +static void applyEthStaticIp() +{ + if (config.network.address_mode != meshtastic_Config_NetworkConfig_AddressMode_STATIC) + return; + + if (config.network.ipv4_config.ip == 0) + LOG_WARN("Static address mode but no IP configured, using DHCP"); + else if (!ETH.config(config.network.ipv4_config.ip, config.network.ipv4_config.gateway, config.network.ipv4_config.subnet, + config.network.ipv4_config.dns)) + LOG_ERROR("Failed to apply static IP to Ethernet, using DHCP"); +} +#endif + #ifdef USE_WS5500 // Startup Ethernet bool initEthernet() { - if ((config.network.eth_enabled) && (ETH.begin(ETH_PHY_W5500, 1, ETH_CS_PIN, ETH_INT_PIN, ETH_RST_PIN, SPI3_HOST, - ETH_SCLK_PIN, ETH_MISO_PIN, ETH_MOSI_PIN))) { - WiFi.onEvent(WiFiEvent); -#if !MESHTASTIC_EXCLUDE_WEBSERVER - createSSLCert(); // For WebServer -#endif - new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); - return true; - } + if (!config.network.eth_enabled) + return false; - return false; + // Register before begin(): static config can fire ETH_GOT_IP immediately + WiFi.onEvent(WiFiEvent); + + if (!ETH.begin(ETH_PHY_W5500, 1, ETH_CS_PIN, ETH_INT_PIN, ETH_RST_PIN, SPI3_HOST, ETH_SCLK_PIN, ETH_MISO_PIN, ETH_MOSI_PIN)) + return false; + + applyEthStaticIp(); +#if !MESHTASTIC_EXCLUDE_WEBSERVER + createSSLCert(); // For WebServer +#endif + new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); + return true; } #endif @@ -120,6 +139,9 @@ bool initEthernet() // Startup Ethernet bool initEthernet() { + if (!config.network.eth_enabled) + return false; + // Configure CH390 ch390_config_t ch390_conf = CH390_DEFAULT_CONFIG(); ch390_conf.spi_host = SPI3_HOST; @@ -134,16 +156,19 @@ bool initEthernet() ch390_conf.reset_gpio = -1; #endif ch390_conf.spi_clock_mhz = 20; - if ((config.network.eth_enabled) && (ETH.begin(ch390_conf))) { - WiFi.onEvent(WiFiEvent); -#if !MESHTASTIC_EXCLUDE_WEBSERVER - createSSLCert(); // For WebServer -#endif - new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); - return true; - } - return false; + // Register before begin(): static config can fire ETH_GOT_IP immediately + WiFi.onEvent(WiFiEvent); + + if (!ETH.begin(ch390_conf)) + return false; + + applyEthStaticIp(); +#if !MESHTASTIC_EXCLUDE_WEBSERVER + createSSLCert(); // For WebServer +#endif + new concurrency::Periodic("EthConnect", ethNetworkConnectedPoll); + return true; } #endif From 37d35c74f2d89e3c30c34da270e4ebb0fec7236a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 10 Aug 2026 14:48:31 +0200 Subject: [PATCH 003/109] Uncomment CALIBRATE_TOUCH in platformio.ini (#11387) replaces #11349 --- variants/esp32s3/t-deck/platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index be0be7d02..047371db9 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -69,7 +69,7 @@ build_flags = -D RADIOLIB_DEBUG_SPI=0 -D RADIOLIB_DEBUG_PROTOCOL=0 -D RADIOLIB_SPI_PARANOID=0 -; -D CALIBRATE_TOUCH=0 + -D CALIBRATE_TOUCH=0 -D LGFX_SCREEN_WIDTH=240 -D LGFX_SCREEN_HEIGHT=320 -D LGFX_BUFSIZE=153600 From 87857e767231ec5e5a23c661350b52b9b2440465 Mon Sep 17 00:00:00 2001 From: Tadayoshi MIURA <11958457+t-miura@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:19:29 +0900 Subject: [PATCH 004/109] Update earlephilhower/arduino-pico to 6.0.0 on rp2350 as well (#11383) --- variants/rp2350/rp2350.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index 3cff5534d..0705ed8ec 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -7,7 +7,7 @@ platform = extends = arduino_base platform_packages = # TODO renovate - arduino-pico@https://github.com/earlephilhower/arduino-pico/releases/download/5.7.0/rp2040-5.7.0.zip + arduino-pico@https://github.com/earlephilhower/arduino-pico/releases/download/6.0.0/rp2040-6.0.0.zip board_build.core = earlephilhower board_build.filesystem_size = 0.5m From ad1dd14b6cd977dfcc1ab11cb112d7ce9cfedf52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 10 Aug 2026 17:48:41 +0200 Subject: [PATCH 005/109] fix(bin): repair the Windows device-install and device-update scripts (#11388) * fix(bin): correct filename check and esptool v5 subcommands in .bat installers device-install.bat rejected every valid firmware-*.factory.bin name. The substring-strip comparison was negated, so it errored when the suffix was present instead of when it was absent. Both scripts hardcoded esptool subcommand spellings. device-install.bat used the v4 underscore forms only. device-update.bat used the v5 write-flash with the v4 read_flash_status, so it worked fully on neither version. Probe the help output once and select the spelling, mirroring bin/device-install.sh. The probe uses %ESPTOOL_CMD% rather than !ESPTOOL_CMD! because cmd does not split a delayed-expanded command token carrying a path into program and arguments. Fixes #8156 * fix(bin): make the -P interpreter option work in the .bat installers Both scripts invoked ESPTOOL_CMD through delayed expansion. cmd does not split a delayed-expanded command token that carries a path into program and arguments, so "-P C:\path\python.exe" exited 9009 and the scripts reported "esptool not found". Use %ESPTOOL_CMD% at the two command positions per file. device-update.bat additionally wrapped the interpreter in doubled quotes, which made python treat python.exe as a source file. Quote the path once, as device-install.bat does, so interpreter paths containing spaces also work. * fix(bin): anchor the .factory.bin suffix check and harden esptool detection The filename check matched .factory.bin anywhere in the name, so firmware-x.factory.bin.bak passed and the script then derived firmware-x.bak.mt.json for metadata. Compare the last 12 characters instead, matching the anchored glob in bin/device-install.sh. A quoted interpreter path that does not exist returns 3 rather than 9009, so the missing-esptool check skipped it and the script died at the probe with no message. Treat 3 as missing as well. device-update.bat read %ERRORLEVEL% after a CALL that overwrote it, so the missing-esptool check never fired. Capture the exit code before logging it. --- bin/device-install.bat | 41 ++++++++++++++++++++++++++++++----------- bin/device-update.bat | 35 +++++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 19 deletions(-) diff --git a/bin/device-install.bat b/bin/device-install.bat index 69469d581..a4e595311 100755 --- a/bin/device-install.bat +++ b/bin/device-install.bat @@ -70,7 +70,7 @@ IF "__!FILENAME!__"=="____" ( CALL :LOG_MESSAGE ERROR "Filename containing spaces are not supported." GOTO help ) - IF NOT "__!FILENAME:.factory.bin=!__"=="__!FILENAME!__" ( + IF /I NOT "!FILENAME:~-12!"==".factory.bin" ( CALL :LOG_MESSAGE ERROR "Filename must be a firmware-*.factory.bin file." GOTO help ) @@ -111,7 +111,7 @@ IF EXIST !METAFILE! ( CALL :LOG_MESSAGE DEBUG "Determine the correct esptool command to use..." IF NOT "__%PYTHON%__"=="____" ( - SET "ESPTOOL_CMD=!PYTHON! -m esptool" + SET "ESPTOOL_CMD="!PYTHON!" -m esptool" CALL :LOG_MESSAGE DEBUG "Python interpreter supplied." ) ELSE ( CALL :LOG_MESSAGE DEBUG "Python interpreter NOT supplied. Looking for esptool..." @@ -126,12 +126,31 @@ IF NOT "__%PYTHON%__"=="____" ( ) CALL :LOG_MESSAGE DEBUG "Checking esptool command !ESPTOOL_CMD!..." -!ESPTOOL_CMD! >nul 2>&1 -IF %ERRORLEVEL% EQU 9009 ( - @REM 9009 = command not found on Windows +@REM %VAR% not !VAR!: cmd will not split a delayed-expanded command token that +@REM carries a path, so the "python -m esptool" form never starts. +%ESPTOOL_CMD% >nul 2>&1 +SET "ESPTOOL_EXIT=!ERRORLEVEL!" +@REM 9009 = command not found, 3 = bad path from -P. Both mean unusable. +IF !ESPTOOL_EXIT! EQU 3 SET "ESPTOOL_EXIT=9009" +IF !ESPTOOL_EXIT! EQU 9009 ( CALL :LOG_MESSAGE ERROR "esptool not found: !ESPTOOL_CMD!" EXIT /B 1 ) + +@REM esptool v5 renamed subcommands to dashes; older versions only take underscores. +@REM Probe here: the --debug and --port rewrites below leave ESPTOOL_CMD unusable. +SET "ESPTOOL_WRITE_FLASH=write_flash" +SET "ESPTOOL_ERASE_FLASH=erase_flash" +SET "ESPTOOL_READ_FLASH_STATUS=read_flash_status" +%ESPTOOL_CMD% 2>&1 | findstr /C:"write-flash" >nul +IF !ERRORLEVEL! EQU 0 ( + SET "ESPTOOL_WRITE_FLASH=write-flash" + SET "ESPTOOL_ERASE_FLASH=erase-flash" + SET "ESPTOOL_READ_FLASH_STATUS=read-flash-status" +) +CALL :RESET_ERROR +CALL :LOG_MESSAGE DEBUG "Using esptool write command: !ESPTOOL_WRITE_FLASH!" + IF %DEBUG% EQU 1 ( CALL :LOG_MESSAGE DEBUG "Skipping ESPTOOL_CMD steps." SET "ESPTOOL_CMD=REM !ESPTOOL_CMD!" @@ -148,7 +167,7 @@ CALL :LOG_MESSAGE INFO "Using esptool baud: !ESPTOOL_BAUD!." IF %BPS_RESET% EQU 1 ( @REM Attempt to change mode via 1200bps Reset. - CALL :RUN_ESPTOOL 1200 --after no_reset read_flash_status + CALL :RUN_ESPTOOL 1200 --after no_reset !ESPTOOL_READ_FLASH_STATUS! GOTO eof ) @@ -174,14 +193,14 @@ IF NOT EXIST !SPIFFS_FILENAME! CALL :LOG_MESSAGE ERROR "File does not exist: "!S @REM Flashing operations. CALL :LOG_MESSAGE INFO "Trying to flash "!FILENAME!", but first erasing and writing system information..." -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! erase_flash || GOTO eof -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash 0x00 "!FILENAME!" || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_ERASE_FLASH! || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! 0x00 "!FILENAME!" || GOTO eof CALL :LOG_MESSAGE INFO "Trying to flash BLEOTA "!OTA_FILENAME!" at OTA_OFFSET !OTA_OFFSET!..." -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash !OTA_OFFSET! "!OTA_FILENAME!" || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! !OTA_OFFSET! "!OTA_FILENAME!" || GOTO eof CALL :LOG_MESSAGE INFO "Trying to flash SPIFFS "!SPIFFS_FILENAME!" at SPIFFS_OFFSET !SPIFFS_OFFSET!..." -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write_flash !SPIFFS_OFFSET! "!SPIFFS_FILENAME!" || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! !SPIFFS_OFFSET! "!SPIFFS_FILENAME!" || GOTO eof CALL :LOG_MESSAGE INFO "Script complete!." @@ -198,7 +217,7 @@ EXIT /B %ERRORLEVEL% @REM Example:: CALL :RUN_ESPTOOL 115200 write_flash 0x10000 "firmwarefile.bin" IF %DEBUG% EQU 1 CALL :LOG_MESSAGE DEBUG "About to run command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4" CALL :RESET_ERROR -!ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4 +%ESPTOOL_CMD% --baud %~1 %~2 %~3 %~4 IF %BPS_RESET% EQU 1 GOTO :eof IF %ERRORLEVEL% NEQ 0 ( CALL :LOG_MESSAGE ERROR "Error running command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4" diff --git a/bin/device-update.bat b/bin/device-update.bat index a9f7a9e1e..e76ae946a 100755 --- a/bin/device-update.bat +++ b/bin/device-update.bat @@ -90,7 +90,7 @@ IF NOT "__!FILENAME:.factory.bin=!__"=="__!FILENAME!__" ( CALL :LOG_MESSAGE DEBUG "Determine the correct esptool command to use..." IF NOT "__%PYTHON%__"=="____" ( - SET "ESPTOOL_CMD=""!PYTHON!"" -m esptool" + SET "ESPTOOL_CMD="!PYTHON!" -m esptool" CALL :LOG_MESSAGE DEBUG "Python interpreter supplied." ) ELSE ( CALL :LOG_MESSAGE DEBUG "Python interpreter NOT supplied. Looking for esptool..." @@ -105,13 +105,32 @@ IF NOT "__%PYTHON%__"=="____" ( ) CALL :LOG_MESSAGE DEBUG "Checking esptool command !ESPTOOL_CMD!..." -!ESPTOOL_CMD! >nul 2>&1 -CALL :LOG_MESSAGE DEBUG "esptool exit code: %ERRORLEVEL%" -IF %ERRORLEVEL% EQU 9009 ( - @REM 9009 = command not found on Windows +@REM %VAR% not !VAR!: cmd will not split a delayed-expanded command token that +@REM carries a path, so the "python -m esptool" form never starts. +%ESPTOOL_CMD% >nul 2>&1 +SET "ESPTOOL_EXIT=!ERRORLEVEL!" +CALL :LOG_MESSAGE DEBUG "esptool exit code: !ESPTOOL_EXIT!" +@REM 9009 = command not found, 3 = bad path from -P. Both mean unusable. +IF !ESPTOOL_EXIT! EQU 3 SET "ESPTOOL_EXIT=9009" +IF !ESPTOOL_EXIT! EQU 9009 ( CALL :LOG_MESSAGE ERROR "esptool not found: !ESPTOOL_CMD!" EXIT /B 1 ) + +@REM esptool v5 renamed subcommands to dashes; older versions only take underscores. +@REM Probe here: the --debug and --port rewrites below leave ESPTOOL_CMD unusable. +SET "ESPTOOL_WRITE_FLASH=write_flash" +SET "ESPTOOL_ERASE_FLASH=erase_flash" +SET "ESPTOOL_READ_FLASH_STATUS=read_flash_status" +%ESPTOOL_CMD% 2>&1 | findstr /C:"write-flash" >nul +IF !ERRORLEVEL! EQU 0 ( + SET "ESPTOOL_WRITE_FLASH=write-flash" + SET "ESPTOOL_ERASE_FLASH=erase-flash" + SET "ESPTOOL_READ_FLASH_STATUS=read-flash-status" +) +CALL :RESET_ERROR +CALL :LOG_MESSAGE DEBUG "Using esptool write command: !ESPTOOL_WRITE_FLASH!" + IF %DEBUG% EQU 1 ( CALL :LOG_MESSAGE DEBUG "Skipping ESPTOOL_CMD steps." SET "ESPTOOL_CMD=REM !ESPTOOL_CMD!" @@ -128,13 +147,13 @@ CALL :LOG_MESSAGE INFO "Using esptool baud: !ESPTOOL_BAUD!." IF %CHANGE_MODE% EQU 1 ( @REM Attempt to change mode via 1200bps Reset. - CALL :RUN_ESPTOOL !RESET_BAUD! --after no_reset read_flash_status + CALL :RUN_ESPTOOL !RESET_BAUD! --after no_reset !ESPTOOL_READ_FLASH_STATUS! GOTO eof ) @REM Flashing operations. CALL :LOG_MESSAGE INFO "Trying to flash update "!FILENAME!" at OFFSET !UPDATE_OFFSET!..." -CALL :RUN_ESPTOOL !ESPTOOL_BAUD! write-flash !UPDATE_OFFSET! "!FILENAME!" || GOTO eof +CALL :RUN_ESPTOOL !ESPTOOL_BAUD! !ESPTOOL_WRITE_FLASH! !UPDATE_OFFSET! "!FILENAME!" || GOTO eof CALL :LOG_MESSAGE INFO "Script complete!." @@ -151,7 +170,7 @@ EXIT /B %ERRORLEVEL% @REM Example:: CALL :RUN_ESPTOOL 115200 write-flash 0x10000 "firmwarefile.bin" IF %DEBUG% EQU 1 CALL :LOG_MESSAGE DEBUG "About to run command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4" CALL :RESET_ERROR -!ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4 +%ESPTOOL_CMD% --baud %~1 %~2 %~3 %~4 IF %CHANGE_MODE% EQU 1 GOTO :eof IF %ERRORLEVEL% NEQ 0 ( CALL :LOG_MESSAGE ERROR "Error running command: !ESPTOOL_CMD! --baud %~1 %~2 %~3 %~4" From fa87730e7a02e4b2781e527829c46244d22e2098 Mon Sep 17 00:00:00 2001 From: Giacomo Di Ciocco <54034335+gdiciocco@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:49:40 +0200 Subject: [PATCH 006/109] fix(rak4631_eth_gw): handle multicast socket exhaustion (#11132) --- src/platform/nrf52/AsyncUDP.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/platform/nrf52/AsyncUDP.cpp b/src/platform/nrf52/AsyncUDP.cpp index 8c937d71f..5431597c5 100644 --- a/src/platform/nrf52/AsyncUDP.cpp +++ b/src/platform/nrf52/AsyncUDP.cpp @@ -8,8 +8,9 @@ bool AsyncUDP::listenMulticast(IPAddress multicastIP, uint16_t port, uint8_t ttl { if (!isMulticast(multicastIP)) return false; + if (!udp.beginMulticast(multicastIP, port)) + return false; localPort = port; - udp.beginMulticast(multicastIP, port); return true; } @@ -80,4 +81,4 @@ int32_t AsyncUDP::runOnce() return 5; // check every 5ms } -#endif // HAS_ETHERNET \ No newline at end of file +#endif // HAS_ETHERNET From b8dee13d0b5bd71a441cdd23f41b7d62c5311298 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 10 Aug 2026 17:06:00 -0700 Subject: [PATCH 007/109] fix(BaseUI): never render banner font tags ([S]/[M]/[L]) as literal text (#11392) The nRF52 BLE pairing banner sends "Bluetooth\nPIN\n[M]" where [M] is a medium-font marker for the PIN line. The renderer only honored font tags for lines covered by the parsed-line cache, so any path that draws a banner line from the raw message - a stored-without-reparse banner, or a draw racing the parse from the BLE task - showed a literal "[M]" ahead of the pairing PIN. Extract the per-line text/font decision into resolveBannerLine(): parsed (tag-stripped) line when the cache covers it, otherwise strip a leading font tag from the raw line on the fly and honor the font it names. Picker content (e.g. node names) is deliberately exempt - it can be user data and must never be tag-interpreted. Also measure line widths on the text actually rendered (they were measured on the raw, un-stripped string, skewing box width and centering), and check p[1] for NUL before reading p[2] in the tag probe, which could read one byte past the end of a line that ends in '['. Add a native test suite (test_banner_font_tags) covering tag parsing, the BLE pairing message, the cache-miss fallback, and the picker exemption. Claude-Session: https://claude.ai/code/session_01CFMdkE6d8fY28Ax3hFVDMU Co-authored-by: Claude --- src/graphics/draw/NotificationRenderer.cpp | 35 ++-- src/graphics/draw/NotificationRenderer.h | 4 + test/native-suite-count | 2 +- test/test_banner_font_tags/test_main.cpp | 176 +++++++++++++++++++++ 4 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 test/test_banner_font_tags/test_main.cpp diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 9aa70fd81..d71ca6d08 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -84,7 +84,7 @@ static inline graphics::NotificationRenderer::BannerFont parseFontTagPrefix(cons { // Tags must be at the start of the line: // [S] small, [M] medium, [L] large - if (p && p[0] == '[' && p[2] == ']' && p[1] != '\0') { + if (p && p[0] == '[' && p[1] != '\0' && p[2] == ']') { char t = p[1]; if (t == 'S') { p += 3; @@ -136,6 +136,26 @@ static inline uint8_t effectiveLineHeightForBannerLine(graphics::NotificationRen return (height > 3) ? (height - 3) : height; } +const char *graphics::NotificationRenderer::resolveBannerLine(uint16_t lineIndex, const char *rawLine, BannerFont &lineFont) +{ + lineFont = BANNER_FONT_DEFAULT; + bool tagAware = (current_notification_type == notificationTypeEnum::text_banner || + current_notification_type == notificationTypeEnum::pairing_pin) && + alertBannerOptions == 0; + if (!tagAware) + return rawLine; + if (lineIndex < alertBannerLineCount) { + lineFont = alertBannerLineFonts[lineIndex]; + return alertBannerLines[lineIndex]; + } + // The parsed-line cache doesn't cover this line (the banner text was stored without a + // re-parse, or a draw raced the parse from another task): strip the tag here too, so it + // acts as a font change and never renders as literal text - the BLE pair PIN banner + // prefixes its PIN line with [M]. + lineFont = parseFontTagPrefix(rawLine); + return rawLine; +} + void graphics::NotificationRenderer::parseBannerMessageWithFonts(const char *message) { alertBannerLineCount = 0; @@ -845,9 +865,6 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay BannerFont lineFonts[totalLines] = {}; uint8_t lineEffectiveHeights[totalLines] = {0}; const char *renderLines[totalLines] = {0}; - bool useTaggedBannerFonts = (current_notification_type == notificationTypeEnum::text_banner || - current_notification_type == notificationTypeEnum::pairing_pin) && - alertBannerOptions == 0; if (maxWidth != 0) is_picker = true; @@ -860,12 +877,8 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay uint16_t widestLineWithBars = 0; while (lines[lineCount] != nullptr) { - const char *renderText = lines[lineCount]; BannerFont lineFont = BANNER_FONT_DEFAULT; - if (useTaggedBannerFonts && lineCount < alertBannerLineCount) { - renderText = alertBannerLines[lineCount]; - lineFont = alertBannerLineFonts[lineCount]; - } + const char *renderText = resolveBannerLine(lineCount, lines[lineCount], lineFont); renderLines[lineCount] = renderText; lineFonts[lineCount] = lineFont; lineEffectiveHeights[lineCount] = effectiveLineHeightForBannerLine(lineFont); @@ -879,10 +892,10 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay if (current_notification_type == notificationTypeEnum::node_picker) { char measureBuffer[64] = {0}; - strncpy(measureBuffer, lines[lineCount], std::min(lineLengths[lineCount], sizeof(measureBuffer) - 1)); + strncpy(measureBuffer, renderText, std::min(lineLengths[lineCount], sizeof(measureBuffer) - 1)); lineWidths[lineCount] = UIRenderer::measureStringWithEmotes(display, measureBuffer); } else { - lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true); + lineWidths[lineCount] = display->getStringWidth(renderText, lineLengths[lineCount], true); } // Consider extra width for signal bars on lines that contain "Signal:" diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 4febe07fc..d4b7781d5 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -38,6 +38,10 @@ class NotificationRenderer static uint8_t alertBannerLineCount; static BannerFont alertBannerLineFonts[MAX_LINES + 1]; static void parseBannerMessageWithFonts(const char *message); + // Decide what text and font a banner line actually renders with: parsed (tag-stripped) + // line if the cache covers it, otherwise the raw line with any leading font tag stripped + // on the fly. Exposed for unit tests. + static const char *resolveBannerLine(uint16_t lineIndex, const char *rawLine, BannerFont &lineFont); static void resetBanner(); static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); diff --git a/test/native-suite-count b/test/native-suite-count index c739b42c4..ea90ee319 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -44 +45 diff --git a/test/test_banner_font_tags/test_main.cpp b/test/test_banner_font_tags/test_main.cpp new file mode 100644 index 000000000..4033c2785 --- /dev/null +++ b/test/test_banner_font_tags/test_main.cpp @@ -0,0 +1,176 @@ +// Regression tests for the alert-banner font-tag pipeline ([S]/[M]/[L] line prefixes). +// +// The BLE pairing banner (src/platform/nrf52/NRF52Bluetooth.cpp) sends +// "Bluetooth\nPIN\n[M]" with notification type pairing_pin. The [M] prefix is a +// font-change tag, never text: it must be stripped by parseBannerMessageWithFonts and, +// crucially, must also be stripped when a draw resolves a line the parsed cache doesn't +// cover (the shape of the historical bug where the pairing PIN rendered a literal "[M]"). +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, etc.) +#include "TestUtil.h" // initializeTestEnvironment() +#include + +#if HAS_SCREEN // Same guard as the module under test + +#include "graphics/draw/NotificationRenderer.h" +#include + +using graphics::NotificationRenderer; +using graphics::notificationTypeEnum; + +static const char *BLE_PIN_MESSAGE = "Bluetooth\nPIN\n[M]123 456"; + +// Reset every static the tests touch, so each case starts from a known state. +void setUp(void) +{ + NotificationRenderer::alertBannerMessage[0] = '\0'; + NotificationRenderer::parseBannerMessageWithFonts(""); + NotificationRenderer::alertBannerOptions = 0; + NotificationRenderer::current_notification_type = notificationTypeEnum::none; +} + +void tearDown(void) {} + +// Simulate Screen::showOverlayBanner storing and parsing a banner message. +static void showBanner(const char *message, notificationTypeEnum type, uint8_t options = 0) +{ + strncpy(NotificationRenderer::alertBannerMessage, message, 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; + NotificationRenderer::parseBannerMessageWithFonts(NotificationRenderer::alertBannerMessage); + NotificationRenderer::alertBannerOptions = options; + NotificationRenderer::current_notification_type = type; +} + +// --- parseBannerMessageWithFonts --- + +void test_pairing_message_parses_and_strips_medium_tag() +{ + showBanner(BLE_PIN_MESSAGE, notificationTypeEnum::pairing_pin); + + TEST_ASSERT_EQUAL_UINT8(3, NotificationRenderer::alertBannerLineCount); + TEST_ASSERT_EQUAL_STRING("Bluetooth", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL_STRING("PIN", NotificationRenderer::alertBannerLines[1]); + TEST_ASSERT_EQUAL_STRING("123 456", NotificationRenderer::alertBannerLines[2]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[1]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_MEDIUM, NotificationRenderer::alertBannerLineFonts[2]); +} + +void test_small_and_large_tags_parse() +{ + showBanner("[S]small\n[L]large", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_UINT8(2, NotificationRenderer::alertBannerLineCount); + TEST_ASSERT_EQUAL_STRING("small", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_SMALL, NotificationRenderer::alertBannerLineFonts[0]); + TEST_ASSERT_EQUAL_STRING("large", NotificationRenderer::alertBannerLines[1]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_LARGE, NotificationRenderer::alertBannerLineFonts[1]); +} + +void test_unknown_tag_is_kept_as_text() +{ + showBanner("[X]hello", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_STRING("[X]hello", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[0]); +} + +void test_tag_not_at_line_start_is_kept_as_text() +{ + showBanner("PIN [M]x", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_STRING("PIN [M]x", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[0]); +} + +void test_tag_only_line_yields_empty_text_with_font() +{ + showBanner("[L]", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_STRING("", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_LARGE, NotificationRenderer::alertBannerLineFonts[0]); +} + +void test_lone_bracket_line_is_kept_as_text() +{ + showBanner("[", notificationTypeEnum::text_banner); + + TEST_ASSERT_EQUAL_STRING("[", NotificationRenderer::alertBannerLines[0]); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, NotificationRenderer::alertBannerLineFonts[0]); +} + +// --- resolveBannerLine: what the draw code actually puts on the panel --- + +void test_resolve_uses_parsed_lines_for_pairing_pin() +{ + showBanner(BLE_PIN_MESSAGE, notificationTypeEnum::pairing_pin); + + NotificationRenderer::BannerFont font = NotificationRenderer::BANNER_FONT_DEFAULT; + const char *text = NotificationRenderer::resolveBannerLine(2, "[M]123 456", font); + TEST_ASSERT_EQUAL_STRING("123 456", text); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_MEDIUM, font); +} + +// The historical bug: the pairing banner drawn from the raw message, with the parsed-line +// cache not consulted (before the pairing_pin type was tag-aware) or not populated (a draw +// racing the parse from the BLE task). The tag must still act as a font change, not text. +void test_resolve_strips_tag_when_parsed_cache_missing() +{ + strncpy(NotificationRenderer::alertBannerMessage, BLE_PIN_MESSAGE, 255); + NotificationRenderer::current_notification_type = notificationTypeEnum::pairing_pin; + NotificationRenderer::alertBannerOptions = 0; + // Deliberately no parseBannerMessageWithFonts call: cache empty. + + NotificationRenderer::BannerFont font = NotificationRenderer::BANNER_FONT_DEFAULT; + const char *text = NotificationRenderer::resolveBannerLine(2, "[M]123 456", font); + TEST_ASSERT_EQUAL_STRING("123 456", text); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_MEDIUM, font); +} + +// Picker content can be user data (e.g. node names); it must never be tag-interpreted. +void test_resolve_leaves_picker_lines_untouched() +{ + NotificationRenderer::current_notification_type = notificationTypeEnum::node_picker; + NotificationRenderer::alertBannerOptions = 0; + + NotificationRenderer::BannerFont font = NotificationRenderer::BANNER_FONT_LARGE; + const char *text = NotificationRenderer::resolveBannerLine(0, "[M]allory", font); + TEST_ASSERT_EQUAL_STRING("[M]allory", text); + TEST_ASSERT_EQUAL(NotificationRenderer::BANNER_FONT_DEFAULT, font); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + RUN_TEST(test_pairing_message_parses_and_strips_medium_tag); + RUN_TEST(test_small_and_large_tags_parse); + RUN_TEST(test_unknown_tag_is_kept_as_text); + RUN_TEST(test_tag_not_at_line_start_is_kept_as_text); + RUN_TEST(test_tag_only_line_yields_empty_text_with_font); + RUN_TEST(test_lone_bracket_line_is_kept_as_text); + + RUN_TEST(test_resolve_uses_parsed_lines_for_pairing_pin); + RUN_TEST(test_resolve_strips_tag_when_parsed_cache_missing); + RUN_TEST(test_resolve_leaves_picker_lines_untouched); + + exit(UNITY_END()); +} + +void loop() {} + +#else // !HAS_SCREEN + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +void loop() {} + +#endif // HAS_SCREEN From bcf486fa8b3bab7b99c6e77c77feb397f457e420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 11 Aug 2026 09:00:01 +0200 Subject: [PATCH 008/109] fix(Power): survive a BQ27220 fuel gauge that fails to init (#11401) * fix(Power): survive a BQ27220 fuel gauge that fails to init Keep the BQ25896 as the battery source when only the gauge fails, so Power stays enabled instead of falling through to an ADC that these variants do not have. Null-guard the gauge in getBattVoltage() and isCharging(). Retry the gauge from the power thread (3 attempts, 60s apart, address probe first) since it is soldered on, and reset the I2C master after a failed init so the bus scan does not run against a stale transaction. Fixes #11372 * fix(Power): address review feedback on the BQ27220 retry Derive "no attempt yet" from gaugeAttemptsLeft instead of a millis() zero sentinel, drop the zero-padding on the logged I2C address, and condense the new comment blocks. --- src/Power.cpp | 96 +++++++++++++++++++++++++++++++++++++++------------ src/Power.h | 2 ++ 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 29667f153..2a0938e2a 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -1117,6 +1117,7 @@ int32_t Power::runOnce() { readPowerStatus(); logHeapUsage(); + lipoChargerRetry(); #ifdef HAS_PMU // WE no longer use the IRQ line to wake the CPU (due to false wakes from @@ -1733,13 +1734,32 @@ bool Power::cw2015Init() #if defined(HAS_PPM) && HAS_PPM +// The gauge is soldered on, so a failed init means wedged rather than absent - retry from +// the power thread before writing it off. +#define BQ27220_INIT_ATTEMPTS 3 +#define BQ27220_RETRY_INTERVAL_MS (60 * 1000) + /** * Adapter class for BQ25896/BQ27220 Lipo battery charger. + * + * The gauge only adds time-to-full/empty, so its failure must not take the charger down. */ class LipoCharger : public HasBatteryLevel { private: BQ27220 *bq = nullptr; + uint8_t gaugeAttemptsLeft = BQ27220_INIT_ATTEMPTS; + uint32_t lastGaugeAttemptMs = 0; + + // An aborted transfer leaves the i2c_master driver holding a stale transaction, which + // the next transfer trips over. Deleting the bus frees it along with the interrupt. + void recoverI2CBus() + { +#ifdef ARCH_ESP32 + Wire.end(); + Wire.begin(I2C_SDA, I2C_SCL); +#endif + } public: /** @@ -1786,24 +1806,46 @@ class LipoCharger : public HasBatteryLevel return false; } } - if (bq == nullptr) { - bq = new BQ27220; - bq->setDefaultCapacity(BQ27220_DESIGN_CAPACITY); + gaugeRunOnce(); + // Ready on the charger alone, so Power stays enabled and can retry the gauge later. + return true; + } - bool result = bq->init(); - if (result) { - LOG_DEBUG("BQ27220 design capacity: %d", bq->getDesignCapacity()); - LOG_DEBUG("BQ27220 fullCharge capacity: %d", bq->getFullChargeCapacity()); - LOG_DEBUG("BQ27220 remaining capacity: %d", bq->getRemainingCapacity()); - return true; - } else { - LOG_WARN("BQ27220 init failed"); - delete bq; - bq = nullptr; - return false; - } + /// Bring up the BQ27220 fuel gauge, unless it is already up or out of attempts + void gaugeRunOnce() + { + if (bq != nullptr || gaugeAttemptsLeft == 0) + return; + if (gaugeAttemptsLeft < BQ27220_INIT_ATTEMPTS && + Throttle::isWithinTimespanMs(lastGaugeAttemptMs, BQ27220_RETRY_INTERVAL_MS)) + return; + + lastGaugeAttemptMs = millis(); + gaugeAttemptsLeft--; + + // Cheap probe first: a silent gauge costs one transaction instead of the + // multi-second unseal/reset/provision sequence inside init(). + Wire.beginTransmission(BQ27220_I2C_ADDRESS); + if (Wire.endTransmission() != 0) { + LOG_WARN("BQ27220 not responding at 0x%x", BQ27220_I2C_ADDRESS); + return; } - return false; + + bq = new BQ27220; + bq->setDefaultCapacity(BQ27220_DESIGN_CAPACITY); + + if (bq->init()) { + LOG_DEBUG("BQ27220 design capacity: %d", bq->getDesignCapacity()); + LOG_DEBUG("BQ27220 fullCharge capacity: %d", bq->getFullChargeCapacity()); + LOG_DEBUG("BQ27220 remaining capacity: %d", bq->getRemainingCapacity()); + return; + } + + delete bq; + bq = nullptr; + // init() bails out mid-sequence, so hand the next bus user a sane driver state. + recoverI2CBus(); + LOG_WARN("BQ27220 init failed (%d retries left), use BQ25896 for battery state", (int)gaugeAttemptsLeft); } /** @@ -1819,7 +1861,7 @@ class LipoCharger : public HasBatteryLevel /** * The raw voltage of the battery in millivolts, or NAN if unknown */ - virtual uint16_t getBattVoltage() override { return bq->getVoltage(); } + virtual uint16_t getBattVoltage() override { return bq ? bq->getVoltage() : PPM->getBattVoltage(); } /** * return true if there is a battery installed in this unit @@ -1837,11 +1879,13 @@ class LipoCharger : public HasBatteryLevel virtual bool isCharging() override { bool isCharging = PPM->isCharging(); - if (isCharging) { - LOG_DEBUG("BQ27220 time to full charge: %d min", bq->getTimeToFull()); - } else { - if (!PPM->isVbusIn()) { - LOG_DEBUG("BQ27220 time to empty: %d min (%d mAh)", bq->getTimeToEmpty(), bq->getRemainingCapacity()); + if (bq) { + if (isCharging) { + LOG_DEBUG("BQ27220 time to full charge: %d min", bq->getTimeToFull()); + } else { + if (!PPM->isVbusIn()) { + LOG_DEBUG("BQ27220 time to empty: %d min (%d mAh)", bq->getTimeToEmpty(), bq->getRemainingCapacity()); + } } } return isCharging; @@ -1863,6 +1907,12 @@ bool Power::lipoChargerInit() return true; } +/// Retry a fuel gauge that did not come up during setup +void Power::lipoChargerRetry() +{ + lipoCharger.gaugeRunOnce(); +} + #else /** * The Lipo battery level sensor is unavailable - default to AnalogBatteryLevel @@ -1871,6 +1921,8 @@ bool Power::lipoChargerInit() { return false; } + +void Power::lipoChargerRetry() {} #endif #ifdef HELTEC_MESH_SOLAR diff --git a/src/Power.h b/src/Power.h index 38a65b081..b47d66aff 100644 --- a/src/Power.h +++ b/src/Power.h @@ -121,6 +121,8 @@ class Power : public concurrency::OSThread bool max17048Init(); /// Setup a Lipo charger bool lipoChargerInit(); + /// Retry a fuel gauge that did not come up during setup + void lipoChargerRetry(); /// Setup a meshSolar battery sensor bool meshSolarInit(); /// Setup a serial battery sensor From e8dae921bdd9a5596791107873555595b559aa9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 11 Aug 2026 14:37:23 +0200 Subject: [PATCH 009/109] Update GitHub Actions workflow for protobufs, allow cross-branch generation --- .github/workflows/update_protobufs.yml | 63 +++++++++++++++++++++----- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/.github/workflows/update_protobufs.yml b/.github/workflows/update_protobufs.yml index 657d9171c..c30f4587e 100644 --- a/.github/workflows/update_protobufs.yml +++ b/.github/workflows/update_protobufs.yml @@ -1,12 +1,23 @@ name: Update protobufs and regenerate classes -on: workflow_dispatch +on: + workflow_dispatch: + inputs: + protobufs_branch: + description: Branch of meshtastic/protobufs to generate from + required: true + type: choice + default: same-as-this-branch + options: + - same-as-this-branch + - master + - develop permissions: read-all jobs: update-protobufs: runs-on: ubuntu-latest - permissions: # Needed for peter-evans/create-pull-request. + permissions: contents: write pull-requests: write steps: @@ -14,22 +25,50 @@ jobs: uses: actions/checkout@v7 with: submodules: true + persist-credentials: false + + - name: Resolve protobufs branch + id: resolve + env: + INPUT_BRANCH: ${{ inputs.protobufs_branch }} + TRIGGER_BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + if [ "$INPUT_BRANCH" = "same-as-this-branch" ]; then + BRANCH="$TRIGGER_BRANCH" + else + BRANCH="$INPUT_BRANCH" + fi + case "$BRANCH" in + master | develop) ;; + *) + echo "::error::Refusing to generate from branch '$BRANCH'" + exit 1 + ;; + esac + echo "branch=$BRANCH" >>"$GITHUB_OUTPUT" - name: Update submodule - if: ${{ github.ref_name == 'master' || github.ref_name == 'develop' }} working-directory: protobufs env: - # Use the branch that triggered the workflow as the protobuf branch. - GIT_BRANCH: ${{ github.ref_name }} + GIT_BRANCH: ${{ steps.resolve.outputs.branch }} run: | - git fetch --prune origin $GIT_BRANCH - git checkout FETCH_HEAD + set -euo pipefail + git fetch --prune origin "+refs/heads/${GIT_BRANCH}:refs/remotes/origin/${GIT_BRANCH}" + git checkout --detach "refs/remotes/origin/${GIT_BRANCH}" + git rev-parse HEAD - name: Download nanopb + env: + NANOPB_VERSION: 0.4.9.1 + NANOPB_SHA256: 951a9ab2385424a4cdf245d0c84f4c88c6ccbc65a0dade4b246d50c068f24128 run: | - wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/nanopb-0.4.9.1-linux-x86.tar.gz - tar xvzf nanopb-0.4.9.1-linux-x86.tar.gz - mv nanopb-0.4.9.1-linux-x86 nanopb-0.4.9 + set -euo pipefail + TARBALL="nanopb-${NANOPB_VERSION}-linux-x86.tar.gz" + wget -q "https://github.com/nanopb/nanopb/releases/download/nanopb-${NANOPB_VERSION}/${TARBALL}" + echo "${NANOPB_SHA256} ${TARBALL}" | sha256sum -c - + tar xzf "${TARBALL}" + mv "nanopb-${NANOPB_VERSION}-linux-x86" nanopb-0.4.9 - name: Re-generate protocol buffers run: | @@ -38,10 +77,12 @@ jobs: - name: Create pull request uses: peter-evans/create-pull-request@v8 with: - branch: create-pull-request/update-protobufs-${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + branch: create-pull-request/update-protobufs-${{ github.ref_name }}-from-${{ steps.resolve.outputs.branch }} labels: submodules title: Update protobufs and classes commit-message: Update protobufs add-paths: | protobufs src/mesh + From c6a20811b2cb6cce98346bea6327c7a4ca84bc39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:56:11 +0200 Subject: [PATCH 010/109] Update protobufs (#11406) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/admin.pb.cpp | 8 +- src/mesh/generated/meshtastic/admin.pb.h | 110 +++++++++-- src/mesh/generated/meshtastic/atak.pb.h | 46 ++--- .../generated/meshtastic/deviceonly.pb.cpp | 2 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 24 ++- .../generated/meshtastic/mesh_beacon.pb.h | 2 +- .../generated/meshtastic/module_config.pb.h | 4 +- .../generated/meshtastic/telemetry.pb.cpp | 5 +- src/mesh/generated/meshtastic/telemetry.pb.h | 178 +++++++++++++++--- 11 files changed, 302 insertions(+), 81 deletions(-) diff --git a/protobufs b/protobufs index cd290ba24..84bfb0fdb 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit cd290ba246fb5130cb449055248f7e22c15bcafb +Subproject commit 84bfb0fdb3b853ea18abc4535497fa41a1b09546 diff --git a/src/mesh/generated/meshtastic/admin.pb.cpp b/src/mesh/generated/meshtastic/admin.pb.cpp index 945840c0f..d029daf31 100644 --- a/src/mesh/generated/meshtastic/admin.pb.cpp +++ b/src/mesh/generated/meshtastic/admin.pb.cpp @@ -30,7 +30,7 @@ PB_BIND(meshtastic_SharedContact, meshtastic_SharedContact, AUTO) PB_BIND(meshtastic_KeyVerificationAdmin, meshtastic_KeyVerificationAdmin, AUTO) -PB_BIND(meshtastic_SensorConfig, meshtastic_SensorConfig, AUTO) +PB_BIND(meshtastic_SensorConfig, meshtastic_SensorConfig, 2) PB_BIND(meshtastic_SCD4X_config, meshtastic_SCD4X_config, AUTO) @@ -39,12 +39,18 @@ PB_BIND(meshtastic_SCD4X_config, meshtastic_SCD4X_config, AUTO) PB_BIND(meshtastic_SEN5X_config, meshtastic_SEN5X_config, AUTO) +PB_BIND(meshtastic_SEN6X_config, meshtastic_SEN6X_config, AUTO) + + PB_BIND(meshtastic_SCD30_config, meshtastic_SCD30_config, AUTO) PB_BIND(meshtastic_SHTXX_config, meshtastic_SHTXX_config, AUTO) +PB_BIND(meshtastic_DS248X_config, meshtastic_DS248X_config, AUTO) + + diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index 4c00a568c..9d73b8508 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -186,7 +186,7 @@ typedef struct _meshtastic_LockdownAuth { token at unlock time: the client-supplied boots_remaining when non-zero, otherwise the firmware default (TOKEN_DEFAULT_BOOTS). Note that boots_remaining == 0 in this message means "use firmware - default", NOT "zero boots" - a client computing the ceiling for + default", NOT "zero boots" — a client computing the ceiling for display should mirror that resolution rather than multiplying the raw request value. @@ -196,7 +196,7 @@ typedef struct _meshtastic_LockdownAuth { Uses millis() (CPU uptime), not wall-clock time, so the cap is immune to GPS spoofing, RTC backup-battery removal, and Faraday - cage isolation - none of those move the uptime counter. The only + cage isolation — none of those move the uptime counter. The only way to reset the session clock is a reboot, which costs a boot from the on-flash, HMAC-bound counter. */ uint32_t max_session_seconds; @@ -213,7 +213,7 @@ typedef struct _meshtastic_LockdownAuth { NOT reversed by this operation: APPROTECT. Once the debug port lockout has been burned (on silicon where it is effective) it is - permanent - disabling lockdown decrypts your data and removes the + permanent — disabling lockdown decrypts your data and removes the access gates, but the SWD/JTAG port stays locked for the life of the device (recoverable only via a full chip erase over a debug probe, which destroys all data). Clients should make this @@ -303,8 +303,38 @@ typedef struct _meshtastic_SEN5X_config { /* One-shot mode (true for low power - one-shot mode, false for normal - continuous mode) */ bool has_set_one_shot_mode; bool set_one_shot_mode; + /* Trigger a fan cleaning cycle */ + bool has_start_fan_cleaning; + bool start_fan_cleaning; } meshtastic_SEN5X_config; +typedef struct _meshtastic_SEN6X_config { + /* Reference temperature in degC */ + bool has_set_temperature; + float set_temperature; + /* One-shot mode (true for low power - one-shot mode, false for normal - continuous mode) */ + bool has_set_one_shot_mode; + bool set_one_shot_mode; + /* Trigger a fan cleaning cycle */ + bool has_start_fan_cleaning; + bool start_fan_cleaning; + /* Set Automatic self-calibration enabled (CO2-capable variants only: SEN63C, SEN66, SEN69C) */ + bool has_set_asc; + bool set_asc; + /* Recalibration target CO2 concentration in ppm (FRC only), CO2-capable variants only */ + bool has_set_target_co2_conc; + uint32_t set_target_co2_conc; + /* Altitude of sensor in meters above sea level. 0 - 3000m (overrides ambient pressure), CO2-capable variants only */ + bool has_set_altitude; + uint32_t set_altitude; + /* Sensor ambient pressure in Pa. 70000 - 120000 Pa (overrides altitude), CO2-capable variants only */ + bool has_set_ambient_pressure; + uint32_t set_ambient_pressure; + /* Perform a factory reset of the CO2 sensor's calibration, CO2-capable variants only */ + bool has_factory_reset; + bool factory_reset; +} meshtastic_SEN6X_config; + typedef struct _meshtastic_SCD30_config { /* Set Automatic self-calibration enabled */ bool has_set_asc; @@ -332,6 +362,12 @@ typedef struct _meshtastic_SHTXX_config { uint32_t set_accuracy; } meshtastic_SHTXX_config; +typedef struct _meshtastic_DS248X_config { + /* Main channel for temperature reporting (0-7) */ + bool has_main_temperature_channel; + uint32_t main_temperature_channel; +} meshtastic_DS248X_config; + typedef struct _meshtastic_SensorConfig { /* SCD4X CO2 Sensor configuration */ bool has_scd4x_config; @@ -345,6 +381,12 @@ typedef struct _meshtastic_SensorConfig { /* SHTXX temperature and relative humidity sensor configuration */ bool has_shtxx_config; meshtastic_SHTXX_config shtxx_config; + /* DS248X-800 temperature sensor configuration */ + bool has_ds248x_config; + meshtastic_DS248X_config ds248x_config; + /* SEN6X PM/RHT/VOC/NOx/CO2/HCHO Sensor configuration */ + bool has_sen6x_config; + meshtastic_SEN6X_config sen6x_config; } meshtastic_SensorConfig; typedef PB_BYTES_ARRAY_T(8) meshtastic_AdminMessage_session_passkey_t; @@ -544,6 +586,8 @@ extern "C" { + + /* Initializer values for message structs */ #define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0} @@ -553,11 +597,13 @@ extern "C" { #define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} #define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0} #define meshtastic_KeyVerificationAdmin_init_default {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0} -#define meshtastic_SensorConfig_init_default {false, meshtastic_SCD4X_config_init_default, false, meshtastic_SEN5X_config_init_default, false, meshtastic_SCD30_config_init_default, false, meshtastic_SHTXX_config_init_default} +#define meshtastic_SensorConfig_init_default {false, meshtastic_SCD4X_config_init_default, false, meshtastic_SEN5X_config_init_default, false, meshtastic_SCD30_config_init_default, false, meshtastic_SHTXX_config_init_default, false, meshtastic_DS248X_config_init_default, false, meshtastic_SEN6X_config_init_default} #define meshtastic_SCD4X_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_SEN5X_config_init_default {false, 0, false, 0} +#define meshtastic_SEN5X_config_init_default {false, 0, false, 0, false, 0} +#define meshtastic_SEN6X_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SCD30_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SHTXX_config_init_default {false, 0} +#define meshtastic_DS248X_config_init_default {false, 0} #define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}} @@ -566,11 +612,13 @@ extern "C" { #define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} #define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0} #define meshtastic_KeyVerificationAdmin_init_zero {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0} -#define meshtastic_SensorConfig_init_zero {false, meshtastic_SCD4X_config_init_zero, false, meshtastic_SEN5X_config_init_zero, false, meshtastic_SCD30_config_init_zero, false, meshtastic_SHTXX_config_init_zero} +#define meshtastic_SensorConfig_init_zero {false, meshtastic_SCD4X_config_init_zero, false, meshtastic_SEN5X_config_init_zero, false, meshtastic_SCD30_config_init_zero, false, meshtastic_SHTXX_config_init_zero, false, meshtastic_DS248X_config_init_zero, false, meshtastic_SEN6X_config_init_zero} #define meshtastic_SCD4X_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_SEN5X_config_init_zero {false, 0, false, 0} +#define meshtastic_SEN5X_config_init_zero {false, 0, false, 0, false, 0} +#define meshtastic_SEN6X_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SCD30_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SHTXX_config_init_zero {false, 0} +#define meshtastic_DS248X_config_init_zero {false, 0} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_AdminMessage_InputEvent_event_code_tag 1 @@ -608,6 +656,15 @@ extern "C" { #define meshtastic_SCD4X_config_set_power_mode_tag 7 #define meshtastic_SEN5X_config_set_temperature_tag 1 #define meshtastic_SEN5X_config_set_one_shot_mode_tag 2 +#define meshtastic_SEN5X_config_start_fan_cleaning_tag 3 +#define meshtastic_SEN6X_config_set_temperature_tag 1 +#define meshtastic_SEN6X_config_set_one_shot_mode_tag 2 +#define meshtastic_SEN6X_config_start_fan_cleaning_tag 3 +#define meshtastic_SEN6X_config_set_asc_tag 4 +#define meshtastic_SEN6X_config_set_target_co2_conc_tag 5 +#define meshtastic_SEN6X_config_set_altitude_tag 6 +#define meshtastic_SEN6X_config_set_ambient_pressure_tag 7 +#define meshtastic_SEN6X_config_factory_reset_tag 8 #define meshtastic_SCD30_config_set_asc_tag 1 #define meshtastic_SCD30_config_set_target_co2_conc_tag 2 #define meshtastic_SCD30_config_set_temperature_tag 3 @@ -615,10 +672,13 @@ extern "C" { #define meshtastic_SCD30_config_set_measurement_interval_tag 5 #define meshtastic_SCD30_config_soft_reset_tag 6 #define meshtastic_SHTXX_config_set_accuracy_tag 1 +#define meshtastic_DS248X_config_main_temperature_channel_tag 1 #define meshtastic_SensorConfig_scd4x_config_tag 1 #define meshtastic_SensorConfig_sen5x_config_tag 2 #define meshtastic_SensorConfig_scd30_config_tag 3 #define meshtastic_SensorConfig_shtxx_config_tag 4 +#define meshtastic_SensorConfig_ds248x_config_tag 5 +#define meshtastic_SensorConfig_sen6x_config_tag 6 #define meshtastic_AdminMessage_get_channel_request_tag 1 #define meshtastic_AdminMessage_get_channel_response_tag 2 #define meshtastic_AdminMessage_get_owner_request_tag 3 @@ -824,13 +884,17 @@ X(a, STATIC, OPTIONAL, UINT32, security_number, 4) X(a, STATIC, OPTIONAL, MESSAGE, scd4x_config, 1) \ X(a, STATIC, OPTIONAL, MESSAGE, sen5x_config, 2) \ X(a, STATIC, OPTIONAL, MESSAGE, scd30_config, 3) \ -X(a, STATIC, OPTIONAL, MESSAGE, shtxx_config, 4) +X(a, STATIC, OPTIONAL, MESSAGE, shtxx_config, 4) \ +X(a, STATIC, OPTIONAL, MESSAGE, ds248x_config, 5) \ +X(a, STATIC, OPTIONAL, MESSAGE, sen6x_config, 6) #define meshtastic_SensorConfig_CALLBACK NULL #define meshtastic_SensorConfig_DEFAULT NULL #define meshtastic_SensorConfig_scd4x_config_MSGTYPE meshtastic_SCD4X_config #define meshtastic_SensorConfig_sen5x_config_MSGTYPE meshtastic_SEN5X_config #define meshtastic_SensorConfig_scd30_config_MSGTYPE meshtastic_SCD30_config #define meshtastic_SensorConfig_shtxx_config_MSGTYPE meshtastic_SHTXX_config +#define meshtastic_SensorConfig_ds248x_config_MSGTYPE meshtastic_DS248X_config +#define meshtastic_SensorConfig_sen6x_config_MSGTYPE meshtastic_SEN6X_config #define meshtastic_SCD4X_config_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, BOOL, set_asc, 1) \ @@ -845,10 +909,23 @@ X(a, STATIC, OPTIONAL, BOOL, set_power_mode, 7) #define meshtastic_SEN5X_config_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, FLOAT, set_temperature, 1) \ -X(a, STATIC, OPTIONAL, BOOL, set_one_shot_mode, 2) +X(a, STATIC, OPTIONAL, BOOL, set_one_shot_mode, 2) \ +X(a, STATIC, OPTIONAL, BOOL, start_fan_cleaning, 3) #define meshtastic_SEN5X_config_CALLBACK NULL #define meshtastic_SEN5X_config_DEFAULT NULL +#define meshtastic_SEN6X_config_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, FLOAT, set_temperature, 1) \ +X(a, STATIC, OPTIONAL, BOOL, set_one_shot_mode, 2) \ +X(a, STATIC, OPTIONAL, BOOL, start_fan_cleaning, 3) \ +X(a, STATIC, OPTIONAL, BOOL, set_asc, 4) \ +X(a, STATIC, OPTIONAL, UINT32, set_target_co2_conc, 5) \ +X(a, STATIC, OPTIONAL, UINT32, set_altitude, 6) \ +X(a, STATIC, OPTIONAL, UINT32, set_ambient_pressure, 7) \ +X(a, STATIC, OPTIONAL, BOOL, factory_reset, 8) +#define meshtastic_SEN6X_config_CALLBACK NULL +#define meshtastic_SEN6X_config_DEFAULT NULL + #define meshtastic_SCD30_config_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, BOOL, set_asc, 1) \ X(a, STATIC, OPTIONAL, UINT32, set_target_co2_conc, 2) \ @@ -864,6 +941,11 @@ X(a, STATIC, OPTIONAL, UINT32, set_accuracy, 1) #define meshtastic_SHTXX_config_CALLBACK NULL #define meshtastic_SHTXX_config_DEFAULT NULL +#define meshtastic_DS248X_config_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, UINT32, main_temperature_channel, 1) +#define meshtastic_DS248X_config_CALLBACK NULL +#define meshtastic_DS248X_config_DEFAULT NULL + extern const pb_msgdesc_t meshtastic_AdminMessage_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_InputEvent_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_OTAEvent_msg; @@ -875,8 +957,10 @@ extern const pb_msgdesc_t meshtastic_KeyVerificationAdmin_msg; extern const pb_msgdesc_t meshtastic_SensorConfig_msg; extern const pb_msgdesc_t meshtastic_SCD4X_config_msg; extern const pb_msgdesc_t meshtastic_SEN5X_config_msg; +extern const pb_msgdesc_t meshtastic_SEN6X_config_msg; extern const pb_msgdesc_t meshtastic_SCD30_config_msg; extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; +extern const pb_msgdesc_t meshtastic_DS248X_config_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg @@ -890,23 +974,27 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_SensorConfig_fields &meshtastic_SensorConfig_msg #define meshtastic_SCD4X_config_fields &meshtastic_SCD4X_config_msg #define meshtastic_SEN5X_config_fields &meshtastic_SEN5X_config_msg +#define meshtastic_SEN6X_config_fields &meshtastic_SEN6X_config_msg #define meshtastic_SCD30_config_fields &meshtastic_SCD30_config_msg #define meshtastic_SHTXX_config_fields &meshtastic_SHTXX_config_msg +#define meshtastic_DS248X_config_fields &meshtastic_DS248X_config_msg /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_ADMIN_PB_H_MAX_SIZE meshtastic_AdminMessage_size #define meshtastic_AdminMessage_InputEvent_size 14 #define meshtastic_AdminMessage_OTAEvent_size 36 #define meshtastic_AdminMessage_size 511 +#define meshtastic_DS248X_config_size 6 #define meshtastic_HamParameters_size 47 #define meshtastic_KeyVerificationAdmin_size 25 #define meshtastic_LockdownAuth_size 56 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 #define meshtastic_SCD30_config_size 27 #define meshtastic_SCD4X_config_size 29 -#define meshtastic_SEN5X_config_size 7 +#define meshtastic_SEN5X_config_size 9 +#define meshtastic_SEN6X_config_size 31 #define meshtastic_SHTXX_config_size 6 -#define meshtastic_SensorConfig_size 77 +#define meshtastic_SensorConfig_size 120 #define meshtastic_SharedContact_size 127 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/atak.pb.h b/src/mesh/generated/meshtastic/atak.pb.h index e0dab8659..6ea298f9e 100644 --- a/src/mesh/generated/meshtastic/atak.pb.h +++ b/src/mesh/generated/meshtastic/atak.pb.h @@ -332,7 +332,7 @@ typedef enum _meshtastic_CotType { /* y-: TAKTALK room/membership broadcast. Payload carried via the TakTalkRoomData typed variant (sender_callsign, room_id, room_name, participants). The CoT type literally has a trailing dash and no - second atom - not a typo. */ + second atom — not a typo. */ meshtastic_CotType_CotType_y = 126 } meshtastic_CotType; @@ -380,7 +380,7 @@ typedef enum _meshtastic_DrawnShape_Kind { /* u-r-b-bullseye: Bullseye ring with range rings and bearing reference */ meshtastic_DrawnShape_Kind_Kind_Bullseye = 7, /* u-d-c-e: Ellipse with distinct major/minor axes (same storage as - Kind_Circle - uses major_cm/minor_cm/angle_deg - but receivers + Kind_Circle — uses major_cm/minor_cm/angle_deg — but receivers render it as a non-circular ellipse rather than a round circle). */ meshtastic_DrawnShape_Kind_Kind_Ellipse = 8, /* u-d-v: 2D vehicle outline drawn on the map. Vertices carry the @@ -400,7 +400,7 @@ typedef enum _meshtastic_DrawnShape_Kind { end of parse; builder uses it to decide which of / to emit in the reconstructed XML. */ typedef enum _meshtastic_DrawnShape_StyleMode { - /* Unspecified - receiver infers from which color fields are non-zero. */ + /* Unspecified — receiver infers from which color fields are non-zero. */ meshtastic_DrawnShape_StyleMode_StyleMode_Unspecified = 0, /* Stroke only. No in the source XML. Used for polylines, ranging lines, bullseye rings. */ @@ -417,7 +417,7 @@ typedef enum _meshtastic_DrawnShape_StyleMode { alone is ambiguous (e.g. a-u-G could be a 2525 symbol or a custom icon depending on the iconset path). */ typedef enum _meshtastic_Marker_Kind { - /* Unspecified - fall back to TAKPacketV2.cot_type_id */ + /* Unspecified — fall back to TAKPacketV2.cot_type_id */ meshtastic_Marker_Kind_Kind_Unspecified = 0, /* b-m-p-s-m: Spot map marker */ meshtastic_Marker_Kind_Kind_Spot = 1, @@ -680,10 +680,10 @@ typedef struct _meshtastic_AircraftTrack { hundred meters of the anchor has per-vertex deltas in the ±10^4 range. Under sint32+zigzag those encode as 2 bytes each (tag+varint), versus the 4 bytes that sfixed32 would always require. At 32 vertices that is ~128 - bytes of savings - the difference between fitting under the LoRa MTU or + bytes of savings — the difference between fitting under the LoRa MTU or not. Absolute coordinates (values ~10^9) would cost sint32 varint 5 bytes per field, which is why TAKPacketV2's top-level latitude_i / longitude_i - stay sfixed32 - only small values win with sint32. */ + stay sfixed32 — only small values win with sint32. */ typedef struct _meshtastic_CotGeoPoint { /* Latitude delta from TAKPacketV2.latitude_i, in 1e-7 degree units. Add to the enclosing event's latitude_i to recover the absolute latitude. */ @@ -791,7 +791,7 @@ typedef struct _meshtastic_Marker { Covers CoT type u-rb-a. The anchor position is on TAKPacketV2.latitude_i/longitude_i; the target endpoint is carried as a - CotGeoPoint - same delta-from-anchor encoding used by DrawnShape.vertices + CotGeoPoint — same delta-from-anchor encoding used by DrawnShape.vertices so a self-anchored RAB (common case) encodes in zero bytes. */ typedef struct _meshtastic_RangeAndBearing { /* Target/anchor endpoint (delta-encoded from TAKPacketV2.latitude_i/longitude_i). */ @@ -899,12 +899,12 @@ typedef struct _meshtastic_CasevacReport { same as the envelope callsign but ATAK sometimes carries a distinct ops-number here. */ pb_callback_t title; - /* Primary medline free-text - the single most clinically important line + /* Primary medline free-text — the single most clinically important line on a MEDLINE form (e.g. "2 urgent litter patients, smoke on approach"). MUST be preserved under MTU pressure as long as any casevac is sent. */ pb_callback_t medline_remarks; /* Line 3 (newer ATAK format): patient counts by precedence level. - Coexists with the enum-style `precedence` field (tag 1) - older ATAK + Coexists with the enum-style `precedence` field (tag 1) — older ATAK emits a single enum, newer ATAK emits these counts, and both can be set simultaneously. Senders populate whichever style(s) the source XML had; receivers prefer counts when non-zero. */ @@ -946,19 +946,19 @@ typedef struct _meshtastic_CasevacReport { (e.g. "Primary HLZ is soccer field"). */ pb_callback_t hlz_remarks; /* Per-patient clinical records. Each entry is one patient's ZMIST card - (Zap number / Mechanism / Injuries / Signs / Treatment). Repeatable - + (Zap number / Mechanism / Injuries / Signs / Treatment). Repeatable — a mass-casualty event can carry 1-6 entries in practice, limited by the 237 B LoRa MTU. */ pb_callback_t zmist; } meshtastic_CasevacReport; -/* Per-patient clinical summary record - one entry per patient in a CASEVAC. +/* Per-patient clinical summary record — one entry per patient in a CASEVAC. Maps directly to ATAK's child element inside . All fields are optional free-text; senders populate what they have. */ typedef struct _meshtastic_ZMistEntry { /* Patient identifier / sequence label (e.g. "ZMIST-1", "ZMIST-2"). */ pb_callback_t title; - /* Zap number - unique patient tracking ID (often a terse code like + /* Zap number — unique patient tracking ID (often a terse code like "Gunshot" or a serial). */ pb_callback_t z; /* Mechanism of injury (e.g. "Penetrating trauma", "Blast injury"). */ @@ -997,7 +997,7 @@ typedef struct _meshtastic_EmergencyAlert { creation time; the fields below carry structured metadata the raw-detail fallback currently loses. - Fields are deliberately lean - this variant is closer to the MTU ceiling + Fields are deliberately lean — this variant is closer to the MTU ceiling than the others, so every string is capped in options. */ typedef struct _meshtastic_TaskRequest { /* Short tag for the task category (e.g. "engage", "observe", "recon", @@ -1017,7 +1017,7 @@ typedef struct _meshtastic_TaskRequest { /* Weather annotation from CoT detail element. - Attaches to any TAKPacketV2 regardless of payload_variant - an Aircraft, + Attaches to any TAKPacketV2 regardless of payload_variant — an Aircraft, PLI, or Marker can all carry observed conditions at the emitting station. ATAK-CIV ships an XSD for but no dedicated handler, so the element round-trips through the generic detail pipeline; this message @@ -1026,7 +1026,7 @@ typedef struct _meshtastic_TaskRequest { Target wire cost: ~6-8 bytes compressed with a fully populated instance. Named `TAKEnvironment` (not just `Environment`) because the bare name - collides with `SwiftUI.Environment` - every SwiftUI view in a consuming + collides with `SwiftUI.Environment` — every SwiftUI view in a consuming iOS app uses the `@Environment` property wrapper, and importing the generated proto module would make `Environment` ambiguous in every one of those files. The `TAK` prefix matches the convention used by the @@ -1055,7 +1055,7 @@ typedef struct _meshtastic_TAKEnvironment { The receiving ATAK client restores those from its own defaults, same as every other CoT carried over Meshtastic today. - Attaches to any TAKPacketV2 - a PLI with a sensor on the operator's head, + Attaches to any TAKPacketV2 — a PLI with a sensor on the operator's head, an Aircraft with a FLIR turret, a Marker dropped on a UAV. Target wire cost: ~7-14 bytes compressed (dominated by model string). */ typedef struct _meshtastic_SensorFov { @@ -1065,30 +1065,30 @@ typedef struct _meshtastic_SensorFov { SensorDetailHandler default (270°) and save varint bytes over centi-deg. */ uint32_t azimuth_deg; /* Maximum range of the cone in meters. - Optional - if unset, receivers should use the ATAK-CIV default of 100m. */ + Optional — if unset, receivers should use the ATAK-CIV default of 100m. */ bool has_range_m; uint32_t range_m; /* Horizontal field of view in whole degrees (cone's angular width). ATAK-CIV default is 45°. */ uint32_t fov_horizontal_deg; /* Vertical field of view in whole degrees. ATAK-CIV default is 45°. - Optional - a value of 0 means "not set / use horizontal FOV". */ + Optional — a value of 0 means "not set / use horizontal FOV". */ uint32_t fov_vertical_deg; /* Elevation angle in whole degrees. Positive = up, negative = down. Range -90 to +90. sint32 for varint efficiency on small negatives. */ int32_t elevation_deg; /* Roll (camera tilt) in whole degrees, -180 to +180. - Optional - use 0 if the sensor doesn't track roll. */ + Optional — use 0 if the sensor doesn't track roll. */ int32_t roll_deg; /* Free-form device model identifier, e.g. "FLIR-Boson-640", "SEEK". - Optional - empty string means "unknown model" (ATAK-CIV default). */ + Optional — empty string means "unknown model" (ATAK-CIV default). */ pb_callback_t model; } meshtastic_SensorFov; /* TAKTALK chat message payload (CoT type m-t-t). TAKTALK is an ATAK plugin for voice + text team messaging. The voice - audio stream goes over UDP/RTP and is NOT carried by the mesh - only + audio stream goes over UDP/RTP and is NOT carried by the mesh — only the text envelope (this message) is. `from_voice` marks messages sent via push-to-talk speech-to-text so receivers can render a mic icon next to the text. @@ -1122,7 +1122,7 @@ typedef struct _meshtastic_TakTalkMessage { Announces a TAKTALK chatroom's friendly name and roster so peers can resolve room UUIDs (used in TakTalkMessage.chatroom_id and GeoChat.room_id) to a display name and participant list. Not a chat - message itself - these events are emitted by TAKTALK when rooms are + message itself — these events are emitted by TAKTALK when rooms are created or memberships change. */ typedef struct _meshtastic_TakTalkRoomData { /* Callsign of the device broadcasting the room state (typically the @@ -1161,7 +1161,7 @@ typedef struct _meshtastic_Marti { primary-vs-cc distinction the same way ATAK does. If dest_callsign is [TAKPacketV2.callsign] (self-addressed, unusual but - legal - e.g. ATAK echoing back to its own room), the builder still emits + legal — e.g. ATAK echoing back to its own room), the builder still emits the element so loopback shapes round-trip cleanly. */ pb_callback_t dest_callsign; } meshtastic_Marti; diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.cpp b/src/mesh/generated/meshtastic/deviceonly.pb.cpp index 558086637..ed477630f 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.cpp +++ b/src/mesh/generated/meshtastic/deviceonly.pb.cpp @@ -24,7 +24,7 @@ PB_BIND(meshtastic_NodePositionEntry, meshtastic_NodePositionEntry, AUTO) PB_BIND(meshtastic_NodeTelemetryEntry, meshtastic_NodeTelemetryEntry, AUTO) -PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, AUTO) +PB_BIND(meshtastic_NodeEnvironmentEntry, meshtastic_NodeEnvironmentEntry, 2) PB_BIND(meshtastic_NodeStatusEntry, meshtastic_NodeStatusEntry, AUTO) diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 669792ddd..a4757b5ca 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -458,7 +458,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_BackupPreferences_size 2740 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 -#define meshtastic_NodeEnvironmentEntry_size 170 +#define meshtastic_NodeEnvironmentEntry_size 218 #define meshtastic_NodeInfoLite_size 112 #define meshtastic_NodePositionEntry_size 42 #define meshtastic_NodeStatusEntry_size 89 diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 60d817d73..733014359 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -1112,10 +1112,8 @@ typedef struct _meshtastic_MeshPacket { meshtastic_MeshPacket_Priority priority; /* 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. */ + 0 dBm; SX127x's formula can even go positive). has_rx_rssi disambiguates; a replayed packet + built from history should leave this field absent rather than emitting 0. */ bool has_rx_rssi; int32_t rx_rssi; /* Describe if this message is delayed */ @@ -1269,15 +1267,15 @@ typedef struct _meshtastic_LockdownStatus { /* Current lockdown state being reported. */ meshtastic_LockdownStatus_State state; /* For LOCKED: machine-readable reason. Known values: - "needs_auth" - storage already unlocked, client must auth - "token_missing" - no boot token on flash - "token_expired" - boot token wall-clock TTL elapsed - "token_boots_zero" - boot token boot-count TTL exhausted - "token_hmac_fail" - token tampered or wrong device - "token_dek_fail" - token DEK decrypt failed - "token_wrong_size" - token file corrupted - "token_bad_magic" - token file corrupted - "not_provisioned" - should generally use NEEDS_PROVISION state instead + "needs_auth" — storage already unlocked, client must auth + "token_missing" — no boot token on flash + "token_expired" — boot token wall-clock TTL elapsed + "token_boots_zero" — boot token boot-count TTL exhausted + "token_hmac_fail" — token tampered or wrong device + "token_dek_fail" — token DEK decrypt failed + "token_wrong_size" — token file corrupted + "token_bad_magic" — token file corrupted + "not_provisioned" — should generally use NEEDS_PROVISION state instead Other values may be added; clients should treat unknown values as "locked, ask for passphrase". */ char lock_reason[32]; diff --git a/src/mesh/generated/meshtastic/mesh_beacon.pb.h b/src/mesh/generated/meshtastic/mesh_beacon.pb.h index 94312eb1e..028d8269f 100644 --- a/src/mesh/generated/meshtastic/mesh_beacon.pb.h +++ b/src/mesh/generated/meshtastic/mesh_beacon.pb.h @@ -15,7 +15,7 @@ /* Payload for MESH_BEACON_APP packets. Periodically broadcast by nodes in beacon mode. Listeners deliver the text message to the local inbox and cache any offered - channel/preset for the client app to act on - the firmware never auto-applies them. */ + channel/preset for the client app to act on — the firmware never auto-applies them. */ typedef struct _meshtastic_MeshBeacon { /* Human-readable beacon message. Max 100 bytes enforced by firmware on send. */ char message[101]; diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index 713a91140..b04c358fc 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -497,7 +497,7 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { /* Single-target TX channel: channel settings (name + PSK) to send beacons on. If unset, beacons go out on the primary channel. Used only when broadcast_targets is empty. NOTE: the single-target path embeds the ChannelSettings inline here, whereas a - broadcast_targets entry references a channel-table slot by channel_index instead - see + broadcast_targets entry references a channel-table slot by channel_index instead — see BroadcastTarget. The two paths are equal, first-class options; only this representation differs. */ bool has_broadcast_on_channel; meshtastic_ChannelSettings broadcast_on_channel; @@ -514,7 +514,7 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { each temporarily switching the radio to that entry's preset/region/channel. When empty, the broadcaster uses the scalar broadcast_on_preset / broadcast_on_region / broadcast_on_channel fields instead (the single-target path). - Single- and multi-target are equal, first-class options - neither is preferred or + Single- and multi-target are equal, first-class options — neither is preferred or deprecated. They differ only in how the TX channel is named: broadcast_on_channel embeds a ChannelSettings inline, while a target references an existing channel-table slot by channel_index (see BroadcastTarget). */ diff --git a/src/mesh/generated/meshtastic/telemetry.pb.cpp b/src/mesh/generated/meshtastic/telemetry.pb.cpp index bc21b9dcb..64cc0422f 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.cpp +++ b/src/mesh/generated/meshtastic/telemetry.pb.cpp @@ -9,7 +9,7 @@ PB_BIND(meshtastic_DeviceMetrics, meshtastic_DeviceMetrics, AUTO) -PB_BIND(meshtastic_EnvironmentMetrics, meshtastic_EnvironmentMetrics, AUTO) +PB_BIND(meshtastic_EnvironmentMetrics, meshtastic_EnvironmentMetrics, 2) PB_BIND(meshtastic_PowerMetrics, meshtastic_PowerMetrics, AUTO) @@ -39,6 +39,9 @@ PB_BIND(meshtastic_Nau7802Config, meshtastic_Nau7802Config, AUTO) PB_BIND(meshtastic_SEN5XState, meshtastic_SEN5XState, AUTO) +PB_BIND(meshtastic_SEN6XState, meshtastic_SEN6XState, AUTO) + + diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index 36f930561..8c0168843 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -123,7 +123,9 @@ typedef enum _meshtastic_TelemetrySensorType { /* SPA06 pressure and temperature */ meshtastic_TelemetrySensorType_SPA06 = 54, /* HM330X PM SENSOR */ - meshtastic_TelemetrySensorType_HM330X = 55 + meshtastic_TelemetrySensorType_HM330X = 55, + /* Sensirion SEN6X PM/RHT/VOC/NOx/CO2/HCHO sensor family (SEN62, SEN63C, SEN65, SEN66, SEN68, SEN69C) */ + meshtastic_TelemetrySensorType_SEN6X = 56 } meshtastic_TelemetrySensorType; /* Struct definitions */ @@ -216,9 +218,54 @@ typedef struct _meshtastic_EnvironmentMetrics { /* Soil temperature measured (*C) */ bool has_soil_temperature; float soil_temperature; - /* One-wire temperature (*C) */ - pb_size_t one_wire_temperature_count; - float one_wire_temperature[8]; + /* Multi-channel ADC Voltage Channel 0 (V) */ + bool has_adc_voltage_ch0; + float adc_voltage_ch0; + /* Multi-channel ADC Voltage Channel 1 (V) */ + bool has_adc_voltage_ch1; + float adc_voltage_ch1; + /* Multi-channel ADC Voltage Channel 2 (V) */ + bool has_adc_voltage_ch2; + float adc_voltage_ch2; + /* Multi-channel ADC Voltage Channel 3 (V) */ + bool has_adc_voltage_ch3; + float adc_voltage_ch3; + /* Multi-channel ADC Voltage Channel 4 (V) */ + bool has_adc_voltage_ch4; + float adc_voltage_ch4; + /* Multi-channel ADC Voltage Channel 5 (V) */ + bool has_adc_voltage_ch5; + float adc_voltage_ch5; + /* Multi-channel ADC Voltage Channel 6 (V) */ + bool has_adc_voltage_ch6; + float adc_voltage_ch6; + /* Multi-channel ADC Voltage Channel 7 (V) */ + bool has_adc_voltage_ch7; + float adc_voltage_ch7; + /* Multi-channel One-Wire Temperature Channel 0 (*C) */ + bool has_one_wire_temperature_ch0; + float one_wire_temperature_ch0; + /* Multi-channel One-Wire Temperature Channel 1 (*C) */ + bool has_one_wire_temperature_ch1; + float one_wire_temperature_ch1; + /* Multi-channel One-Wire Temperature Channel 2 (*C) */ + bool has_one_wire_temperature_ch2; + float one_wire_temperature_ch2; + /* Multi-channel One-Wire Temperature Channel 3 (*C) */ + bool has_one_wire_temperature_ch3; + float one_wire_temperature_ch3; + /* Multi-channel One-Wire Temperature Channel 4 (*C) */ + bool has_one_wire_temperature_ch4; + float one_wire_temperature_ch4; + /* Multi-channel One-Wire Temperature Channel 5 (*C) */ + bool has_one_wire_temperature_ch5; + float one_wire_temperature_ch5; + /* Multi-channel One-Wire Temperature Channel 6 (*C) */ + bool has_one_wire_temperature_ch6; + float one_wire_temperature_ch6; + /* Multi-channel One-Wire Temperature Channel 7 (*C) */ + bool has_one_wire_temperature_ch7; + float one_wire_temperature_ch7; } meshtastic_EnvironmentMetrics; /* Power Metrics (voltage / current / etc) */ @@ -241,34 +288,34 @@ typedef struct _meshtastic_PowerMetrics { /* Current (Ch3) */ bool has_ch3_current; float ch3_current; - /* Voltage (Ch4) */ + /* Voltage (Ch4) - TODO Remove */ bool has_ch4_voltage; float ch4_voltage; - /* Current (Ch4) */ + /* Current (Ch4) - TODO Remove */ bool has_ch4_current; float ch4_current; - /* Voltage (Ch5) */ + /* Voltage (Ch5) - TODO Remove */ bool has_ch5_voltage; float ch5_voltage; - /* Current (Ch5) */ + /* Current (Ch5) - TODO Remove */ bool has_ch5_current; float ch5_current; - /* Voltage (Ch6) */ + /* Voltage (Ch6) - TODO Remove */ bool has_ch6_voltage; float ch6_voltage; - /* Current (Ch6) */ + /* Current (Ch6) - TODO Remove */ bool has_ch6_current; float ch6_current; - /* Voltage (Ch7) */ + /* Voltage (Ch7) - TODO Remove */ bool has_ch7_voltage; float ch7_voltage; - /* Current (Ch7) */ + /* Current (Ch7) - TODO Remove */ bool has_ch7_current; float ch7_current; - /* Voltage (Ch8) */ + /* Voltage (Ch8) - TODO Remove */ bool has_ch8_voltage; float ch8_voltage; - /* Current (Ch8) */ + /* Current (Ch8) - TODO Remove */ bool has_ch8_current; float ch8_current; } meshtastic_PowerMetrics; @@ -350,6 +397,12 @@ typedef struct _meshtastic_AirQualityMetrics { /* Typical Particle Size in um */ bool has_particles_tps; float particles_tps; + /* Raw PM sensor device status/error register bitmask, as defined by the sensor's own datasheet + (currently populated by the SEN6X family: bit 4 fan error, bit 6 RH&T error, bit 7 gas/VOC-NOx + error, bit 9 CO2 error (SEN66), bit 10 HCHO error, bit 11 PM error, bit 12 CO2 error (SEN63C/SEN69C), + bit 21 fan speed warning) */ + bool has_pm_status_flags; + uint32_t pm_status_flags; } meshtastic_AirQualityMetrics; /* Local device mesh statistics */ @@ -478,7 +531,7 @@ typedef struct _meshtastic_Nau7802Config { float calibrationFactor; } meshtastic_Nau7802Config; -/* SEN5X State, for saving to flash */ +/* SEN5X State, for saving to flash (to be merged with SEN6XState) */ typedef struct _meshtastic_SEN5XState { /* Last cleaning time for SEN5X */ uint32_t last_cleaning_time; @@ -497,6 +550,25 @@ typedef struct _meshtastic_SEN5XState { uint64_t voc_state_array; } meshtastic_SEN5XState; +/* SEN6X State, for saving to flash */ +typedef struct _meshtastic_SEN6XState { + /* Last cleaning time for SEN6X */ + uint32_t last_cleaning_time; + /* Last cleaning time for SEN6X - valid flag */ + bool last_cleaning_valid; + /* Config flag for one-shot mode (see admin.proto) */ + bool one_shot_mode; + /* Last VOC state time, for models with a VOC sensor (SEN65, SEN66, SEN68, SEN69C) */ + bool has_voc_state_time; + uint32_t voc_state_time; + /* Last VOC state validity flag, for models with a VOC sensor (SEN65, SEN66, SEN68, SEN69C) */ + bool has_voc_state_valid; + bool voc_state_valid; + /* VOC state array (8x uint8t), for models with a VOC sensor (SEN65, SEN66, SEN68, SEN69C) */ + bool has_voc_state_array; + uint64_t voc_state_array; +} meshtastic_SEN6XState; + #ifdef __cplusplus extern "C" { @@ -504,8 +576,9 @@ extern "C" { /* Helper constants for enums */ #define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET -#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_HM330X -#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_HM330X+1)) +#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_SEN6X +#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_SEN6X+1)) + @@ -521,9 +594,9 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_DeviceMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, 0, {0, 0, 0, 0, 0, 0, 0, 0}} +#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_AirQualityMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_AirQualityMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_TrafficManagementStats_init_default {0, 0, 0, 0, 0, 0, 0} #define meshtastic_HealthMetrics_init_default {false, 0, false, 0, false, 0} @@ -531,10 +604,11 @@ extern "C" { #define meshtastic_Telemetry_init_default {0, 0, {meshtastic_DeviceMetrics_init_default}} #define meshtastic_Nau7802Config_init_default {0, 0} #define meshtastic_SEN5XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} +#define meshtastic_SEN6XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_DeviceMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, 0, {0, 0, 0, 0, 0, 0, 0, 0}} +#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_AirQualityMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_AirQualityMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_TrafficManagementStats_init_zero {0, 0, 0, 0, 0, 0, 0} #define meshtastic_HealthMetrics_init_zero {false, 0, false, 0, false, 0} @@ -542,6 +616,7 @@ extern "C" { #define meshtastic_Telemetry_init_zero {0, 0, {meshtastic_DeviceMetrics_init_zero}} #define meshtastic_Nau7802Config_init_zero {0, 0} #define meshtastic_SEN5XState_init_zero {0, 0, 0, false, 0, false, 0, false, 0} +#define meshtastic_SEN6XState_init_zero {0, 0, 0, false, 0, false, 0, false, 0} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_DeviceMetrics_battery_level_tag 1 @@ -571,7 +646,22 @@ extern "C" { #define meshtastic_EnvironmentMetrics_rainfall_24h_tag 20 #define meshtastic_EnvironmentMetrics_soil_moisture_tag 21 #define meshtastic_EnvironmentMetrics_soil_temperature_tag 22 -#define meshtastic_EnvironmentMetrics_one_wire_temperature_tag 23 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch0_tag 24 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch1_tag 25 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch2_tag 26 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch3_tag 27 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch4_tag 28 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch5_tag 29 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch6_tag 30 +#define meshtastic_EnvironmentMetrics_adc_voltage_ch7_tag 31 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch0_tag 32 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch1_tag 33 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch2_tag 34 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch3_tag 35 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch4_tag 36 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch5_tag 37 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch6_tag 38 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_ch7_tag 39 #define meshtastic_PowerMetrics_ch1_voltage_tag 1 #define meshtastic_PowerMetrics_ch1_current_tag 2 #define meshtastic_PowerMetrics_ch2_voltage_tag 3 @@ -613,6 +703,7 @@ extern "C" { #define meshtastic_AirQualityMetrics_pm_voc_idx_tag 23 #define meshtastic_AirQualityMetrics_pm_nox_idx_tag 24 #define meshtastic_AirQualityMetrics_particles_tps_tag 25 +#define meshtastic_AirQualityMetrics_pm_status_flags_tag 26 #define meshtastic_LocalStats_uptime_seconds_tag 1 #define meshtastic_LocalStats_channel_utilization_tag 2 #define meshtastic_LocalStats_air_util_tx_tag 3 @@ -664,6 +755,12 @@ extern "C" { #define meshtastic_SEN5XState_voc_state_time_tag 4 #define meshtastic_SEN5XState_voc_state_valid_tag 5 #define meshtastic_SEN5XState_voc_state_array_tag 6 +#define meshtastic_SEN6XState_last_cleaning_time_tag 1 +#define meshtastic_SEN6XState_last_cleaning_valid_tag 2 +#define meshtastic_SEN6XState_one_shot_mode_tag 3 +#define meshtastic_SEN6XState_voc_state_time_tag 4 +#define meshtastic_SEN6XState_voc_state_valid_tag 5 +#define meshtastic_SEN6XState_voc_state_array_tag 6 /* Struct field encoding specification for nanopb */ #define meshtastic_DeviceMetrics_FIELDLIST(X, a) \ @@ -698,7 +795,22 @@ X(a, STATIC, OPTIONAL, FLOAT, rainfall_1h, 19) \ X(a, STATIC, OPTIONAL, FLOAT, rainfall_24h, 20) \ X(a, STATIC, OPTIONAL, UINT32, soil_moisture, 21) \ X(a, STATIC, OPTIONAL, FLOAT, soil_temperature, 22) \ -X(a, STATIC, REPEATED, FLOAT, one_wire_temperature, 23) +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch0, 24) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch1, 25) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch2, 26) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch3, 27) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch4, 28) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch5, 29) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch6, 30) \ +X(a, STATIC, OPTIONAL, FLOAT, adc_voltage_ch7, 31) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch0, 32) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch1, 33) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch2, 34) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch3, 35) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch4, 36) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch5, 37) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch6, 38) \ +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch7, 39) #define meshtastic_EnvironmentMetrics_CALLBACK NULL #define meshtastic_EnvironmentMetrics_DEFAULT NULL @@ -747,7 +859,8 @@ X(a, STATIC, OPTIONAL, FLOAT, pm_temperature, 21) \ X(a, STATIC, OPTIONAL, FLOAT, pm_humidity, 22) \ X(a, STATIC, OPTIONAL, FLOAT, pm_voc_idx, 23) \ X(a, STATIC, OPTIONAL, FLOAT, pm_nox_idx, 24) \ -X(a, STATIC, OPTIONAL, FLOAT, particles_tps, 25) +X(a, STATIC, OPTIONAL, FLOAT, particles_tps, 25) \ +X(a, STATIC, OPTIONAL, UINT32, pm_status_flags, 26) #define meshtastic_AirQualityMetrics_CALLBACK NULL #define meshtastic_AirQualityMetrics_DEFAULT NULL @@ -838,6 +951,16 @@ X(a, STATIC, OPTIONAL, FIXED64, voc_state_array, 6) #define meshtastic_SEN5XState_CALLBACK NULL #define meshtastic_SEN5XState_DEFAULT NULL +#define meshtastic_SEN6XState_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, last_cleaning_time, 1) \ +X(a, STATIC, SINGULAR, BOOL, last_cleaning_valid, 2) \ +X(a, STATIC, SINGULAR, BOOL, one_shot_mode, 3) \ +X(a, STATIC, OPTIONAL, UINT32, voc_state_time, 4) \ +X(a, STATIC, OPTIONAL, BOOL, voc_state_valid, 5) \ +X(a, STATIC, OPTIONAL, FIXED64, voc_state_array, 6) +#define meshtastic_SEN6XState_CALLBACK NULL +#define meshtastic_SEN6XState_DEFAULT NULL + extern const pb_msgdesc_t meshtastic_DeviceMetrics_msg; extern const pb_msgdesc_t meshtastic_EnvironmentMetrics_msg; extern const pb_msgdesc_t meshtastic_PowerMetrics_msg; @@ -849,6 +972,7 @@ extern const pb_msgdesc_t meshtastic_HostMetrics_msg; extern const pb_msgdesc_t meshtastic_Telemetry_msg; extern const pb_msgdesc_t meshtastic_Nau7802Config_msg; extern const pb_msgdesc_t meshtastic_SEN5XState_msg; +extern const pb_msgdesc_t meshtastic_SEN6XState_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_DeviceMetrics_fields &meshtastic_DeviceMetrics_msg @@ -862,18 +986,20 @@ extern const pb_msgdesc_t meshtastic_SEN5XState_msg; #define meshtastic_Telemetry_fields &meshtastic_Telemetry_msg #define meshtastic_Nau7802Config_fields &meshtastic_Nau7802Config_msg #define meshtastic_SEN5XState_fields &meshtastic_SEN5XState_msg +#define meshtastic_SEN6XState_fields &meshtastic_SEN6XState_msg /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_TELEMETRY_PB_H_MAX_SIZE meshtastic_Telemetry_size -#define meshtastic_AirQualityMetrics_size 150 +#define meshtastic_AirQualityMetrics_size 157 #define meshtastic_DeviceMetrics_size 27 -#define meshtastic_EnvironmentMetrics_size 161 +#define meshtastic_EnvironmentMetrics_size 209 #define meshtastic_HealthMetrics_size 11 #define meshtastic_HostMetrics_size 264 #define meshtastic_LocalStats_size 87 #define meshtastic_Nau7802Config_size 16 #define meshtastic_PowerMetrics_size 81 #define meshtastic_SEN5XState_size 27 +#define meshtastic_SEN6XState_size 27 #define meshtastic_Telemetry_size 272 #define meshtastic_TrafficManagementStats_size 42 From fb17f6ddbe28ba8bb1a7cecee1a33b402341612c Mon Sep 17 00:00:00 2001 From: Quency-D <55523105+Quency-D@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:05:30 +0800 Subject: [PATCH 011/109] Add LoRa FEM LNA toggle menu support (#11341) --- src/graphics/draw/MenuHandler.cpp | 82 ++++++++++++++++++++++++++++++- src/graphics/draw/MenuHandler.h | 11 ++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index a1a162dce..f5cd21e1a 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -20,6 +20,9 @@ #include "input/UpDownInterruptImpl1.h" #include "main.h" #include "mesh/Default.h" +#if HAS_LORA_FEM +#include "mesh/LoRaFEMInterface.h" +#endif #include "mesh/MeshTypes.h" #include "mesh/RadioLibInterface.h" #include "modules/AdminModule.h" @@ -139,12 +142,34 @@ uint8_t test_count = 0; void menuHandler::loraMenu() { - static const char *optionsArray[] = {"Back", "Device Role", "Radio Preset", "Frequency Slot", "LoRa Region"}; - enum optionsNumbers { Back = 0, DeviceRolePicker = 1, RadioPresetPicker = 2, FrequencySlot = 3, LoraPicker = 4 }; + static const char *optionsArray[] = { + "Back", + "Device Role", + "Radio Preset", + "Frequency Slot", + "LoRa Region", +#if HAS_LORA_FEM + "FEM LNA", +#endif + }; + enum optionsNumbers { + Back = 0, + DeviceRolePicker = 1, + RadioPresetPicker = 2, + FrequencySlot = 3, + LoraPicker = 4, +#if HAS_LORA_FEM + LoraFemLna = 5 +#endif + }; BannerOverlayOptions bannerOptions; bannerOptions.message = "LoRa Actions"; bannerOptions.optionsArrayPtr = optionsArray; +#if HAS_LORA_FEM + bannerOptions.optionsCount = loraFEMInterface.isLnaCanControl() ? 6 : 5; +#else bannerOptions.optionsCount = 5; +#endif bannerOptions.bannerCallback = [](int selected) -> void { if (selected == Back) { // No action @@ -157,6 +182,11 @@ void menuHandler::loraMenu() } else if (selected == LoraPicker) { menuHandler::menuQueue = menuHandler::LoraPicker; } +#if HAS_LORA_FEM + else if (selected == LoraFemLna) { + menuHandler::menuQueue = menuHandler::LoraFemLnaToggleMenu; + } +#endif }; screen->showOverlayBanner(bannerOptions); } @@ -2804,6 +2834,49 @@ void menuHandler::messageBubblesMenu() screen->showOverlayBanner(bannerOptions); } +#if HAS_LORA_FEM +void menuHandler::LoRaFEMLNAToggleMenu() +{ + static const LoRaFEMLNAToggleOption femToggleOptions[] = { + {"Back", OptionsAction::Back}, + {"Enabled", OptionsAction::Select, meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED}, + {"Disabled", OptionsAction::Select, meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED}, + }; + constexpr size_t toggleCount = sizeof(femToggleOptions) / sizeof(femToggleOptions[0]); + static std::array toggleLabels{}; + + auto bannerOptions = createStaticBannerOptions( + "FEM LNA", femToggleOptions, toggleLabels, [](const LoRaFEMLNAToggleOption &option, int) -> void { + if (option.action == OptionsAction::Back) { + menuQueue = LoraMenu; + screen->runNow(); + return; + } + + if (!option.hasValue || config.lora.fem_lna_mode == option.value) { + return; + } + + const bool enabled = option.value != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED; + config.lora.fem_lna_mode = option.value; + loraFEMInterface.setLNAEnable(enabled); + service->reloadConfig(SEGMENT_CONFIG); + LOG_INFO("FEM LNA %s", enabled ? "enabled" : "disabled"); + }); + + int initialSelection = 0; + for (size_t i = 0; i < toggleCount; ++i) { + if (femToggleOptions[i].hasValue && config.lora.fem_lna_mode == femToggleOptions[i].value) { + initialSelection = static_cast(i); + break; + } + } + bannerOptions.InitialSelected = initialSelection; + + screen->showOverlayBanner(bannerOptions); +} +#endif + void menuHandler::themeMenu() { // Build menu dynamically from the theme table. @@ -3013,6 +3086,11 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case LicensedToNormalConfirm: licensedToNormalConfirmMenu(); break; +#if HAS_LORA_FEM + case LoraFemLnaToggleMenu: + LoRaFEMLNAToggleMenu(); + break; +#endif } menuQueue = MenuNone; } diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index e05742f97..311205e0a 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -59,7 +59,10 @@ class menuHandler MessageBubblesMenu, ThemeMenu, HamModeConfirm, - LicensedToNormalConfirm + LicensedToNormalConfirm, +#if HAS_LORA_FEM + LoraFemLnaToggleMenu +#endif }; static screenMenus menuQueue; static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu @@ -120,6 +123,9 @@ class menuHandler static void textMessageMenu(); static void hamModeConfirmMenu(); static void licensedToNormalConfirmMenu(); +#if HAS_LORA_FEM + static void LoRaFEMLNAToggleMenu(); +#endif // Lifted out of its banner-callback lambda so it is reachable without a Screen. The lambda only // ever runs via screen->showOverlayBanner(), which is why nothing here was unit-testable. @@ -159,6 +165,9 @@ using NodeNameOption = MenuOption; using PositionMenuOption = MenuOption; using ManageNodeOption = MenuOption; using ClockFaceOption = MenuOption; +#if HAS_LORA_FEM +using LoRaFEMLNAToggleOption = MenuOption; +#endif } // namespace graphics #endif From 546b9d9e4029161e365cf69dcaf1204b148fa1fd Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:34:08 -0400 Subject: [PATCH 012/109] Block coordinate traffic on configured event channels (#11045) * Block coordinate traffic on configured event channels Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Suppress event coordinates in reliable relay paths Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Reject blocked phone coordinates before rate limiting Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Prevent event coordinates from reaching MQTT Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Add event coordinate policy preference Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Test event coordinate policy in native CI Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Make event policy test tolerate a full NodeDB Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Test Router event coordinate enforcement Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Test PhoneAPI event coordinate retry handling Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Test reliable event coordinate suppression Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Test MQTT event coordinate suppression Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * Run event policy behavioral suites in native CI Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * tests: address CodeRabbit review feedback - test_event_channel_phone_api: complete the setUp/tearDown save-restore pair. GlobalState now carries cryptLock and myNodeInfo; setUp() nulls cryptLock before constructing MockRouter (Router's ctor asserts it is unset), and tearDown() restores both so the suite leaves no global mutated. Not reachable today - the globals start null in this binary - but the pair was asymmetric. - Replace the strcpy calls this branch added on Channel.settings.name (char[12]) with the bounded form the rest of the test tree already uses, strncpy(dst, src, sizeof(dst) - 1). Covers the flagged site in test_nexthop_routing plus the six equivalents in test_event_channel_phone_api, test_mqtt and test_position_precision, which trip the same ast-grep dangerous-buffer-functions-cpp rule. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Sisyphus Co-authored-by: Ben Meadors Co-authored-by: Claude Opus 5 --- .github/workflows/test_native.yml | 5 +- src/mesh/Channels.cpp | 15 + src/mesh/Channels.h | 13 +- src/mesh/NextHopRouter.cpp | 11 +- src/mesh/PhoneAPI.cpp | 10 + src/mesh/PositionPrecision.cpp | 4 + src/mesh/ReliableRouter.cpp | 6 + src/mesh/Router.cpp | 74 +++- src/mesh/Router.h | 9 + src/mqtt/MQTT.cpp | 12 + test/native-suite-count | 2 +- .../test_main.cpp | 263 ++++++++++++ test/test_event_channel_router/test_main.cpp | 405 ++++++++++++++++++ test/test_mqtt/MQTT.cpp | 126 +++++- test/test_nexthop_routing/test_main.cpp | 375 ++++++++++++++-- test/test_position_precision/test_main.cpp | 185 ++++++++ userPrefs.jsonc | 1 + variants/native/portduino/platformio.ini | 12 + 18 files changed, 1492 insertions(+), 36 deletions(-) create mode 100644 test/test_event_channel_phone_api/test_main.cpp create mode 100644 test/test_event_channel_router/test_main.cpp diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 2688a07b9..a6350188d 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -329,13 +329,16 @@ jobs: lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative. + - name: Event channel policy tests + run: platformio test -e coverage-event-policy -v --junit-output-path event-policy-testreport.xml + - name: Save test results if: always() # run this step even if previous step failed uses: actions/upload-artifact@v7 with: name: platformio-test-report-${{ steps.version.outputs.long }} overwrite: true - path: ./testreport.xml + path: ./*testreport.xml - name: Save coverage information uses: actions/upload-artifact@v7 diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 2d8d4f246..5860c6fc7 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -497,6 +497,21 @@ bool Channels::isWellKnownChannel(ChannelIndex chIndex) return false; } +bool Channels::isEventChannel(ChannelIndex chIndex) +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK; + static_assert(sizeof(configuredEventPsk) == 16 || sizeof(configuredEventPsk) == 32, + "USERPREFS_CHANNEL_0_PSK must be an AES-128 or AES-256 key"); + CryptoKey effectiveKey = getKey(chIndex); + return effectiveKey.length == sizeof(configuredEventPsk) && + memcmp(effectiveKey.bytes, configuredEventPsk, sizeof(configuredEventPsk)) == 0; +#else + (void)chIndex; + return false; +#endif +} + bool Channels::hasDefaultChannel() { // If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 6e17a7ab6..6fc40b0e4 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -5,6 +5,14 @@ #include "mesh-pb-constants.h" #include +#ifndef USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +#define USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL USERPREFS_EVENT_MODE +#endif + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && !defined(USERPREFS_CHANNEL_0_PSK) +#error "USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL requires USERPREFS_CHANNEL_0_PSK" +#endif + /** A channel number (index into the channel table) */ typedef uint8_t ChannelIndex; @@ -95,6 +103,9 @@ class Channels // matches the current preset's name and PSK byte 1. bool isWellKnownChannel(ChannelIndex chIndex); + // Returns true if this channel's effective key matches USERPREFS_CHANNEL_0_PSK. + bool isEventChannel(ChannelIndex chIndex); + // Returns true if we can be reached via a channel with the default settings given a region and modem preset bool hasDefaultChannel(); @@ -164,4 +175,4 @@ bool channelFileUsesPublicKey(const meshtastic_ChannelFile &cf, ChannelIndex chI static const uint8_t eventpsk[] = {0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, - 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1}; \ No newline at end of file + 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1}; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index b265e0ff5..c4eb5c681 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -113,7 +113,8 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) // If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again if (isRepeated) { if (!findInTxQueue(p->from, p->id)) { - if (reprocessPacket(p) && !perhapsRebroadcast(p) && isToUs(p) && p->want_ack) { + if (reprocessPacket(p) && !isBlockedEventCoordinatePacket(p) && !perhapsRebroadcast(p) && isToUs(p) && + p->want_ack) { sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0); } } @@ -190,6 +191,14 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast /* Check if we should be rebroadcasting this packet if so, do so. */ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) { +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + // Never relay coordinate-bearing packets on the event ("everyone") channel. + // Closes the reliable-retransmit-dupe path that runs before handleReceived(). + if (isBlockedEventCoordinatePacket(p)) { + return false; + } +#endif + // Check if traffic management wants to exhaust this packet's hops bool exhaustHops = false; #if HAS_TRAFFIC_MANAGEMENT diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index fe6d9ba0a..28d35d00f 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -1812,6 +1812,16 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p) } #endif + // Reject before recording duplicate or per-port cooldown state, so a blocked + // attempt cannot throttle a valid private-channel position retry. + if (isBlockedEventCoordinatePacket(&p)) { + LOG_DEBUG("Suppress phone coordinate send on event (everyone) channel"); + meshtastic_QueueStatus qs = router->getQueueStatus(); + service->sendQueueStatusToPhone(qs, 0, p.id); + sendNotification(meshtastic_LogRecord_Level_WARNING, p.id, "Location sharing is disabled on this channel"); + return false; + } + #if defined(ARCH_PORTDUINO) // For use with the simulator, we should not ignore duplicate packets from the phone if (SimRadio::instance == nullptr) diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp index 4302531a5..d34c66086 100644 --- a/src/mesh/PositionPrecision.cpp +++ b/src/mesh/PositionPrecision.cpp @@ -16,6 +16,10 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel) uint32_t getPositionPrecisionForChannel(uint8_t channelIndex) { + // Event-channel privacy takes precedence over every stored precision and key policy. + if (channels.isEventChannel(channelIndex)) + return 0; + const meshtastic_Channel &ch = channels.getByIndex(channelIndex); if (ch.role == meshtastic_Channel_Role_DISABLED) return 0; diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index fce6b8a32..72ef73eab 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -16,6 +16,12 @@ */ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p) { + if (isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Suppress reliable coordinate send on event (everyone) channel"); + packetPool.release(p); + return meshtastic_Routing_Error_NOT_AUTHORIZED; + } + const GlobalPacketId key(p); const bool retransmitting = p->want_ack; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 77653248a..5e33db235 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -69,6 +69,52 @@ Allocator &packetPool = staticPool; static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__)); +static ChannelIndex getEffectiveChannelIndex(const meshtastic_MeshPacket *p) +{ + ChannelIndex chIndex = p->channel; + if (nodeDB && isFromUs(p) && !chIndex && !p->pki_encrypted && !isBroadcast(p->to)) { + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); + if (node) + chIndex = node->channel; + } + return chIndex; +} + +bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p) +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (p->pki_encrypted || willUsePki(p)) { + return false; + } + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + return isCoordinatePortnum(p->decoded.portnum) && channels.isEventChannel(getEffectiveChannelIndex(p)); + } + return false; +#else + (void)p; + return false; +#endif +} + +bool willUsePki(const meshtastic_MeshPacket *p) +{ +#if !(MESHTASTIC_EXCLUDE_PKI) + if (p->which_payload_variant != meshtastic_MeshPacket_decoded_tag || !isFromUs(p)) + return false; + bool haveDestKey = false; + if (p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP) { + meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}}; + haveDestKey = nodeDB->copyPublicKey(p->to, destKey); + if (!haveDestKey && p->pki_encrypted) + haveDestKey = crypto->getPendingPublicKey(p->to, destKey); + } + return wouldEncryptWithPKC(p, getEffectiveChannelIndex(p), haveDestKey); +#else + (void)p; + return false; +#endif +} + struct RoutingAuthCache { bool valid = false; // Deliberately NOT initialized in-class as this eats flash space. @@ -141,7 +187,6 @@ void resetRoutingAuthEvaluationCount() } } #endif - /** * Constructor * @@ -360,9 +405,9 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) // don't override if a channel was requested and no need to set it when PKI is enforced if (!p->channel && !p->pki_encrypted && !isBroadcast(p->to)) { - meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->to); - if (node) { - p->channel = node->channel; + ChannelIndex chIndex = getEffectiveChannelIndex(p); + if (chIndex) { + p->channel = chIndex; LOG_DEBUG("localSend to channel %d", p->channel); } } @@ -473,6 +518,12 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) fixPriority(p); // Before encryption, fix the priority if it's unset // Position precision is an originator-only privacy policy. Relays keep // p->from as the original sender, so do not rewrite their POSITION_APP payload. + if (isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Suppress coordinate send on event (everyone) channel"); + packetPool.release(p); + return meshtastic_Routing_Error_NOT_AUTHORIZED; + } + if (isFromUs(p)) { if (!applyPositionPrecisionForChannel(*p, p->channel)) { LOG_ERROR("Drop malformed position packet before send"); @@ -959,6 +1010,11 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) return DecodeState::DECODE_POLICY_REJECT; #endif + if (isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Decoded coordinate packet on event channel; suppress payload logging"); + return DecodeState::DECODE_SUCCESS; + } + if (p->decoded.has_bitfield) p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; @@ -1436,6 +1492,16 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) cancelSending(p->from, p->id); skipHandle = true; } + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + // Discard coordinate-bearing packets that arrive on the event ("everyone") + // channel: don't process, store in NodeDB, or rebroadcast them. + if (!skipHandle && isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Drop coordinate packet on event (everyone) channel"); + cancelSending(p->from, p->id); + skipHandle = true; + } +#endif } else { printPacket("packet decoding failed or skipped (no PSK?)", p); } diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 2a4d979c2..003aebc57 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -11,6 +11,15 @@ #include "concurrency/OSThread.h" #include +inline bool isCoordinatePortnum(meshtastic_PortNum portnum) +{ + return portnum == meshtastic_PortNum_POSITION_APP || portnum == meshtastic_PortNum_WAYPOINT_APP || + portnum == meshtastic_PortNum_MAP_REPORT_APP; +} + +bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p); +bool willUsePki(const meshtastic_MeshPacket *p); + /// 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 { diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index ec2894c9e..6bd2f3688 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -700,6 +700,12 @@ void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_Me { if (mp_encrypted.via_mqtt) return; // Don't send messages that came from MQTT back into MQTT +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (isBlockedEventCoordinatePacket(&mp_decoded)) { + LOG_DEBUG("MQTT onSend - Suppress coordinate packet on event channel"); + return; + } +#endif bool uplinkEnabled = false; for (int i = 0; i <= 7; i++) { if (channels.getByIndex(i).settings.uplink_enabled) @@ -777,6 +783,12 @@ void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_Me void MQTT::perhapsReportToMap() { +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (channels.isEventChannel(channels.getPrimaryIndex())) { + LOG_DEBUG("Suppress MQTT map report on event (everyone) channel"); + return; + } +#endif if (!moduleConfig.mqtt.map_reporting_enabled || !moduleConfig.mqtt.map_report_settings.should_report_location || !(moduleConfig.mqtt.proxy_to_client_enabled || isConnectedDirectly())) return; diff --git a/test/native-suite-count b/test/native-suite-count index ea90ee319..9e5feb525 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -45 +46 diff --git a/test/test_event_channel_phone_api/test_main.cpp b/test/test_event_channel_phone_api/test_main.cpp new file mode 100644 index 000000000..f7932b9c3 --- /dev/null +++ b/test/test_event_channel_phone_api/test_main.cpp @@ -0,0 +1,263 @@ +#include "Channels.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "RadioInterface.h" +#include "Router.h" +#include "StreamAPI.h" +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include +#include +#include +#include + +namespace +{ +constexpr PacketId BLOCKED_PACKET_ID = 0x10203040; +constexpr PacketId FOLLOWUP_PACKET_ID = 0x50607080; +constexpr ChannelIndex EVENT_CHANNEL = 0; +constexpr ChannelIndex PRIVATE_CHANNEL = 1; +constexpr NodeNum REMOTE_NODE = 0x12345678; + +class MockRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *packet) override + { + packetPool.release(packet); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t, bool) override { return 0; } +}; + +class MockRouter : public Router +{ + public: + MockRouter() { addInterface(std::make_unique()); } + + ~MockRouter() + { + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *packet) override + { + sentPackets.push_back(*packet); + packetPool.release(packet); + return ERRNO_OK; + } + + std::vector sentPackets; +}; + +class MockMeshService : public MeshService +{ + public: + ~MockMeshService() + { + while (auto *status = getQueueStatusForPhone()) { + releaseQueueStatusToPool(status); + } + } + + void sendClientNotification(meshtastic_ClientNotification *notification) override + { + notifications.push_back(*notification); + releaseClientNotificationToPool(notification); + } + + void assertQueueStatus(PacketId packetId) + { + auto *status = getQueueStatusForPhone(); + TEST_ASSERT_NOT_NULL(status); + TEST_ASSERT_EQUAL_UINT32(packetId, status->mesh_packet_id); + releaseQueueStatusToPool(status); + } + + std::vector notifications; +}; + +class TestStreamAPI : public StreamAPI +{ + public: + TestStreamAPI() : StreamAPI(nullptr) {} + bool checkIsConnected() override { return true; } +}; + +struct GlobalState { + MeshService *service; + Router *router; + NodeDB *nodeDB; + // Router's ctor asserts !cryptLock and allocates one; ~MockRouter() deletes it. Save the + // incoming lock so the restored router keeps the one it was built with. + concurrency::Lock *cryptLock; + meshtastic_MyNodeInfo myNodeInfo; + Channels channels; + meshtastic_ChannelFile channelFile; + meshtastic_LocalConfig config; + meshtastic_LocalModuleConfig moduleConfig; + meshtastic_DeviceState deviceState; +}; + +GlobalState *savedState; +MockMeshService *mockService; +MockRouter *mockRouter; +NodeDB *mockNodeDB; +TestStreamAPI *streamAPI; + +void configureChannels() +{ + const meshtastic_ChannelFile defaultChannelFile = meshtastic_ChannelFile_init_default; + channelFile = defaultChannelFile; + channelFile.channels_count = 2; + + auto &eventChannel = channelFile.channels[EVENT_CHANNEL]; + eventChannel.index = EVENT_CHANNEL; + eventChannel.has_settings = true; + eventChannel.role = meshtastic_Channel_Role_PRIMARY; + strncpy(eventChannel.settings.name, "everyone", sizeof(eventChannel.settings.name) - 1); +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t eventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(eventPsk); + memcpy(eventChannel.settings.psk.bytes, eventPsk, sizeof(eventPsk)); +#endif + + auto &privateChannel = channelFile.channels[PRIVATE_CHANNEL]; + privateChannel.index = PRIVATE_CHANNEL; + privateChannel.has_settings = true; + privateChannel.role = meshtastic_Channel_Role_SECONDARY; + strncpy(privateChannel.settings.name, "private", sizeof(privateChannel.settings.name) - 1); + privateChannel.settings.psk.size = 32; + memset(privateChannel.settings.psk.bytes, 0xab, privateChannel.settings.psk.size); + + channels.onConfigChanged(); +} + +meshtastic_ToRadio makePositionToRadio(PacketId id, ChannelIndex channel) +{ + meshtastic_ToRadio message = meshtastic_ToRadio_init_default; + const meshtastic_MeshPacket defaultPacket = meshtastic_MeshPacket_init_default; + message.which_payload_variant = meshtastic_ToRadio_packet_tag; + message.packet = defaultPacket; + message.packet.to = REMOTE_NODE; + message.packet.id = id; + message.packet.channel = channel; + message.packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + message.packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + return message; +} + +bool sendToRadio(const meshtastic_ToRadio &message) +{ + uint8_t encoded[meshtastic_ToRadio_size] = {}; + const size_t encodedSize = + pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ToRadio_msg, const_cast(&message)); + if (encodedSize == 0) { + return false; + } + return streamAPI->handleToRadio(encoded, encodedSize); +} + +void assertSentPacket(size_t index, PacketId id, ChannelIndex channel) +{ + TEST_ASSERT_GREATER_THAN(index, mockRouter->sentPackets.size()); + const auto &packet = mockRouter->sentPackets[index]; + TEST_ASSERT_EQUAL_UINT32(id, packet.id); + TEST_ASSERT_EQUAL_UINT8(channel, packet.channel); + TEST_ASSERT_EQUAL(meshtastic_PortNum_POSITION_APP, packet.decoded.portnum); +} +} // namespace + +void setUp(void) +{ + savedState = + new GlobalState{service, router, nodeDB, cryptLock, myNodeInfo, channels, channelFile, config, moduleConfig, devicestate}; + + service = mockService = new MockMeshService(); + nodeDB = mockNodeDB = new NodeDB(); + myNodeInfo.my_node_num = 0x87654321; + configureChannels(); + cryptLock = nullptr; // Router's ctor asserts this is unset before allocating its own. + router = mockRouter = new MockRouter(); + streamAPI = new TestStreamAPI(); + testDelay(1); +} + +void tearDown(void) +{ + delete streamAPI; + streamAPI = nullptr; + delete mockRouter; + mockRouter = nullptr; + delete mockNodeDB; + mockNodeDB = nullptr; + delete mockService; + mockService = nullptr; + + service = savedState->service; + router = savedState->router; + nodeDB = savedState->nodeDB; + cryptLock = savedState->cryptLock; // ~MockRouter() nulled it; hand the saved router its own back. + myNodeInfo = savedState->myNodeInfo; + channels = savedState->channels; + channelFile = savedState->channelFile; + config = savedState->config; + moduleConfig = savedState->moduleConfig; + devicestate = savedState->deviceState; + delete savedState; + savedState = nullptr; +} + +static void test_event_position_ingress_does_not_poison_retry_state() +{ + const auto eventAttempt = makePositionToRadio(BLOCKED_PACKET_ID, EVENT_CHANNEL); + const auto sameIdPrivateRetry = makePositionToRadio(BLOCKED_PACKET_ID, PRIVATE_CHANNEL); + const auto immediatePrivateFollowup = makePositionToRadio(FOLLOWUP_PACKET_ID, PRIVATE_CHANNEL); + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_FALSE(sendToRadio(eventAttempt)); + TEST_ASSERT_EQUAL(0, mockRouter->sentPackets.size()); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(1, mockService->notifications.size()); + TEST_ASSERT_EQUAL_UINT32(BLOCKED_PACKET_ID, mockService->notifications[0].reply_id); + + TEST_ASSERT_TRUE(sendToRadio(sameIdPrivateRetry)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, BLOCKED_PACKET_ID, PRIVATE_CHANNEL); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + + TEST_ASSERT_FALSE(sendToRadio(immediatePrivateFollowup)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + mockService->assertQueueStatus(FOLLOWUP_PACKET_ID); + TEST_ASSERT_EQUAL(1, mockService->notifications.size()); +#else + TEST_ASSERT_TRUE(sendToRadio(eventAttempt)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, BLOCKED_PACKET_ID, EVENT_CHANNEL); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); + + TEST_ASSERT_FALSE(sendToRadio(sameIdPrivateRetry)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + TEST_ASSERT_NULL(mockService->getQueueStatusForPhone()); + + TEST_ASSERT_FALSE(sendToRadio(immediatePrivateFollowup)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + mockService->assertQueueStatus(FOLLOWUP_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); +#endif +} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_event_position_ingress_does_not_poison_retry_state); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_event_channel_router/test_main.cpp b/test/test_event_channel_router/test_main.cpp new file mode 100644 index 000000000..c1f5e8bec --- /dev/null +++ b/test/test_event_channel_router/test_main.cpp @@ -0,0 +1,405 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "airtime.h" +#include "mesh/Channels.h" +#include "mesh/CryptoEngine.h" +#include "mesh/MeshModule.h" +#include "mesh/MeshRadio.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "mesh/Router.h" +#if ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" +#endif +#include +#include +#include +#include +#include +#include + +#if ARCH_PORTDUINO +#define EVENT_ROUTER_TEST_ENTRY extern "C" +#else +#define EVENT_ROUTER_TEST_ENTRY +#endif + +namespace +{ + +constexpr NodeNum kLocalNode = 0x11111111; +constexpr NodeNum kRemoteNode = 0x22222222; +constexpr NodeNum kPkiPeer = 0x33333333; +constexpr ChannelIndex kEventChannel = 0; +constexpr ChannelIndex kPrivateChannel = 1; + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +constexpr bool kBlockEventCoordinates = true; +constexpr ErrorCode kExpectedEventTxResult = meshtastic_Routing_Error_NOT_AUTHORIZED; +constexpr size_t kExpectedEventDeliveryCount = 0; +#else +constexpr bool kBlockEventCoordinates = false; +constexpr ErrorCode kExpectedEventTxResult = ERRNO_OK; +constexpr size_t kExpectedEventDeliveryCount = 3; +#endif + +constexpr std::array kCoordinatePorts = { + meshtastic_PortNum_POSITION_APP, + meshtastic_PortNum_WAYPOINT_APP, + meshtastic_PortNum_MAP_REPORT_APP, +}; + +class TestNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num, ChannelIndex channel, const uint8_t *publicKey = nullptr) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + node.channel = channel; + if (publicKey) { + node.public_key.size = 32; + memcpy(node.public_key.bytes, publicKey, 32); + } + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + private: + std::vector testNodes; +}; + +class CaptureRadio : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *packet) override + { + packets.push_back(*packet); + packetPool.release(packet); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t, bool = false) override { return 0; } + + std::vector packets; +}; + +class CaptureModule : public MeshModule +{ + public: + CaptureModule() : MeshModule("event-router-capture") { encryptedOk = true; } + + bool wantPacket(const meshtastic_MeshPacket *) override { return true; } + + ProcessMessage handleReceived(const meshtastic_MeshPacket &packet) override + { + packets.push_back(packet); + return ProcessMessage::CONTINUE; + } + + std::vector packets; +}; + +struct SavedGlobals { + meshtastic_LocalConfig config; + meshtastic_LocalModuleConfig moduleConfig; + meshtastic_ChannelFile channelFile; + meshtastic_User owner; + meshtastic_MyNodeInfo myNodeInfo; + NodeDB *nodeDB; + Router *router; + MeshService *service; + AirTime *airTime; + concurrency::Lock *cryptLock; +#if ARCH_PORTDUINO + bool forceSimRadio; +#endif +}; + +SavedGlobals saved; +TestNodeDB *testNodeDB = nullptr; +Router *testRouter = nullptr; +CaptureRadio *captureRadio = nullptr; +CaptureModule *captureModule = nullptr; +AirTime *testAirTime = nullptr; + +static void installChannels() +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel &event = channelFile.channels[kEventChannel]; + memset(&event, 0, sizeof(event)); + event.index = kEventChannel; + event.role = meshtastic_Channel_Role_PRIMARY; + event.has_settings = true; + strncpy(event.settings.name, "everyone", sizeof(event.settings.name) - 1); +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t eventKey[] = USERPREFS_CHANNEL_0_PSK; + static_assert(sizeof(eventKey) == 16 || sizeof(eventKey) == 32); + event.settings.psk.size = sizeof(eventKey); + memcpy(event.settings.psk.bytes, eventKey, sizeof(eventKey)); +#else + static const uint8_t eventKey[16] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; + event.settings.psk.size = sizeof(eventKey); + memcpy(event.settings.psk.bytes, eventKey, sizeof(eventKey)); +#endif + + meshtastic_Channel &privateChannel = channelFile.channels[kPrivateChannel]; + memset(&privateChannel, 0, sizeof(privateChannel)); + privateChannel.index = kPrivateChannel; + privateChannel.role = meshtastic_Channel_Role_SECONDARY; + privateChannel.has_settings = true; + strncpy(privateChannel.settings.name, "private", sizeof(privateChannel.settings.name) - 1); + privateChannel.settings.psk.size = 32; + for (size_t i = 0; i < privateChannel.settings.psk.size; ++i) + privateChannel.settings.psk.bytes[i] = static_cast(0x80 + i); + + channels.onConfigChanged(); +} + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to, ChannelIndex channel) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = from; + packet.to = to; + packet.id = 0x40000000u + static_cast(port); + packet.channel = channel; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = port; + + if (port == meshtastic_PortNum_POSITION_APP) { + meshtastic_Position position = meshtastic_Position_init_zero; + position.has_latitude_i = true; + position.latitude_i = 374221234; + position.has_longitude_i = true; + position.longitude_i = -1220845678; + packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), + &meshtastic_Position_msg, &position); + } else { + packet.decoded.payload.size = 1; + packet.decoded.payload.bytes[0] = 0x5a; + } + return packet; +} + +static ErrorCode sendCoordinate(meshtastic_PortNum port, ChannelIndex channel, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket *packet = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(packet); + const meshtastic_MeshPacket contents = makeDecodedPacket(port, kLocalNode, to, channel); + packet->to = contents.to; + packet->channel = contents.channel; + packet->decoded = contents.decoded; + return testRouter->send(packet); +} + +static void receivePacket(const meshtastic_MeshPacket &contents) +{ + meshtastic_MeshPacket *packet = packetPool.allocCopy(contents); + TEST_ASSERT_NOT_NULL(packet); + testRouter->enqueueReceivedMessage(packet); + testRouter->runOnce(); +} + +static void test_tx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports() +{ + TEST_ASSERT_EQUAL(kBlockEventCoordinates, channels.isEventChannel(kEventChannel)); + + for (meshtastic_PortNum port : kCoordinatePorts) { + const size_t before = captureRadio->packets.size(); + TEST_ASSERT_EQUAL_INT(kExpectedEventTxResult, sendCoordinate(port, kEventChannel)); + TEST_ASSERT_EQUAL_UINT32(before + (kBlockEventCoordinates ? 0 : 1), captureRadio->packets.size()); + } +} + +static void test_rx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports() +{ + for (meshtastic_PortNum port : kCoordinatePorts) + receivePacket(makeDecodedPacket(port, kRemoteNode, NODENUM_BROADCAST, kEventChannel)); + + TEST_ASSERT_EQUAL_UINT32(kExpectedEventDeliveryCount, captureModule->packets.size()); +} + +static void test_private_channel_preserves_legacy_tx_and_rx_for_all_coordinate_ports() +{ + TEST_ASSERT_FALSE(channels.isEventChannel(kPrivateChannel)); + + for (meshtastic_PortNum port : kCoordinatePorts) { + TEST_ASSERT_EQUAL_INT(ERRNO_OK, sendCoordinate(port, kPrivateChannel)); + receivePacket(makeDecodedPacket(port, kRemoteNode, NODENUM_BROADCAST, kPrivateChannel)); + } + + TEST_ASSERT_EQUAL_UINT32(kCoordinatePorts.size(), captureRadio->packets.size()); + TEST_ASSERT_EQUAL_UINT32(kCoordinatePorts.size(), captureModule->packets.size()); +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +static void test_tx_event_coordinate_that_uses_pki_reaches_radio() +{ + uint8_t peerPublic[32], peerPrivate[32]; + uint8_t localPublic[32], localPrivate[32]; + crypto->generateKeyPair(peerPublic, peerPrivate); + crypto->generateKeyPair(localPublic, localPrivate); + + config.has_security = true; + config.security.private_key.size = 32; + config.security.public_key.size = 32; + memcpy(config.security.private_key.bytes, localPrivate, 32); + memcpy(config.security.public_key.bytes, localPublic, 32); + crypto->setDHPrivateKey(localPrivate); + testNodeDB->addNode(kPkiPeer, kEventChannel, peerPublic); + + TEST_ASSERT_EQUAL_INT(ERRNO_OK, sendCoordinate(meshtastic_PortNum_WAYPOINT_APP, kEventChannel, kPkiPeer)); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); + TEST_ASSERT_TRUE(captureRadio->packets.front().pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, captureRadio->packets.front().which_payload_variant); +} +#endif + +static void test_opaque_tx_is_not_misclassified_as_coordinates() +{ + meshtastic_MeshPacket *outgoing = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(outgoing); + outgoing->channel = channels.getHash(kEventChannel); + outgoing->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + outgoing->encrypted.size = 1; + outgoing->encrypted.bytes[0] = 0xa5; + + TEST_ASSERT_EQUAL_INT(ERRNO_OK, testRouter->send(outgoing)); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); +} + +static void test_capture_endpoints_release_packet_pool_ownership() +{ + constexpr size_t iterations = 64; + for (size_t i = 0; i < iterations; ++i) { + meshtastic_MeshPacket *outgoing = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(outgoing); + outgoing->channel = kPrivateChannel; + outgoing->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + outgoing->decoded.payload.size = 1; + outgoing->decoded.payload.bytes[0] = static_cast(i); + TEST_ASSERT_EQUAL_INT(ERRNO_OK, testRouter->send(outgoing)); + + meshtastic_MeshPacket incoming = + makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, kPrivateChannel); + incoming.id += i; + receivePacket(incoming); + } + + TEST_ASSERT_EQUAL_UINT32(iterations, captureRadio->packets.size()); + TEST_ASSERT_EQUAL_UINT32(iterations, captureModule->packets.size()); +} + +} // namespace + +void setUp(void) +{ + saved.config = config; + saved.moduleConfig = moduleConfig; + saved.channelFile = channelFile; + saved.owner = owner; + saved.myNodeInfo = myNodeInfo; + saved.nodeDB = nodeDB; + saved.router = router; + saved.service = service; + saved.airTime = airTime; + saved.cryptLock = cryptLock; +#if ARCH_PORTDUINO + saved.forceSimRadio = portduino_config.force_simradio; +#endif + + testNodeDB = new TestNodeDB(); + testNodeDB->clearTestNodes(); + nodeDB = testNodeDB; + + memset(&config, 0, sizeof(config)); + config.lora.override_duty_cycle = true; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + memset(&moduleConfig, 0, sizeof(moduleConfig)); + memset(&owner, 0, sizeof(owner)); + memset(&myNodeInfo, 0, sizeof(myNodeInfo)); + myNodeInfo.my_node_num = kLocalNode; + service = nullptr; +#if ARCH_PORTDUINO + portduino_config.force_simradio = false; +#endif + installChannels(); + + testAirTime = new AirTime(); + airTime = testAirTime; + + cryptLock = nullptr; + testRouter = new Router(); + router = testRouter; + std::unique_ptr radio(new CaptureRadio()); + captureRadio = radio.get(); + testRouter->addInterface(std::move(radio)); + captureModule = new CaptureModule(); +} + +void tearDown(void) +{ + delete captureModule; + captureModule = nullptr; + + router = nullptr; + delete testRouter; + testRouter = nullptr; + captureRadio = nullptr; + delete cryptLock; + cryptLock = saved.cryptLock; + + delete testNodeDB; + testNodeDB = nullptr; + delete testAirTime; + testAirTime = nullptr; + + config = saved.config; + moduleConfig = saved.moduleConfig; + channelFile = saved.channelFile; + owner = saved.owner; + myNodeInfo = saved.myNodeInfo; + channels.onConfigChanged(); + nodeDB = saved.nodeDB; + router = saved.router; + service = saved.service; + airTime = saved.airTime; +#if ARCH_PORTDUINO + portduino_config.force_simradio = saved.forceSimRadio; +#endif +} + +EVENT_ROUTER_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Router event-channel coordinate enforcement ===\n"); + RUN_TEST(test_tx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports); + RUN_TEST(test_rx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports); + RUN_TEST(test_private_channel_preserves_legacy_tx_and_rx_for_all_coordinate_ports); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_tx_event_coordinate_that_uses_pki_reaches_radio); +#endif + RUN_TEST(test_opaque_tx_is_not_misclassified_as_coordinates); + RUN_TEST(test_capture_endpoints_release_packet_pool_ownership); + + exit(UNITY_END()); +} + +EVENT_ROUTER_TEST_ENTRY void loop() {} diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index e2d006e38..3c4f1ab6a 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -338,16 +338,64 @@ const meshtastic_MeshPacket encrypted = { .encrypted = {.size = 0}, .id = 3, }; + +void configureCoordinatePolicyChannels(bool eventChannelIsPrimary = true) +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + auto &eventChannel = channelFile.channels[0]; + eventChannel.index = 0; + eventChannel.has_settings = true; + strncpy(eventChannel.settings.name, "everyone", sizeof(eventChannel.settings.name) - 1); + eventChannel.settings.uplink_enabled = true; + eventChannel.settings.downlink_enabled = true; + eventChannel.role = eventChannelIsPrimary ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY; +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(configuredEventPsk); + memcpy(eventChannel.settings.psk.bytes, configuredEventPsk, sizeof(configuredEventPsk)); +#endif + + auto &privateChannel = channelFile.channels[1]; + privateChannel.index = 1; + privateChannel.has_settings = true; + strncpy(privateChannel.settings.name, "private", sizeof(privateChannel.settings.name) - 1); + privateChannel.settings.psk.size = 32; + memset(privateChannel.settings.psk.bytes, 0xab, privateChannel.settings.psk.size); + privateChannel.settings.uplink_enabled = true; + privateChannel.settings.downlink_enabled = true; + privateChannel.role = eventChannelIsPrimary ? meshtastic_Channel_Role_SECONDARY : meshtastic_Channel_Role_PRIMARY; + + channels.onConfigChanged(); +} + +meshtastic_MeshPacket makePositionPacket(ChannelIndex channel) +{ + meshtastic_MeshPacket packet = decoded; + packet.to = NODENUM_BROADCAST; + packet.channel = channel; + packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + return packet; +} + +void clearPublicationState() +{ + TEST_ASSERT_EQUAL(0, unitTest->queueSize()); + pubsub->published_.clear(); + mockMeshService->messages_.clear(); +} } // namespace // Initialize mocks and configuration before running each test. void setUp(void) { - config = meshtastic_LocalConfig_init_zero; + memset(&config, 0, sizeof(config)); moduleConfig.mqtt = meshtastic_ModuleConfig_MQTTConfig{.enabled = true, .map_reporting_enabled = true, .has_map_report_settings = true}; moduleConfig.mqtt.map_report_settings = meshtastic_ModuleConfig_MapReportSettings{ .publish_interval_secs = 0, .position_precision = 14, .should_report_location = true}; + memset(&channelFile, 0, sizeof(channelFile)); channelFile.channels[0] = meshtastic_Channel{ .index = 0, .has_settings = true, @@ -355,6 +403,7 @@ void setUp(void) .role = meshtastic_Channel_Role_PRIMARY, }; channelFile.channels_count = 1; + channels.onConfigChanged(); owner = meshtastic_User{.id = "!12345678"}; myNodeInfo = meshtastic_MyNodeInfo{.my_node_num = 0x12345678}; // Match the expected gateway ID in topic localPosition = @@ -412,6 +461,50 @@ void test_sendDirectlyConnectedEncrypted(void) TEST_ASSERT_EQUAL(encrypted.id, env.packet->id); } +void test_eventPositionPublicationFollowsCompileTimePolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + const meshtastic_MeshPacket position = makePositionPacket(0); + + mqtt->onSend(encrypted, position, 0); + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_TRUE(pubsub->published_.empty()); + TEST_ASSERT_EQUAL(0, unitTest->queueSize()); +#else + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); +#endif +} + +void test_privatePositionStillPublishesWithEventPolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + const meshtastic_MeshPacket position = makePositionPacket(1); + + mqtt->onSend(encrypted, position, 1); + + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); + TEST_ASSERT_EQUAL_STRING("msh/2/e/private/!12345678", pubsub->published_.front().first.c_str()); +} + +void test_explicitPkiPositionStillPublishesWithEventPolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + meshtastic_MeshPacket position = makePositionPacket(0); + meshtastic_MeshPacket encryptedPki = encrypted; + position.to = 2; + position.pki_encrypted = true; + encryptedPki.pki_encrypted = true; + + mqtt->onSend(encryptedPki, position, 0); + + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); + TEST_ASSERT_EQUAL_STRING("msh/2/e/PKI/!12345678", pubsub->published_.front().first.c_str()); +} + // Verify that the decoded MeshPacket is proxied through the MeshService when encryption_enabled = false. void test_proxyToMeshServiceDecoded(void) { @@ -918,6 +1011,32 @@ void test_reportToMapDefaultImprecise(void) TEST_ASSERT_EQUAL_STRING("msh/2/map/", topic.c_str()); } +void test_eventPrimaryMapReportFollowsCompileTimePolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + + unitTest->reportToMap(); + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_TRUE(pubsub->published_.empty()); + TEST_ASSERT_EQUAL(0, unitTest->queueSize()); +#else + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); +#endif +} + +void test_privatePrimaryMapReportStillPublishesWithEventPolicy(void) +{ + configureCoordinatePolicyChannels(false); + clearPublicationState(); + + unitTest->reportToMap(); + + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); + TEST_ASSERT_EQUAL_STRING("msh/2/map/", pubsub->published_.front().first.c_str()); +} + // Location is sent over the phone proxy. void test_reportToMapImpreciseProxied(void) { @@ -1135,6 +1254,9 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_sendDirectlyConnectedDecoded); RUN_TEST(test_sendDirectlyConnectedEncrypted); + RUN_TEST(test_eventPositionPublicationFollowsCompileTimePolicy); + RUN_TEST(test_privatePositionStillPublishesWithEventPolicy); + RUN_TEST(test_explicitPkiPositionStillPublishesWithEventPolicy); RUN_TEST(test_proxyToMeshServiceDecoded); RUN_TEST(test_proxyToMeshServiceEncrypted); RUN_TEST(test_dontMqttMeOnPublicServer); @@ -1171,6 +1293,8 @@ void setup() RUN_TEST(test_publishTextMessageDirect); RUN_TEST(test_publishTextMessageWithProxy); RUN_TEST(test_reportToMapDefaultImprecise); + RUN_TEST(test_eventPrimaryMapReportFollowsCompileTimePolicy); + RUN_TEST(test_privatePrimaryMapReportStillPublishesWithEventPolicy); RUN_TEST(test_reportToMapImpreciseProxied); RUN_TEST(test_usingDefaultServer); RUN_TEST(test_usingDefaultServerWithPort); diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index 7d3dd9eec..45dfa8c4a 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -11,15 +11,21 @@ #include "TestUtil.h" #include +#include "airtime.h" #include "configuration.h" #include "gps/RTC.h" #include "mesh/Default.h" #include "mesh/NextHopRouter.h" #include "mesh/NodeDB.h" #include "mesh/RadioInterface.h" +#include "mesh/ReliableRouter.h" +#include "modules/RoutingModule.h" #include #include +#include #include +#include +#include #define MSG_BUF_LEN 200 #define TEST_MSG_FMT(fmt, ...) \ @@ -30,6 +36,13 @@ } while (0) static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11 +static constexpr NodeNum kRemoteNode = 0x22222222; + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) +static constexpr bool kEventPolicyEnabled = true; +#else +static constexpr bool kEventPolicyEnabled = false; +#endif // --------------------------------------------------------------------------- // MockNodeDB - inject nodes with controlled last byte, hop distance, age, role, favorite flag. @@ -93,6 +106,15 @@ class NextHopRouterTestShim : public NextHopRouter using NextHopRouter::relayOpaquePacket; using Router::shouldDecrementHopLimit; // protected in Router + bool filterViaFlooding(const meshtastic_MeshPacket *p) { return FloodingRouter::shouldFilterReceived(p); } + bool filterViaNextHop(const meshtastic_MeshPacket *p) { return NextHopRouter::shouldFilterReceived(p); } + + void clearPendingForTest() + { + while (!pending.empty()) + stopRetransmission(pending.begin()->first); + } + void resetRouteHealthForTest() { for (auto &h : routeHealth) @@ -100,10 +122,8 @@ class NextHopRouterTestShim : public NextHopRouter } }; -// --------------------------------------------------------------------------- -// MockRadioInterface - mirrors RadioLibInterface::send()'s NODENUM_BROADCAST_NO_LORA branch, which -// returns ERRNO_SHOULD_RELEASE without releasing. -// --------------------------------------------------------------------------- +// Mirrors RadioLibInterface::send()'s NODENUM_BROADCAST_NO_LORA branch, which +// returns ERRNO_SHOULD_RELEASE without releasing the packet. class MockRadioInterface : public RadioInterface { public: @@ -132,8 +152,137 @@ class MockRadioInterface : public RadioInterface uint8_t lastHopStart = 0; }; +class CaptureRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + bool cancelSending(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + cancelCount++; + return false; + } + + bool findInTxQueue(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + return false; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } + + void reset() + { + sentPackets.clear(); + cancelCount = 0; + } + + std::vector sentPackets; + uint32_t cancelCount = 0; +}; + +class ReliableRouterTestShim : public ReliableRouter +{ + public: + ReliableRouterTestShim() : ReliableRouter() {} + + size_t pendingCount() const { return pending.size(); } + + void seedRetry(const meshtastic_MeshPacket &p, uint8_t attempts) + { + auto *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + startRetransmission(copy, attempts); + } + + void makeRetryDue(NodeNum from, PacketId id) + { + PendingPacket *record = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(record); + record->nextTxMsec = 0; + } + + int32_t runDueRetries() { return doRetransmissions(); } + void sniffForTest(const meshtastic_MeshPacket *p, const meshtastic_Routing *routing) + { + ReliableRouter::sniffReceived(p, routing); + } + + void clearPendingForTest() + { + while (!pending.empty()) + stopRetransmission(pending.begin()->first); + } +}; + +class MockRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, + bool ackWantsAck = false) override + { + ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck); + } + + std::list> ackNaks; +}; + +class ScopedAirTimeFixture +{ + public: + ScopedAirTimeFixture() : previous(airTime) { airTime = &instance; } + ~ScopedAirTimeFixture() { airTime = previous; } + + private: + AirTime instance; + AirTime *previous; +}; + +static meshtastic_MeshPacket makeRebroadcastCandidate(NodeNum to) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kRemoteNode; + p.to = to; + p.id = 0x0BADF00D; + p.hop_start = 3; + p.hop_limit = 3; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 8; + return p; +} + static MockNodeDB *mockNodeDB = nullptr; static NextHopRouterTestShim *shim = nullptr; +static ReliableRouterTestShim *reliableShim = nullptr; +static CaptureRadioInterface *nextHopRadio = nullptr; +static CaptureRadioInterface *reliableRadio = nullptr; +static MockRoutingModule *mockRoutingModule = nullptr; +static std::unique_ptr airTimeFixture; +static PacketId nextBehaviorPacketId = 0x70000000; + +static MockRadioInterface *installMockIface() +{ + MockRadioInterface *mock = new MockRadioInterface(); + // addInterface replaces and destroys the suite's original capture interface. + // Clear its borrowed pointer before the next Unity setUp() runs. + nextHopRadio = nullptr; + shim->addInterface(std::unique_ptr(mock)); + return mock; +} static constexpr uint32_t TTL = NextHopRouter::ROUTE_TTL_MSEC; static constexpr uint8_t THRESH = NextHopRouter::ROUTE_FAILURE_THRESHOLD; @@ -150,12 +299,84 @@ static meshtastic_MeshPacket makeRelayedPacket(uint8_t relay, uint8_t hopsAway) return p; } +static meshtastic_Channel makeBehaviorChannel(meshtastic_Channel_Role role, const char *name) +{ + meshtastic_Channel channel = meshtastic_Channel_init_default; + channel.has_settings = true; + channel.role = role; + channel.settings.has_module_settings = true; + channel.settings.module_settings.position_precision = 16; + strncpy(channel.settings.name, name, sizeof(channel.settings.name) - 1); + return channel; +} + +static void configureBehaviorChannels() +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel eventChannel = makeBehaviorChannel(meshtastic_Channel_Role_PRIMARY, "everyone"); + eventChannel.index = 0; +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t eventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(eventPsk); + memcpy(eventChannel.settings.psk.bytes, eventPsk, sizeof(eventPsk)); +#endif + + meshtastic_Channel privateChannel = makeBehaviorChannel(meshtastic_Channel_Role_SECONDARY, "private"); + privateChannel.index = 1; + privateChannel.settings.psk.size = 32; + memset(privateChannel.settings.psk.bytes, 0xAB, privateChannel.settings.psk.size); + + channelFile.channels[0] = eventChannel; + channelFile.channels[1] = privateChannel; + channels.onConfigChanged(); +} + +static meshtastic_MeshPacket makeBehaviorPacket(meshtastic_PortNum portnum, NodeNum from, NodeNum to, uint8_t channel, + bool wantAck = false) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = to; + p.id = nextBehaviorPacketId++; + p.channel = channel; + p.hop_start = 3; + p.hop_limit = 3; + p.relay_node = 0x22; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.want_ack = wantAck; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = portnum; + return p; +} + +static meshtastic_MeshPacket *allocBehaviorPacket(meshtastic_PortNum portnum, NodeNum to, uint8_t channel, bool wantAck) +{ + auto packet = makeBehaviorPacket(portnum, kLocalNode, to, channel, wantAck); + auto *allocated = packetPool.allocCopy(packet); + TEST_ASSERT_NOT_NULL(allocated); + return allocated; +} + void setUp(void) { myNodeInfo.my_node_num = kLocalNode; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + config.lora.override_duty_cycle = true; + config.security.private_key.size = 0; + owner.is_licensed = false; mockNodeDB->clearTestNodes(); shim->resetRouteHealthForTest(); + shim->clearPendingForTest(); + reliableShim->clearPendingForTest(); + if (nextHopRadio) + nextHopRadio->reset(); + reliableRadio->reset(); + mockRoutingModule->ackNaks.clear(); + configureBehaviorChannels(); } void tearDown(void) {} @@ -446,33 +667,112 @@ void test_hoplimit_decrement_when_resolved_not_favorite(void) } // =========================================================================== -// Rebroadcast of NODENUM_BROADCAST_NO_LORA +// Group 5 - event-coordinate routing behavior // =========================================================================== -static MockRadioInterface *installMockIface() +void test_eventPolicy_reliableOriginSendSuppressesTxAndPending(void) { - MockRadioInterface *m = new MockRadioInterface(); - shim->addInterface(std::unique_ptr(m)); - return m; + ErrorCode result = reliableShim->send( + allocBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, NODENUM_BROADCAST, /*event channel=*/0, /*wantAck=*/true)); + + if (kEventPolicyEnabled) { + TEST_ASSERT_EQUAL_INT(meshtastic_Routing_Error_NOT_AUTHORIZED, result); + TEST_ASSERT_EQUAL_UINT32(0, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); + } else { + TEST_ASSERT_EQUAL_INT(ERRNO_OK, result); + TEST_ASSERT_EQUAL_UINT32(1, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + } } -// Eligible for rebroadcast: not from/to us, hops left, nonzero id, no next-hop preference. -// Encrypted variant so Router::send() skips the encode path. -static meshtastic_MeshPacket makeRebroadcastCandidate(NodeNum to) +void test_eventPolicy_reliablePrivateCoordinateStillSends(void) { - meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; - p.from = 0x22222222; // not us - p.to = to; - p.id = 0x0BADF00D; - p.hop_start = 3; - p.hop_limit = 3; - p.next_hop = NO_NEXT_HOP_PREFERENCE; - p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; - p.encrypted.size = 8; - return p; + ErrorCode result = reliableShim->send( + allocBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, NODENUM_BROADCAST, /*private channel=*/1, /*wantAck=*/true)); + + TEST_ASSERT_EQUAL_INT(ERRNO_OK, result); + TEST_ASSERT_EQUAL_UINT32(1, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_eventPolicy_floodingDuplicateSuppressesCoordinateButRelaysText(void) +{ + mockNodeDB->addNode(kRemoteNode, 0, true, 0); + auto coordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, NODENUM_BROADCAST, 0); + TEST_ASSERT_FALSE(shim->filterViaFlooding(&coordinate)); + TEST_ASSERT_TRUE(shim->filterViaFlooding(&coordinate)); + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, nextHopRadio->sentPackets.size()); + + nextHopRadio->reset(); + auto text = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, 0); + TEST_ASSERT_FALSE(shim->filterViaFlooding(&text)); + TEST_ASSERT_TRUE(shim->filterViaFlooding(&text)); + TEST_ASSERT_EQUAL_UINT32(1, nextHopRadio->sentPackets.size()); +} + +void test_eventPolicy_nextHopDuplicateSuppressesEventButRelaysPrivateCoordinate(void) +{ + mockNodeDB->addNode(kRemoteNode, 0, true, 0); + auto eventCoordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, NODENUM_BROADCAST, 0); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&eventCoordinate)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&eventCoordinate)); + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, nextHopRadio->sentPackets.size()); + + nextHopRadio->reset(); + auto privateCoordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, NODENUM_BROADCAST, 1); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&privateCoordinate)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&privateCoordinate)); + TEST_ASSERT_EQUAL_UINT32(1, nextHopRadio->sentPackets.size()); +} + +void test_eventPolicy_repeatedLocalPacketSuppressesCoordinateAckButKeepsTextAck(void) +{ + mockNodeDB->addNode(kRemoteNode, 0, true, 0); + auto coordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, kLocalNode, 0, /*wantAck=*/true); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&coordinate)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&coordinate)); + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, mockRoutingModule->ackNaks.size()); + + mockRoutingModule->ackNaks.clear(); + auto text = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 0, /*wantAck=*/true); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&text)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&text)); + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + const auto &ack = mockRoutingModule->ackNaks.front(); + TEST_ASSERT_EQUAL_INT(meshtastic_Routing_Error_NONE, std::get<0>(ack)); + TEST_ASSERT_EQUAL_HEX32(kRemoteNode, std::get<1>(ack)); + TEST_ASSERT_EQUAL_HEX32(text.id, std::get<2>(ack)); +} + +void test_eventPolicy_seededRetrySuppressesTxUntilGateOff(void) +{ + auto coordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kLocalNode, NODENUM_BROADCAST, 0, /*wantAck=*/true); + reliableShim->seedRetry(coordinate, /*attempts=*/2); + reliableShim->makeRetryDue(kLocalNode, coordinate.id); + + reliableShim->runDueRetries(); + + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_reliableAckStopsNormalPendingTransmission(void) +{ + auto original = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_RETX); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + + auto ack = makeBehaviorPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + ack.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_NONE; + + reliableShim->sniffForTest(&ack, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); } -// Control: proves the NO_LORA case below turns on the `to` field alone. void test_rebroadcast_normal_broadcast_is_relayed(void) { MockRadioInterface *mockIface = installMockIface(); @@ -491,13 +791,10 @@ void test_rebroadcast_no_lora_broadcast_is_not_relayed(void) TEST_ASSERT_EQUAL_MESSAGE(0, mockIface->sendCount, "no packet should be handed to the radio at all"); } -// Declining mock bypasses the guard so send() is reached; the release itself is only observable as -// a sanitizer leak report, not an assertion. void test_rebroadcast_declined_send_releases_packet(void) { MockRadioInterface *mockIface = installMockIface(); mockIface->declineAll = true; - meshtastic_MeshPacket p = makeRebroadcastCandidate(NODENUM_BROADCAST); TEST_ASSERT_TRUE_MESSAGE(shim->perhapsRebroadcast(&p), "the rebroadcast must still be attempted"); @@ -551,10 +848,23 @@ void setup() initializeTestEnvironment(); UNITY_BEGIN(); + airTimeFixture = std::make_unique(); mockNodeDB = new MockNodeDB(); shim = new NextHopRouterTestShim(); + reliableShim = new ReliableRouterTestShim(); nodeDB = mockNodeDB; + auto nextRadio = std::make_unique(); + nextHopRadio = nextRadio.get(); + shim->addInterface(std::move(nextRadio)); + + auto reliableCapture = std::make_unique(); + reliableRadio = reliableCapture.get(); + reliableShim->addInterface(std::move(reliableCapture)); + + mockRoutingModule = new MockRoutingModule(); + routingModule = mockRoutingModule; + printf("\n=== resolveLastByte (M1) ===\n"); RUN_TEST(test_resolve_none_when_empty); RUN_TEST(test_resolve_zero_byte_is_none); @@ -594,6 +904,15 @@ void setup() RUN_TEST(test_hoplimit_decrement_on_colliding_favorites); RUN_TEST(test_hoplimit_decrement_when_resolved_not_favorite); + printf("\n=== event-coordinate routing behavior ===\n"); + RUN_TEST(test_eventPolicy_reliableOriginSendSuppressesTxAndPending); + RUN_TEST(test_eventPolicy_reliablePrivateCoordinateStillSends); + RUN_TEST(test_eventPolicy_floodingDuplicateSuppressesCoordinateButRelaysText); + RUN_TEST(test_eventPolicy_nextHopDuplicateSuppressesEventButRelaysPrivateCoordinate); + RUN_TEST(test_eventPolicy_repeatedLocalPacketSuppressesCoordinateAckButKeepsTextAck); + RUN_TEST(test_eventPolicy_seededRetrySuppressesTxUntilGateOff); + RUN_TEST(test_reliableAckStopsNormalPendingTransmission); + printf("\n=== rebroadcast of NODENUM_BROADCAST_NO_LORA ===\n"); RUN_TEST(test_rebroadcast_normal_broadcast_is_relayed); RUN_TEST(test_rebroadcast_no_lora_broadcast_is_not_relayed); @@ -602,7 +921,9 @@ void setup() RUN_TEST(test_event_mode_hop_behavior); #endif - exit(UNITY_END()); + int result = UNITY_END(); + airTimeFixture.reset(); + exit(result); } void loop() {} diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index 5f5569752..fae50e87f 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -1,11 +1,16 @@ #include "Channels.h" #include "GeoCoord.h" +#include "NodeDB.h" #include "PositionPrecision.h" +#include "Router.h" #include "TestUtil.h" #include "mesh-pb-constants.h" #include #include #include +#if ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" +#endif static meshtastic_Position makePosition() { @@ -129,6 +134,8 @@ static void test_getPositionPrecisionForChannel_clampsPreciseOnDefaultKeyChannel channels.initDefaults(); // channel 0: primary, default key (psk {0x01}) -> publicly decryptable uint8_t idx = 0; meshtastic_Channel &ch = channels.getByIndex(idx); + ch.settings.psk.size = 1; + ch.settings.psk.bytes[0] = 0x01; ch.settings.has_module_settings = true; ch.settings.module_settings.position_precision = 32; // user requests "Precise" on a public channel @@ -236,6 +243,178 @@ static void test_geocoord_extreme_coords_no_oob() } } +static void configureEventChannels(bool eventAtIndexOne, bool inheritEventKeyOnSecondary) +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel eventChannel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + meshtastic_Channel otherChannel = makeChannel(meshtastic_Channel_Role_SECONDARY, true, 16); + strncpy(eventChannel.settings.name, "everyone", sizeof(eventChannel.settings.name) - 1); +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(configuredEventPsk); + memcpy(eventChannel.settings.psk.bytes, configuredEventPsk, sizeof(configuredEventPsk)); +#endif + if (!inheritEventKeyOnSecondary) { + otherChannel.settings.psk.size = 32; + memset(otherChannel.settings.psk.bytes, 0xAB, 32); + strncpy(otherChannel.settings.name, "private", sizeof(otherChannel.settings.name) - 1); + } + + eventChannel.index = eventAtIndexOne ? 1 : 0; + otherChannel.index = eventAtIndexOne ? 0 : 1; + channelFile.channels[eventChannel.index] = eventChannel; + channelFile.channels[otherChannel.index] = otherChannel; + channels.onConfigChanged(); +} + +static void test_getPositionPrecisionForChannel_eventChannelClampedToZero() +{ + // The event ("everyone") channel must never share location, even when the + // stored precision is non-zero. Under the block gate the clamp forces 0; + // otherwise the stored value is honored like any other channel. +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, false); + TEST_ASSERT_TRUE(channels.isEventChannel(0)); + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(0)); +#else + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(channel)); +#endif +} + +static void test_eventChannelIdentity_usesEffectiveKeyAndSurvivesReorder() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, true); + TEST_ASSERT_TRUE(channels.isEventChannel(0)); + TEST_ASSERT_TRUE(channels.isEventChannel(1)); + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(1)); + + configureEventChannels(true, false); + TEST_ASSERT_FALSE(channels.isEventChannel(0)); + TEST_ASSERT_TRUE(channels.isEventChannel(1)); + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(0)); + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(1)); +#else + TEST_ASSERT_FALSE(channels.isEventChannel(0)); +#endif +} + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, uint8_t channelIndex) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_default; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = portnum; + packet.channel = channelIndex; + return packet; +} + +static void test_eventCoordinatePolicy_coversPortsAndExcludesPki() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, false); + auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); + auto waypoint = makeDecodedPacket(meshtastic_PortNum_WAYPOINT_APP, 0); + auto mapReport = makeDecodedPacket(meshtastic_PortNum_MAP_REPORT_APP, 0); + auto text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, 0); + + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&waypoint)); + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&mapReport)); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&text)); + + waypoint.pki_encrypted = true; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&waypoint)); + + waypoint.pki_encrypted = false; + waypoint.to = 0x12345678; + config.security.private_key.size = 32; + owner.is_licensed = false; +#if ARCH_PORTDUINO + portduino_config.force_simradio = false; +#endif + TEST_ASSERT_TRUE(willUsePki(&waypoint)); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&waypoint)); +#else + auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&position)); +#endif +} + +static void test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, false); + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_default; + packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + packet.channel = channels.getHash(0); + packet.from = 0; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.pki_encrypted = true; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.channel = 0; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.pki_encrypted = false; + packet.channel = channels.getHash(0); + packet.from = 0x12345678; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.from = 0; + packet.channel = channels.getHash(1); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); +#else + TEST_ASSERT_TRUE(true); +#endif +} + +static void test_eventCoordinatePolicy_usesResolvedUnicastChannel() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + NodeDB *savedNodeDB = nodeDB; + nodeDB = new NodeDB(); + configureEventChannels(false, false); + meshtastic_NodeInfoLite *node = + nodeDB->getNumMeshNodes() > 1 ? nodeDB->getMeshNodeByIndex(1) : nodeDB->getOrCreateMeshNode(0x12345678); + TEST_ASSERT_NOT_NULL(node); + const NodeNum destination = node->num; + const uint8_t savedChannel = node->channel; + + auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); + position.to = destination; + node->channel = 1; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&position)); + + position.from = 0x87654321; + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + + position.from = 0; + node->channel = 0; + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + node->channel = savedChannel; + delete nodeDB; + nodeDB = savedNodeDB; +#else + TEST_ASSERT_TRUE(true); +#endif +} + +static void test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored() +{ + // A private channel with a full 32-byte key that is not the configured + // channel-0 PSK must be + // unaffected by the clamp on either side of the gate. + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + channel.settings.psk.size = 32; + memset(channel.settings.psk.bytes, 0xAB, 32); + + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(channel)); +} + void setUp(void) {} void tearDown(void) {} @@ -262,6 +441,12 @@ void setup() RUN_TEST(test_cryptoKeyIsPublic_aes256KeyIsPrivate); RUN_TEST(test_cryptoKeyIsPublic_invalidKeyIsNotPublic); RUN_TEST(test_geocoord_extreme_coords_no_oob); + RUN_TEST(test_getPositionPrecisionForChannel_eventChannelClampedToZero); + RUN_TEST(test_eventChannelIdentity_usesEffectiveKeyAndSurvivesReorder); + RUN_TEST(test_eventCoordinatePolicy_coversPortsAndExcludesPki); + RUN_TEST(test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash); + RUN_TEST(test_eventCoordinatePolicy_usesResolvedUnicastChannel); + RUN_TEST(test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored); exit(UNITY_END()); } diff --git a/userPrefs.jsonc b/userPrefs.jsonc index fc18c0097..cec5b82c5 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -25,6 +25,7 @@ // "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_BLOCK_POSITION_ON_EVENT_CHANNEL": "1", // Block location TX + discard inbound location on channels keyed with USERPREFS_CHANNEL_0_PSK. Defaults on under EVENT_MODE. // "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", diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index feaff1c80..37d5bf2a0 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -137,6 +137,18 @@ test_testing_command = ${platformio.build_dir}/${this.__env__}/meshtasticd -s +[env:coverage-event-policy] +extends = env:coverage +build_flags = ${env:coverage.build_flags} + -DUSERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL=1 + -DUSERPREFS_CHANNEL_0_PSK='{0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f}' +test_filter = + test_position_precision + test_event_channel_router + test_nexthop_routing + test_event_channel_phone_api + test_mqtt + ; --------------------------------------------------------------------------- ; Native build for macOS (Darwin / arm64 + x86_64). Headless meshtasticd that ; runs in SimRadio mode (`-s`) or against real LoRa hardware via a CH341 From 5d04f86af3af204c50d1e7e6bdff7b34d60d6c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 11 Aug 2026 18:28:04 +0200 Subject: [PATCH 013/109] fix(Radio): reject bogus coding rate and length readbacks on RX (#11408) --- src/mesh/RadioLibInterface.cpp | 7 +++++++ src/mesh/RadioLibInterface.h | 34 ++++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index d770c19ca..7c45728cc 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -616,6 +616,13 @@ void RadioLibInterface::handleReceiveInterrupt() // read the number of actually received bytes size_t length = iface->getPacketLength(); + // Some drivers report this as a 16 bit value, so a bad readback can overrun radioBuffer in readData() + if (length > sizeof(radioBuffer)) { + LOG_ERROR("Ignore rx packet, bad length %u", (unsigned int)length); + rxBad++; + return; + } + uint32_t rxMsec = getPacketTime(length, true); #ifndef DISABLE_WELCOME_UNSET diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 82471e760..014272178 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -331,23 +331,29 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified template uint32_t computePacketTime(T &lora, uint32_t pl, bool received) { if (received) { - // First get the actual coding rate and CRC status from the received packet - uint8_t rxCR; - bool hasCRC; - lora.getLoRaRxHeaderInfo(&rxCR, &hasCRC); - // Go from raw header value to denominator - if (rxCR < 5) { - rxCR += 4; - } else if (rxCR == 7) { - rxCR = 8; - } - // Received packet configuration must be the same as configured, except for coding rate and CRC DataRate_t dr = getDataRate(); - dr.lora.codingRate = rxCR; - PacketConfig_t pc = getPacketConfig(); - pc.lora.crcEnabled = hasCRC; + + uint8_t rxCR = 0; + bool hasCRC = true; + if (lora.getLoRaRxHeaderInfo(&rxCR, &hasCRC) == RADIOLIB_ERR_NONE) { + // Raw 0 is reserved and >7 is either undefined or an LR2021-only convolutional rate no + // Meshtastic peer can send. calculateTimeOnAir() would multiply by it unchecked. + if (rxCR < 1 || rxCR > 7) { + LOG_WARN("Bogus RX coding rate %d from radio, use configured %d", rxCR, dr.lora.codingRate); + } else { + // Go from raw header value to denominator + if (rxCR < 5) { + rxCR += 4; + } else if (rxCR == 7) { + rxCR = 8; + } + + dr.lora.codingRate = rxCR; + pc.lora.crcEnabled = hasCRC; + } + } return lora.calculateTimeOnAir(modemType, dr, pc, pl) / 1000; } From cb557d89a636dce210af9288453b15fe76edbf23 Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Tue, 11 Aug 2026 18:28:40 +0200 Subject: [PATCH 014/109] Multi one wire measurements (#10192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First version of DS248X bridge * Add first iteration of DS248X sensor * Supports single readings on DS2484 * Supports readings on ch0 for DS2484_800 * Detection of variant for DS248X * Minor fix on retries for sensor init * Allow multiple channel detect passes on 8-ch version * Always read temperature via ROM matching * Small comment to show how to send all channels * Minor logging changes * Prevent one-wire double definitions * Detect ROMs per round * Fix comment * Prevent skipping on DS2482 ALT3 check * Fix comment (again) * Fix style checks * Remove comment for multiple measurements * Add multi-sensor measurements for one-wire sensors using wildcard message * Move to unpacked measurements in one-wire * Add admin command to set main temperature in 8-channel one-wire bridge. * Fix merge ref * Trunk fmt * Remove unused variable --------- Co-authored-by: Thomas Göttgens --- platformio.ini | 12 +- src/detect/ScanI2CTwoWire.cpp | 1 - src/modules/Telemetry/Sensor/DS248XSensor.cpp | 117 ++++++++++++++++-- src/modules/Telemetry/Sensor/DS248XSensor.h | 5 + 4 files changed, 122 insertions(+), 13 deletions(-) diff --git a/platformio.ini b/platformio.ini index f3ae026f6..76b00c1f7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -247,8 +247,18 @@ lib_deps = closedcube/ClosedCube OPT3001@1.1.2 # renovate: datasource=git-refs depName=meshtastic-DFRobot_LarkWeatherStation packageName=https://github.com/meshtastic/DFRobot_LarkWeatherStation gitBranch=master https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip + # renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core + https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip + # renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x + https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip + # renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x + https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip + # renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30 + https://github.com/Sensirion/arduino-i2c-scd30/archive/1.1.1.zip + # renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht + https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip # renovate: datasource=github-tags depName=Adafruit DS248x packageName=adafruit/Adafruit_DS248x - https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip + https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip ; Environmental sensors with BSEC2 (Bosch proprietary IAQ) [environmental_extra] diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 0aa2301e3..21a74b759 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -743,7 +743,6 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("DS2482-800", (uint8_t)addr.address); break; } - type = HMC5883L; logFoundDevice("HMC5883L", (uint8_t)addr.address); break; diff --git a/src/modules/Telemetry/Sensor/DS248XSensor.cpp b/src/modules/Telemetry/Sensor/DS248XSensor.cpp index a660700fc..f3158d432 100644 --- a/src/modules/Telemetry/Sensor/DS248XSensor.cpp +++ b/src/modules/Telemetry/Sensor/DS248XSensor.cpp @@ -78,7 +78,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) // Try to init One-Wire with 3 retries. This detects ROMs consistently // on the second one. uint8_t numRetries = 3; - uint8_t rom[8]{}; for (uint8_t retry = 1; retry <= numRetries; retry++) { bool initError = false; @@ -162,12 +161,14 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } if (!initError) { - LOG_INFO("%s: Started one-wire (%u/%u)", sensorName, retry, numRetries); status = true; // We want to keep searching for ROMs on the DS248X_DS2482_800 // and always do the three passes if (_variant == ds248x_variant_t::DS248X_DS2484) { + LOG_INFO("%s: Started one-wire (%u/%u)", sensorName, retry, numRetries); break; + } else { + LOG_INFO("%s: One-wire startup cycle (%u/%u)", sensorName, retry, numRetries); } } // TODO Potentially not needed, but taken from Adafruit's library example @@ -282,18 +283,112 @@ bool DS248XSensor::getMetrics(meshtastic_Telemetry *measurement) return true; } } else if (_variant == ds248x_variant_t::DS248X_DS2482_800) { - // Only ch0 is reported, and each populated channel blocks 750ms on its conversion - // TODO Support more than one temperature via repeated (3.0) - // TODO Select which channel can be reported as main temperature - if (readTemperatureChannel(0)) { - measurement->variant.environment_metrics.temperature = ds2482800Data.ds248xData[0].temperature; - measurement->variant.environment_metrics.has_temperature = true; - LOG_DEBUG("Got %s readings: temperature=%.2f", sensorName, measurement->variant.environment_metrics.temperature); - return true; + // If using DS248X_DS2482_800, we read all channels + uint8_t channelCount = 0; + + // Note, the reason why we are using an unpacked version of this message + // (instead of repeated) it's to save space. With repeated, we have to send all + // channels (even if null) or otherwise we don't know where each channel is + // being reported + for (uint8_t channel = 0; channel < 8; channel++) { + if (readTemperatureChannel(channel)) { + channelCount += 1; + + if (channel == mainTemperatureChannel) { + measurement->variant.environment_metrics.has_temperature = true; + measurement->variant.environment_metrics.temperature = ds2482800Data.ds248xData[channel].temperature; + } + + switch (channel) { + case 0: + measurement->variant.environment_metrics.has_one_wire_temperature_ch0 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch0 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 1: + measurement->variant.environment_metrics.has_one_wire_temperature_ch1 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch1 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 2: + measurement->variant.environment_metrics.has_one_wire_temperature_ch2 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch2 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 3: + measurement->variant.environment_metrics.has_one_wire_temperature_ch3 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch3 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 4: + measurement->variant.environment_metrics.has_one_wire_temperature_ch4 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch4 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 5: + measurement->variant.environment_metrics.has_one_wire_temperature_ch5 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch5 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 6: + measurement->variant.environment_metrics.has_one_wire_temperature_ch6 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch6 = + ds2482800Data.ds248xData[channel].temperature; + break; + case 7: + measurement->variant.environment_metrics.has_one_wire_temperature_ch7 = true; + measurement->variant.environment_metrics.one_wire_temperature_ch7 = + ds2482800Data.ds248xData[channel].temperature; + break; + } + + LOG_DEBUG("Got %s readings: temperature_ch%u=%.2f", sensorName, channel, + ds2482800Data.ds248xData[channel].temperature); + } } - return false; + return channelCount > 0; } return false; } +void DS248XSensor::setMainTemperature(uint8_t channel) +{ + if (channel > 7) { + LOG_ERROR("%s: Requested channel (%u) not available", sensorName, channel); + return; + } + + LOG_INFO("%s: Setting requested channel (%u) as main temperature", sensorName, channel); + mainTemperatureChannel = channel; + return; +} + +AdminMessageHandleResult DS248XSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + result = AdminMessageHandleResult::NOT_HANDLED; + + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: + if (!request->sensor_config.has_ds248x_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + + // Check for main temperature channel request + if (request->sensor_config.ds248x_config.has_main_temperature_channel) { + this->setMainTemperature(request->sensor_config.ds248x_config.main_temperature_channel); + } + + result = AdminMessageHandleResult::HANDLED; + break; + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} + #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/DS248XSensor.h b/src/modules/Telemetry/Sensor/DS248XSensor.h index bfe18a035..f1215149c 100644 --- a/src/modules/Telemetry/Sensor/DS248XSensor.h +++ b/src/modules/Telemetry/Sensor/DS248XSensor.h @@ -66,6 +66,7 @@ class DS248XSensor : public TelemetrySensor ds248x_variant_t _variant = DS248X_UNKNOWN; _DS248XData ds248xData{}; _DS2482800Data ds2482800Data{}; + uint8_t mainTemperatureChannel = 0; #ifdef DS248X_I2C_CLOCK_SPEED ReClockI2C reClockI2C; #endif @@ -73,12 +74,16 @@ class DS248XSensor : public TelemetrySensor bool isValidROM(const uint8_t *rom); float readTemperatureROM(const uint8_t *rom); bool readTemperatureChannel(uint8_t channel); + void setMainTemperature(uint8_t channel); public: DS248XSensor(); ds248x_variant_t detectVariant(); virtual bool getMetrics(meshtastic_Telemetry *measurement) override; virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; }; #endif \ No newline at end of file From 97f836186758a3ffef03a30f779c7aa51699febc Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Tue, 11 Aug 2026 18:28:47 +0200 Subject: [PATCH 015/109] Correct for awake time in AQ telemetry (#11404) * Correct for awake time in AQ telemetry * Minor typo on debug log * Avoid updating start of cycle twice Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix log type * Only update ahead of time if successful transmit --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/modules/Telemetry/AirQualityTelemetry.cpp | 42 +++++++++++++++---- src/modules/Telemetry/AirQualityTelemetry.h | 2 + 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index 3373966c2..5418d6620 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -208,6 +208,10 @@ int32_t AirQualityTelemetryModule::runOnce() if (!sensor->isActive()) { LOG_DEBUG("Waking up: %s", sensor->sensorName); + if (awakeAheadOfTimeMs == 0) + startAirQualityTelemetryCycle = millis(); + awakeAheadOfTimeMs = max(awakeAheadOfTimeMs, sensor->wakeUpTimeMs()); + // TODO multiple sensors with different wake up times collide return sensor->wakeUp(); } @@ -219,19 +223,35 @@ int32_t AirQualityTelemetryModule::runOnce() } bool telemetryDue = (lastTelemetry == 0) || !Throttle::isWithinTimespanMs(lastTelemetry, telemetryIntervalMs); - bool phoneDue = (lastSentToPhone == 0) || !Throttle::isWithinTimespanMs(lastSentToPhone, sendToPhoneIntervalMs); if (telemetryDue && telemetryAllowed) { - sendTelemetry(); - - if (transmitHistory) { - transmitHistory->setLastSentToMesh(TX_HISTORY_KEY_AIR_QUALITY_TELEMETRY); + if (sendTelemetry()) { + if (transmitHistory) { + transmitHistory->setLastSentToMesh(TX_HISTORY_KEY_AIR_QUALITY_TELEMETRY); + } + // Correct the awake time, trimming to 0 + const unsigned long elapsed = millis() - startAirQualityTelemetryCycle; + awakeAheadOfTimeMs = elapsed >= awakeAheadOfTimeMs ? 0 : awakeAheadOfTimeMs - elapsed; + // LOG_DEBUG("Time to publish. Correcting ahead of time by: %d", awakeAheadOfTimeMs); + } else { + awakeAheadOfTimeMs = 0; } } else if (phoneDue && phoneAllowed) { // Mesh transmission isn't due yet, but we can still update the phone. - sendTelemetry(NODENUM_BROADCAST, true); - lastSentToPhone = millis(); + if (sendTelemetry(NODENUM_BROADCAST, true)) { + lastSentToPhone = millis(); + // Correct the awake time, trimming to 0 + const unsigned long elapsed = millis() - startAirQualityTelemetryCycle; + awakeAheadOfTimeMs = elapsed >= awakeAheadOfTimeMs ? 0 : awakeAheadOfTimeMs - elapsed; + // LOG_DEBUG("Time to publish. Correcting ahead of time by: %d", awakeAheadOfTimeMs); + } else { + awakeAheadOfTimeMs = 0; + } + } else { + // if for some reason we end up here after waking up, but not able to send, then reset + // the counter + awakeAheadOfTimeMs = 0; } // Send to sleep sensors that can be to save power @@ -253,7 +273,13 @@ int32_t AirQualityTelemetryModule::runOnce() // mistime the pending deep sleep return FIVE_SECONDS_MS; } - return min(sendToPhoneIntervalMs, result); + + // Update next interval if we were ahead + uint32_t correctedIntervalMs = sendToPhoneIntervalMs + awakeAheadOfTimeMs; + awakeAheadOfTimeMs = 0; + startAirQualityTelemetryCycle = 0; + LOG_DEBUG("Corrected interval in ms: %u", correctedIntervalMs); + return min(correctedIntervalMs, result); } bool AirQualityTelemetryModule::wantUIFrame() diff --git a/src/modules/Telemetry/AirQualityTelemetry.h b/src/modules/Telemetry/AirQualityTelemetry.h index 4cc5af420..7a7bfff5a 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.h +++ b/src/modules/Telemetry/AirQualityTelemetry.h @@ -66,6 +66,8 @@ class AirQualityTelemetryModule : private concurrency::OSThread, private: bool firstTime = true; + int32_t awakeAheadOfTimeMs = 0; + int32_t startAirQualityTelemetryCycle = 0; meshtastic_MeshPacket *lastMeasurementPacket; uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute // uint32_t sendToPhoneIntervalMs = 1000; // Send to phone every minute From 9cc1ff99f5a1e64b98d6412d20133e073c08f36a Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 11 Aug 2026 11:16:15 -0700 Subject: [PATCH 016/109] The position block is for a single event, so disable by default (#11411) Co-authored-by: Ben Meadors --- src/mesh/Channels.h | 4 ---- userPrefs.jsonc | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 6fc40b0e4..27833130c 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -5,10 +5,6 @@ #include "mesh-pb-constants.h" #include -#ifndef USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL -#define USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL USERPREFS_EVENT_MODE -#endif - #if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && !defined(USERPREFS_CHANNEL_0_PSK) #error "USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL requires USERPREFS_CHANNEL_0_PSK" #endif diff --git a/userPrefs.jsonc b/userPrefs.jsonc index cec5b82c5..eb9ff3faf 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -25,7 +25,7 @@ // "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_BLOCK_POSITION_ON_EVENT_CHANNEL": "1", // Block location TX + discard inbound location on channels keyed with USERPREFS_CHANNEL_0_PSK. Defaults on under EVENT_MODE. + // "USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL": "1", // Block location TX + discard inbound location on channels keyed with USERPREFS_CHANNEL_0_PSK. Defaults off, and must be set explicitly. // "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 d765bd99ca21e7915a1f9f68830989ec545ccec2 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 11 Aug 2026 12:28:25 -0700 Subject: [PATCH 017/109] Fix the all-zero MAC address for own node (#11409) * Fix the all-zero MAC address for own node * Increment native suite count from 46 to 47 * Fix condition for copying MAC address in PhoneAPI --- src/mesh/PhoneAPI.cpp | 3 +++ test/native-suite-count | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 28d35d00f..696c370fa 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -615,6 +615,9 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) auto info = TypeConversions::ConvertToNodeInfo(us); info.has_hops_away = false; info.is_favorite = true; + // NodeInfoLite dropped macaddr, so ConvertToUser() zero-fills it. + if (info.has_user) + memcpy(info.user.macaddr, owner.macaddr, sizeof(info.user.macaddr)); { concurrency::LockGuard guard(&nodeInfoMutex); nodeInfoForPhone = info; diff --git a/test/native-suite-count b/test/native-suite-count index 9e5feb525..abac1ea7b 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -46 +47 From af56a11f0033dcc956114e518b45afff058a0989 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 11 Aug 2026 14:16:56 -0700 Subject: [PATCH 018/109] Replace native-suite-count file with dynamic test discovery (#11413) * Derive the native suite count on the fly instead of registering it in a file test/native-suite-count was a manually-maintained register of the test_* directory count, reconciled against the actual directories by bin/run-tests.sh (as an AMBER verdict) and by a dedicated suite-count-check CI job. The reconciliation only ever guarded the file itself: the check that matters - suites that actually ran vs. the test_* directories on disk - already derives its expected count from a directory walk, so the file added a bookkeeping step to every suite addition/removal without adding signal. Remove the file and everything that existed to keep it honest: - bin/run-tests.sh: drop the canonical-count file read, the count-mismatch AMBER verdict, and the [canonical: x/y] suffix; the verdict lines already carry ran/expected from the directory walk. The shuffle seed suffix stays. - test_native.yml: delete the suite-count-check job and its needs: edges. - Docs (copilot-instructions.md, AGENTS.md, test/README.md) and the test-script comments now describe the count as derived from test/test_* at run time. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01APCEfNjd1X7ErDHEzT6Dqd * Add suite-shrinkage-check: fail a PR that silently loses a test_* suite With test/native-suite-count gone, nothing in CI noticed the suite set shrinking: platformio test discovers and runs whatever test_* directories exist, and bin/run-tests.sh derives its expected count from the same walk, so a suite directory lost in a bad rebase or an overzealous cleanup just means fewer suites run - every remaining check stays green. Restore that tripwire git-aware instead of file-based: on pull_request runs, compare the test_* directory list at the PR's merge base against the PR result. A vanished suite fails the job unless its name appears in the PR title, PR body, or a commit message in the PR's range - a deliberate removal satisfies that by stating what it removes; an accidental loss cannot. Other events skip: they have no natural base, and PRs are where accidents arrive. No job depends on this one (a skipped job would skip its dependents). Incidentally: test/ currently holds 47 test_* directories while the deleted count file said 46 - the manual register had already drifted, which is exactly the bookkeeping failure mode this replaces. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01APCEfNjd1X7ErDHEzT6Dqd * Re-pad the verdict table after shortening the AMBER row Shrinking the AMBER cell left the table's column padding inconsistent, which trunk (prettier + markdownlint MD060) rejects. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01APCEfNjd1X7ErDHEzT6Dqd --------- Co-authored-by: Claude --- .github/copilot-instructions.md | 29 ++++++------- .github/workflows/test_native.yml | 72 ++++++++++++++++++------------- AGENTS.md | 2 +- bin/run-tests.sh | 51 ++++++---------------- bin/test-lint-unity-exit.sh | 5 ++- bin/test-state-check.sh | 6 +-- test/README.md | 13 +++--- test/native-suite-count | 1 - 8 files changed, 81 insertions(+), 98 deletions(-) delete mode 100644 test/native-suite-count diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1786759aa..5c1c99e97 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -313,7 +313,7 @@ firmware/ │ └── native/ # Linux/Portduino variants ├── protobufs/ # Protocol buffer definitions ├── boards/ # Custom PlatformIO board definitions -├── test/ # Native unit-test suites (count: test/native-suite-count) +├── test/ # Native unit-test suites (count = the test_* dirs, detected on the fly) └── bin/ # Build and utility scripts ``` @@ -663,7 +663,7 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing. ### Native unit tests (C++) -Unit tests in `test/` directory. The canonical suite count is in `test/native-suite-count`, cross-checked against `test/test_*` on every full run and by the `suite-count-check` CI job. **Never state the count as a literal anywhere else** - point at that file. The list below is a partial description of what suites cover, not an inventory: +Unit tests in `test/` directory. The canonical suite count is detected on the fly: the `test_*` directories under `test/` are the register, and `bin/run-tests.sh` cross-checks the suites that actually ran against them on every full run. **Never state the count as a literal anywhere** - it is whatever `test/test_*` contains right now. In CI, the `suite-shrinkage-check` job (`test_native.yml`) fails a PR that loses a `test_*` directory relative to its merge base unless the suite is named in the PR title, body, or a commit message - deleting a suite therefore requires saying so. The list below is a partial description of what suites cover, not an inventory: - `test_admin_radio/` - LoRa region/config validation, AdminModule dispatch, node-DB metadata saves - `test_fscommon_getfiles/` - bounded file-manifest walk (cap, depth, truncation reporting) @@ -693,7 +693,7 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su - `test_utf8/` - UTF-8 utilities - `test_warm_store/` - Warm-tier node store -**Preferred run command - `bin/run-tests.sh`** (defaults to the `coverage` env; emits a machine-readable verdict on the final line; update `test/native-suite-count` when adding or removing suites): +**Preferred run command - `bin/run-tests.sh`** (defaults to the `coverage` env; emits a machine-readable verdict on the final line; new `test_*` directories are picked up automatically): ```bash ./bin/run-tests.sh # all suites @@ -712,18 +712,18 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su Exit codes and verdicts (exact counts will vary; examples below are illustrative): -| Exit | Verdict | Meaning | -| ---- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 0 | `GREEN` | All canonical suites ran, all passed, no ignored test cases | -| 1 | `RED` | At least one failure, build error, or sanitizer fault | -| 2 | `AMBER` | All that ran passed, but something was lost or unexplained: a suite silently went missing on a full run, individual test cases were skipped (`TEST_IGNORE`), `test/native-suite-count` disagrees with the `test/` directory count, or a suite left behind shared state it does not declare | -| 3 | `FILTERED` | A `-f` run completed cleanly; suites outside the filter were intentionally not run | +| Exit | Verdict | Meaning | +| ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | `GREEN` | All canonical suites ran, all passed, no ignored test cases | +| 1 | `RED` | At least one failure, build error, or sanitizer fault | +| 2 | `AMBER` | All that ran passed, but something was lost or unexplained: a suite silently went missing on a full run, individual test cases were skipped (`TEST_IGNORE`), or a suite left behind shared state it does not declare | +| 3 | `FILTERED` | A `-f` run completed cleanly; suites outside the filter were intentionally not run | Examples - exact counts will vary by suite count and env: ```text # GREEN: all suites ran and passed -RESULT: GREEN N/N suites passed [canonical: N/N] +RESULT: GREEN N/N suites passed, all CLEAN # RED: real test failure RESULT: RED 1 failed @@ -731,14 +731,11 @@ RESULT: RED 1 failed # RED: sanitizer exit-time abort (all tests passed but process aborted at exit) RESULT: RED exit-time abort (tests passed; likely sanitizer - see hint above) -# AMBER: native-suite-count disagrees with test/ directory count (too low) -RESULT: AMBER test/ has 24 suite directories but native-suite-count says 5 - update test/native-suite-count after registering new suites - -# AMBER: native-suite-count disagrees with test/ directory count (too high) -RESULT: AMBER test/ has 24 suite directories but native-suite-count says 99 - update test/native-suite-count after removing suites +# AMBER: a suite silently went missing on a full run +RESULT: AMBER 23/24 suites ran (missing: test_radio) - all that ran passed # FILTERED: single suite run completed cleanly -RESULT: FILTERED 1/24 suites ran (not run: test_admin_radio test_atak …) - filtered: test_serial [canonical: 1/24] +RESULT: FILTERED 1/24 suites ran (not run: test_admin_radio test_atak …) - filtered: test_serial ``` > **Copilot interface note:** When running tests via the Copilot chat interface, edits made through the chat may not be reflected in the on-disk files that the test binary reads. If tests pass in chat but fail locally (or vice versa), verify the files on disk match what you expect before trusting the result. Always confirm with a local terminal run. diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index a6350188d..2e171e46a 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -23,13 +23,19 @@ env: LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}" jobs: - # Guard the registered native-suite total. `platformio test` discovers and runs whatever - # test_* directories exist, so it never notices when test/native-suite-count drifts from the - # actual directory count (a suite added without registering it, or the file left stale). That - # reconciliation only lives in bin/run-tests.sh, which CI does not invoke - so mirror the exact - # check here and fail the PR on a mismatch, keeping the manual count honest. - suite-count-check: - name: Native Suite Count + # Tripwire against the native suite set shrinking by accident. `platformio test` discovers and + # runs whatever test_* directories exist, and bin/run-tests.sh derives its expected count from + # the same walk - so a suite directory lost in a bad rebase or an overzealous cleanup just means + # fewer suites run, and every remaining check stays green. Compare the test_* directory list + # against the PR's merge base and fail when a suite vanished without the PR saying so: a removed + # suite's name must appear in the PR title, the PR body, or a commit message in the PR's range. + # A deliberate removal satisfies that by stating what it removes; an accidental loss cannot. + # Only pull_request runs have a base to compare against (and PRs are where accidents arrive); + # every other event skips. No job depends on this one: a skipped job would skip its dependents, + # and the expensive jobs should not wait on a full-history clone. + suite-shrinkage-check: + name: Native Suite Shrinkage + if: github.event_name == 'pull_request' runs-on: ubuntu-slim permissions: contents: read @@ -37,40 +43,45 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false + # Full history: the merge base must be computed, not guessed from a possibly stale + # event payload, and the acknowledgment scan reads every commit message in the range. + fetch-depth: 0 - - name: Reconcile native-suite-count with test/ directories + - name: Fail if a test_* suite vanished unacknowledged shell: bash + # PR title/body are attacker-controlled text; they reach the script through env: only, + # never spliced into the shell source (same rule as the suite-order seed below). + env: + BASE_REF: ${{ github.base_ref }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} run: | set -euo pipefail - count_file="test/native-suite-count" - if [[ ! -f $count_file ]]; then - echo "::error title=Missing native-suite-count::$count_file not found - it must record the number of test_* suite directories." - exit 1 + git fetch --quiet origin "$BASE_REF" + base=$(git merge-base FETCH_HEAD HEAD) + # Same canonical set every other consumer derives: directories named test_* directly + # under test/, read from the git trees so the comparison is exact at both endpoints. + list_suites() { git ls-tree -d --name-only "$1" test/ | sed 's#^test/##' | grep '^test_' | sort; } + removed=$(comm -23 <(list_suites "$base") <(list_suites HEAD)) + if [[ -z $removed ]]; then + echo "No suite removed: $(list_suites HEAD | wc -l) test_* directories, none lost since merge base ${base:0:8}." + exit 0 fi - # Same canonical set as bin/run-tests.sh: directories named test_* directly under test/. - expected_count=$(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | wc -l) - canonical_count=$(tr -d '[:space:]' <"$count_file") - if ! [[ $canonical_count =~ ^[0-9]+$ ]]; then - echo "::error title=Invalid native-suite-count::$count_file must contain a single integer, got '$canonical_count'." - exit 1 - fi - echo "test/ directories: $expected_count" - echo "native-suite-count: $canonical_count" - if [[ $expected_count -ne $canonical_count ]]; then - if [[ $expected_count -gt $canonical_count ]]; then - hint="a suite was added - bump $count_file to $expected_count" + messages=$(git log --format=%B "$base..HEAD") + fail=0 + while IFS= read -r suite; do + if printf '%s\n%s\n%s\n' "$PR_TITLE" "$PR_BODY" "$messages" | grep -qF "$suite"; then + echo "Removed suite $suite is named in the PR title/body or a commit message - acknowledged." else - hint="a suite was removed - lower $count_file to $expected_count" + echo "::error title=Native suite vanished::test/$suite exists on the merge base but is gone from this PR, and nothing in the PR title, body, or commit messages mentions it. If the removal is deliberate, name $suite in the PR description or a commit message; if not, restore the directory - platformio test would silently run without it." + fail=1 fi - echo "::error title=native-suite-count mismatch::test/ has $expected_count suite directories but $count_file says $canonical_count ($hint)." - exit 1 - fi - echo "native-suite-count matches the $expected_count suite directories." + done <<<"$removed" + exit $fail simulator-tests: name: Native Simulator Tests runs-on: ubuntu-24.04-arm - needs: suite-count-check steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -169,7 +180,6 @@ jobs: platformio-tests: name: Native PlatformIO Tests runs-on: ubuntu-24.04-arm - needs: suite-count-check steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/AGENTS.md b/AGENTS.md index 9dc3fa22e..f154b7824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ Sequence these; don't parallelize on the same port. | `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | | `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | | `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | -| `test/` | Firmware unit tests (count: `test/native-suite-count`; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) | +| `test/` | Firmware unit tests (count = the `test_*` dirs, detected on the fly; `./bin/run-tests.sh` preferred, falls back to `pio test -e native`) | | [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) | Standalone MCP server + tiered pytest hardware harness (`unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/`) - registered here via `.mcp.json` | | `.github/prompts/` | Copilot prompt bodies (firmware scaffolding: new module / sensor / variant) | | `.github/copilot-instructions.md` | **Primary agent instructions - read this** | diff --git a/bin/run-tests.sh b/bin/run-tests.sh index 1c5109282..dcc3a705f 100755 --- a/bin/run-tests.sh +++ b/bin/run-tests.sh @@ -163,20 +163,11 @@ export MESHTASTIC_TEST_STATE_SUMMARY="$STATE_SUMMARY" $KEEP_STATE && export MESHTASTIC_TEST_KEEP_STATE=1 $WRITE_MANIFEST && export MESHTASTIC_TEST_KEEP_STATE=1 -# Canonical suite set = the directories in test/. This is the source of truth for -# "what should run"; a filtered run only expects its filtered suite. +# Canonical suite set = the directories in test/, detected on the fly. This is the sole source +# of truth for "what should run"; a filtered run only expects its filtered suite. mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort) EXPECTED_COUNT=${#ALL_SUITES[@]} -# Canonical suite count - the registered total, maintained in test/native-suite-count. -# Update that file whenever a test suite is added or removed. -CANONICAL_COUNT_FILE="test/native-suite-count" -if [[ -f $CANONICAL_COUNT_FILE ]]; then - CANONICAL_COUNT=$(tr -d '[:space:]' <"$CANONICAL_COUNT_FILE") -else - CANONICAL_COUNT="" -fi - # Cached object-count for this env, written after each completed build (in the gitignored build # dir). Used as the progress denominator: accurate for a full rebuild (every object recompiles), # only a rough upper bound for an incremental run. @@ -462,31 +453,15 @@ if ! grep -qE "$PASS_RE" "$LOG"; then exit 1 fi -# Canonical-count rating suffix - appended to every verdict line so the result is always -# rated against the registered total, not just the directory count. -# If the two counts diverge (suite added/removed without updating native-suite-count), that -# is itself surfaced as AMBER before we reach any verdict. -canonical_rating() { +# Verdict-line suffix. The suite count itself is derived from the test_* directories on the fly +# (EXPECTED_COUNT above), so the only extra context a verdict needs is the shuffle seed - carried +# into the machine-readable line so a verdict is always replayable from it alone. +verdict_suffix() { local rating="" - if [[ -n $CANONICAL_COUNT ]]; then - rating="[canonical: ${RAN_COUNT}/${CANONICAL_COUNT}]" - fi - # Carry the seed into the machine-readable line so a verdict is always replayable from it alone. - $SHUFFLE && rating="$rating [seed: $SEED]" + $SHUFFLE && rating="[seed: $SEED]" echo "$rating" } -# AMBER: directory count disagrees with native-suite-count - file needs updating. -if [[ -n $CANONICAL_COUNT && $EXPECTED_COUNT -ne $CANONICAL_COUNT ]]; then - echo "" - if [[ $EXPECTED_COUNT -gt $CANONICAL_COUNT ]]; then - echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT - update test/native-suite-count after registering new suites" - else - echo "RESULT: AMBER test/ has $EXPECTED_COUNT suite directories but native-suite-count says $CANONICAL_COUNT - update test/native-suite-count after removing suites" - fi - exit 2 -fi - # --- Shared-state axis -------------------------------------------------------- # Read what the per-suite wrapper recorded. Reported after the count checks so a structural problem # still wins, and before the pass/fail verdict lines so the state summary always prints. @@ -546,7 +521,7 @@ if [[ $IGNORED_COUNT -gt 0 ]]; then echo "" echo "$IGNORE_DETAIL" echo "" - echo "RESULT: AMBER ${IGNORED_COUNT} test case(s) ignored $(canonical_rating)" + echo "RESULT: AMBER ${IGNORED_COUNT} test case(s) ignored $(verdict_suffix)" exit 2 fi @@ -558,7 +533,7 @@ if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || missing+=("$s") done echo "" - echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) - all that ran passed $(canonical_rating)" + echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) - all that ran passed $(verdict_suffix)" exit 2 fi @@ -572,7 +547,7 @@ if ((${#DIRTY_SUITES[@]} > 0)); then echo "" echo " -> declare these in test/state-manifest.tsv with a reason, or stop the write." echo " -> ./bin/run-tests.sh --write-manifest prints the entries to paste." - echo "RESULT: AMBER ${#DIRTY_SUITES[@]} suite(s) left undeclared shared state $(canonical_rating)" + echo "RESULT: AMBER ${#DIRTY_SUITES[@]} suite(s) left undeclared shared state $(verdict_suffix)" exit 2 fi @@ -588,7 +563,7 @@ if ((${#SURVIVOR_SUITES[@]} > 0)); then echo "" echo " -> end every setup() branch with exit(UNITY_END()), not a bare UNITY_END()." echo " -> ./bin/lint-unity-exit.sh test/**/*.cpp finds the sites; see test/README.md." - echo "RESULT: AMBER ${#SURVIVOR_SUITES[@]} suite(s) still running after the suite finished $(canonical_rating)" + echo "RESULT: AMBER ${#SURVIVOR_SUITES[@]} suite(s) still running after the suite finished $(verdict_suffix)" exit 2 fi @@ -599,10 +574,10 @@ if [[ -n $FILTER ]]; then for s in "${ALL_SUITES[@]}"; do printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || not_run+=("$s") done - echo "RESULT: FILTERED ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (not run: ${not_run[*]}) - filtered: $FILTER $(canonical_rating)" + echo "RESULT: FILTERED ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (not run: ${not_run[*]}) - filtered: $FILTER $(verdict_suffix)" exit 3 fi # GREEN: all canonical suites ran, all passed, no ignored test cases, nothing undeclared left behind. -echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed, all CLEAN $(canonical_rating)" +echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed, all CLEAN $(verdict_suffix)" exit 0 diff --git a/bin/test-lint-unity-exit.sh b/bin/test-lint-unity-exit.sh index 7c1dac30d..b88f7d2df 100755 --- a/bin/test-lint-unity-exit.sh +++ b/bin/test-lint-unity-exit.sh @@ -12,8 +12,9 @@ # anything" is what catches a rule that reports the right number of findings in the wrong places, or # that collapses two findings on one line into one. # -# Not a Unity suite and not counted in test/native-suite-count - same arrangement as -# bin/test-state-check.sh, and for the same reason: it asserts the behaviour of a process. +# Not a Unity suite and not a test_* directory, so outside the suite count run-tests.sh derives +# from test/ - same arrangement as bin/test-state-check.sh, and for the same reason: it asserts +# the behaviour of a process. # # Usage: ./bin/test-lint-unity-exit.sh (exit 0 = all fixtures behaved) diff --git a/bin/test-state-check.sh b/bin/test-state-check.sh index 96fa8803d..14dad9d44 100755 --- a/bin/test-state-check.sh +++ b/bin/test-state-check.sh @@ -8,9 +8,9 @@ # before-empty assertion fires, because an after-diff measured against a dirty baseline reports # green while meaning nothing. # -# Not a Unity suite and not counted in test/native-suite-count - the same arrangement as -# bin/test-config-check.sh, and for the same reason: what it asserts is the behaviour of a process, -# not of a linkable function. +# Not a Unity suite and not a test_* directory, so outside the suite count run-tests.sh derives +# from test/ - the same arrangement as bin/test-config-check.sh, and for the same reason: what it +# asserts is the behaviour of a process, not of a linkable function. # # Usage: ./bin/test-state-check.sh (exit 0 = all fixtures behaved) diff --git a/test/README.md b/test/README.md index 4074d94f5..d1dbd804c 100644 --- a/test/README.md +++ b/test/README.md @@ -467,8 +467,8 @@ 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. +`test_native.yml`. It is not a `test_*` directory, so it sits outside the suite count the +harness derives from `test/`. ```bash pio run -e native && ./bin/test-config-check.sh @@ -476,10 +476,11 @@ pio run -e native && ./bin/test-config-check.sh ## Existing Test Suites -**This table is a description, not an inventory.** The canonical suite total lives in -`test/native-suite-count`, is machine-checked against `test/test_*` on every full run and in CI -(`test_native.yml`), and is the only number that should be trusted or quoted. Entries below carry -per-suite descriptions the count cannot; do not infer completeness from the row count. +**This table is a description, not an inventory.** The canonical suite total is the number of +`test_*` directories under `test/`, detected on the fly by `bin/run-tests.sh` on every full run +and cross-checked against the suites that actually ran. That derived count is the only number +that should be trusted or quoted. Entries below carry per-suite descriptions the count cannot; +do not infer completeness from the row count. | Suite | Module Under Test | | ---------------------------- | ----------------------------- | diff --git a/test/native-suite-count b/test/native-suite-count deleted file mode 100644 index abac1ea7b..000000000 --- a/test/native-suite-count +++ /dev/null @@ -1 +0,0 @@ -47 From 204f88ddfe6878c33b0952bc56af09e0465a0786 Mon Sep 17 00:00:00 2001 From: Carlos Valdes Date: Tue, 11 Aug 2026 22:15:05 +0200 Subject: [PATCH 019/109] fix(nrf54l15): restore the nrf54l15dk build (#11410) * fix(nrf54l15): restore the nrf54l15dk build Three unrelated faults stacked up, so the env has not built from a clean cache for some time. All three were diagnosed in July but never committed. Pin framework-zephyr to 3.40201.251021 (Zephyr 4.2.1). Seeed's platform script only maps their own seeed-xiao-* board ids to a package; any other board -- ours included -- falls back to whatever platform.json declares as the default, which is now Zephyr 4.4.0. Its west manifest pulls a CMSIS_6 whose cmsis_gcc.h calls the ACLE builtins __sxtb16/__sxtab16, and none of the GCC ARM toolchains PlatformIO ships (8.2.1/9.2.1/9.3.1) declare them in arm_acle.h. In C that is only an implicit-declaration warning; in C++ it is a hard error. So a fresh cache silently breaks the build even though nothing in the tree changed. Guard the MMC5983MA case in MagnetometerThread with __has_include. The switch arm constructs MMC5983MASensor unconditionally, so any env whose libdeps lack SparkFun_MMC5983MA_Arduino_Library fails with "expected type-specifier before 'MMC5983MASensor'". Add Print::availableForWrite() to the nrf54l15 Arduino shim. The shim declares flush() but not availableForWrite(), which StreamFrameWriter calls -- so it went unnoticed until that code landed. Verified: clean build of nrf54l15dk from an empty package cache, SUCCESS in 16:01, FLASH 39.04% (570804 B of 1428 KB), RAM 65.65%. The three had never been exercised together -- a previous run with only the pin applied got 17:30 in before hitting the other two. * review: collapse the pin rationale to one repo-local comment The block was pasted twice, and both copies pointed at a note that does not exist in this repository. Kept one, and only the part a reader here can act on: why the fallback happens, and why it is a C++ error rather than the warning the pure-C Zephyr core gets away with. --------- Co-authored-by: Jonathan Bennett --- src/motion/MagnetometerThread.h | 2 ++ src/platform/nrf54l15/Arduino.h | 1 + variants/nrf54l15/nrf54l15.ini | 13 +++++++++++++ 3 files changed, 16 insertions(+) diff --git a/src/motion/MagnetometerThread.h b/src/motion/MagnetometerThread.h index 1f558eb57..cf632867d 100644 --- a/src/motion/MagnetometerThread.h +++ b/src/motion/MagnetometerThread.h @@ -67,9 +67,11 @@ class MagnetometerThread : public concurrency::OSThread } switch (device.type) { +#if __has_include() case ScanI2C::DeviceType::MMC5983MA: sensor = new MMC5983MASensor(device); break; +#endif default: disable(); return; diff --git a/src/platform/nrf54l15/Arduino.h b/src/platform/nrf54l15/Arduino.h index b608c4856..c67628afa 100644 --- a/src/platform/nrf54l15/Arduino.h +++ b/src/platform/nrf54l15/Arduino.h @@ -297,6 +297,7 @@ class Print } virtual void flush() {} + virtual int availableForWrite() { return 0; } }; // ── Stream base class ──────────────────────────────────────────────────────── diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 31adaee10..45e997271 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -1,5 +1,18 @@ [nrf54l15_base] platform = https://github.com/Seeed-Studio/platform-seeedboards.git +; Pin the Zephyr package explicitly. Seeed's platform script only maps their +; own "seeed-xiao-*" board ids to a framework-zephyr package; any other board +; -- nrf54l15dk included -- falls back to whatever platform.json declares as +; the default, which is now framework-zephyr-nrf54lm20 (Zephyr 4.4.0). Its +; west manifest pulls a CMSIS_6 whose cmsis_gcc.h calls the ACLE builtins +; __sxtb16/__sxtab16, and none of the GCC ARM toolchains PlatformIO ships +; (8.2.1/9.2.1/9.3.1) declare them in arm_acle.h. In C that is only an +; implicit-declaration warning, so the pure-C Zephyr core never notices; in +; C++ it is a hard error, and any .cpp pulling in zephyr/kernel.h hits it. +; Without the pin a fresh package cache breaks this build with nothing in the +; tree having changed. +platform_packages = + platformio/framework-zephyr-nrf54lm20@https://dl.registry.platformio.org/download/platformio/tool/framework-zephyr/3.40201.251021/framework-zephyr-3.40201.251021.tar.gz framework = zephyr extends = arduino_base From 87a009da63ca254c3e4f064cb0416a25d88fa3b0 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 11 Aug 2026 17:57:45 -0500 Subject: [PATCH 020/109] fix(nrf52): LTO was dropping the board variant's weak hook overrides (#11415) * fix(nrf52): LTO was dropping the board variant's weak hook overrides Whole-image LTO (enabled arch-wide for nrf52840 in #10655) inlines the empty weak body of earlyInitVariant()/lateInitVariant()/variant_shutdown()/ variant_nrf52LoopHook()/variantDefault*Config() at the call site, because the weak default and the call site live in the SAME translation unit. The strong override in variants///variant.cpp is then never linked, and the board's hardware setup silently does not run. nrf52_lto.py's -fno-lto variant recompile does not help here: the caller is the problem, not the variant object. Needs both ingredients, so this only affects 2.8: the earlyInitVariant() indirection landed in #9438 and is present in v2.7.26 too, but v2.7.26 has no -flto, so the override linked normally. Found on the muzi R1 Neo, whose earlyInitVariant() drives DCDC_EN_HOLD (P0.13, the DC-DC hold after the user button) and NRF_ON (P0.29, "tells IO controller device is on"). Both were dropped from the image, so the companion MCU never saw the nRF application come up and stayed in its DFU indication (purple LED). Verified in the ELF: pre-fix setup() runs straight from waitUntilPowerLevelSafe() to the LED_NOTIFICATION block with no earlyInitVariant symbol in the binary and no pinMode/digitalWrite on P0.13 or P0.29 anywhere; post-fix it calls the real override. HW-confirmed on an R1 Neo. Also affected on nrf52840: earlyInitVariant() on 10 variants (incl. t-echo-card, which sequences its RT9080 3V3 rail there), variant_shutdown() on 18 variants (t114, t-echo, ThinkNode M1-M8, meshlink, wio-tracker-L1 ... - sleep pin parking, so deep-sleep leakage), variant_nrf52LoopHook() on 3 RAK variants. Confirmed dropped on heltec-mesh-node-t114 by build, not just by inspection. Fix is __attribute__((noinline)) on both the weak declaration and definition - the same guard already carried by loopCanSleep(), preFSBegin(), PowerHAL and variant_enableBatteryLpcompWake(), whose comment in main-nrf52.cpp already documents this exact failure mode. Also extend _VARIANT_OVERRIDES in extra_scripts/nrf52_lto.py from just _Z11initVariantv to all eight hooks. That post-link guard already had the right logic and would have caught this on every PR - it simply was not listing Meshtastic's own weak variant hooks, only the core's. With the list extended it goes red on both r1-neo and heltec-mesh-node-t114 when the noinline is reverted, and green with it. Its failure message now names both possible causes. * review: trim the noinline rationale comments to two lines Per AGENTS.md ("keep code comments minimal - one or two lines, max"), the incident detail and extended background belong in the PR description, not the source. Keeps the LTO/noinline rationale and the pointer to the guard. --------- Co-authored-by: Jonathan Bennett --- extra_scripts/nrf52_lto.py | 37 ++++++++++++++++++++++++------- src/main.cpp | 10 +++++---- src/mesh/NodeDB.cpp | 10 +++++---- src/platform/nrf52/main-nrf52.cpp | 10 +++++---- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/extra_scripts/nrf52_lto.py b/extra_scripts/nrf52_lto.py index 22b236f58..f62194a05 100644 --- a/extra_scripts/nrf52_lto.py +++ b/extra_scripts/nrf52_lto.py @@ -233,9 +233,26 @@ def _assert_isr_handlers_survived(source, target, env): # and the build stays green. Turn that into a red build: # 1. the linked variant.cpp.o must not be an LTO object (proves the -fno-lto recompile fired); # 2. any override the object defines strong must resolve strong in the ELF. +# +# The list must also cover Meshtastic's OWN weak variant hooks, not just the core's. Those have +# a second, independent way to vanish: their weak default AND their call site sit in the same +# LTO'd translation unit (src/main.cpp, src/platform/nrf52/main-nrf52.cpp), so GCC inlines the +# empty body at the call site and never reaches for the variant's strong override -- the +# -fno-lto middleware above cannot help, the caller is the problem. The definitions carry +# __attribute__((noinline)) to prevent it; this guard is what catches a future one that forgets. +# Regression that motivated the extension: 2.8 dropped earlyInitVariant() on the muzi R1 Neo, so +# DCDC_EN_HOLD/NRF_ON were never driven and the IO controller read the nRF as stuck in DFU +# (purple LED). The build stayed green because only _Z11initVariantv was listed here. _VARIANT_OVERRIDES = ( - "_Z11initVariantv", -) # extend if the core grows more weak variant hooks + "_Z11initVariantv", # core hook (cores/nRF5/main.cpp) + "_Z16earlyInitVariantv", # src/main.cpp -- pre-peripheral board bring-up + "_Z15lateInitVariantv", # src/main.cpp -- post-radio board bring-up + "_Z16variant_shutdownv", # main-nrf52.cpp -- pin parking before System OFF + "_Z21variant_nrf52LoopHookv", # main-nrf52.cpp -- per-loop variant hook + "_Z31variant_enableBatteryLpcompWakev", # main-nrf52.cpp -- LPCOMP wake opt-out + "_Z20variantDefaultConfigv", # NodeDB.cpp -- per-board config defaults + "_Z26variantDefaultModuleConfigv", # NodeDB.cpp -- per-board module defaults +) # extend if the core (or Meshtastic) grows more weak variant hooks def _assert_variant_survived(source, target, env): @@ -289,15 +306,19 @@ def _assert_variant_survived(source, target, env): ): problems.append( "%s is strong in variant.cpp.o but weak/absent in the ELF " - "(LTO resolved the core's call to the empty weak stub)" % sym + "(LTO resolved the call to the empty weak stub)" % sym ) if problems: sys.stderr.write( - "\n*** nrf52 LTO guard: board variant DROPPED from the image ***\n%s\n" - "The variant's early hardware setup (initVariant) will never run on this board.\n" - "Check _is_board_variant() in extra_scripts/nrf52_lto.py -- middleware nodes are\n" - "$BUILD_DIR-mirrored; match srcnode() paths, not node.get_abspath().\n\n" - % "\n".join(" - " + p for p in problems) + "\n*** nrf52 LTO guard: board variant override DROPPED from the image ***\n%s\n" + "That board hardware setup silently will not run. Two possible causes:\n" + " 1. The weak default and its CALL SITE share one LTO'd translation unit\n" + " (src/main.cpp, src/platform/nrf52/main-nrf52.cpp, src/mesh/NodeDB.cpp), so GCC\n" + " inlined the empty body and never reached the override. Fix: mark BOTH the weak\n" + " declaration and definition __attribute__((noinline)) -- see earlyInitVariant().\n" + " 2. The -fno-lto middleware stopped matching the variant. Check _is_board_variant()\n" + " below -- middleware nodes are $BUILD_DIR-mirrored, so match srcnode() paths,\n" + " not node.get_abspath().\n\n" % "\n".join(" - " + p for p in problems) ) from SCons.Script import Exit diff --git a/src/main.cpp b/src/main.cpp index 66ea2e139..e51dee109 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -316,11 +316,13 @@ __attribute__((weak, noinline)) bool loopCanSleep() // Weak empty variant initialization function. // May be redefined by variant files. -void lateInitVariant() __attribute__((weak)); -void lateInitVariant() {} +// noinline: weak default and call site share this TU, so LTO would inline the empty body and +// never link the variant's strong override. nrf52_lto.py's _VARIANT_OVERRIDES guards this. +__attribute__((noinline)) void lateInitVariant() __attribute__((weak)); +__attribute__((noinline)) void lateInitVariant() {} -void earlyInitVariant() __attribute__((weak)); -void earlyInitVariant() {} +__attribute__((noinline)) void earlyInitVariant() __attribute__((weak)); +__attribute__((noinline)) void earlyInitVariant() {} // NRF52 (and probably other platforms) can report when system is in power failure mode // (eg. too low battery voltage) and operating it is unsafe (data corruption, bootloops, etc). diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index b766114ac..9ccc8065d 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -98,11 +98,13 @@ static unsigned char userprefs_admin_key_2[] = USERPREFS_USE_ADMIN_KEY_2; // Weak empty variant initialization function. // May be redefined by variant files. -void variantDefaultConfig() __attribute__((weak)); -void variantDefaultConfig() {} +// noinline: weak default and call site share this TU, so LTO would inline the empty body and +// never link the variant's strong override. Same guard as earlyInitVariant() in main.cpp. +__attribute__((noinline)) void variantDefaultConfig() __attribute__((weak)); +__attribute__((noinline)) void variantDefaultConfig() {} -void variantDefaultModuleConfig() __attribute__((weak)); -void variantDefaultModuleConfig() {} +__attribute__((noinline)) void variantDefaultModuleConfig() __attribute__((weak)); +__attribute__((noinline)) void variantDefaultModuleConfig() {} #ifdef HELTEC_MESH_NODE_T114 diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index c71a6d699..14138767e 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -51,12 +51,14 @@ uint16_t getVDDVoltage(); // Weak empty variant shutdown prep function. // May be redefined by variant files. -void variant_shutdown() __attribute__((weak)); -void variant_shutdown() {} +// noinline: same reason as variant_enableBatteryLpcompWake() below -- weak default and call +// site are in this file, so LTO would inline the empty body and drop the variant's override. +__attribute__((noinline)) void variant_shutdown() __attribute__((weak)); +__attribute__((noinline)) void variant_shutdown() {} // Optional variant hook called each nrf52Loop(); e.g. for low-VDD System OFF. -void variant_nrf52LoopHook(void) __attribute__((weak)); -void variant_nrf52LoopHook(void) {} +__attribute__((noinline)) void variant_nrf52LoopHook(void) __attribute__((weak)); +__attribute__((noinline)) void variant_nrf52LoopHook(void) {} // Return false to skip LPCOMP wake when entering System OFF (e.g. user CLI shutdown). // noinline: weak default and call site are in this file; without it GCC may inline the From c5979a85bf55bc2ede93f5241201c45549a3b433 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:36:55 -0500 Subject: [PATCH 021/109] Update meshtastic/device-ui digest to 6e5e3b6 (#11419) 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 76b00c1f7..cd229fdfc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -137,7 +137,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/4f150e5b622fc2f5c8ac26b81bf843e14c33ba33.zip + https://github.com/meshtastic/device-ui/archive/6e5e3b69a20020dffe16384f690daa2080e1b047.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 5baad2e2a854a5db5283e6fce020b5b9284907e7 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 11 Aug 2026 17:05:51 -0700 Subject: [PATCH 022/109] logging: compile out LOG_TRACE by default, demote chatty DEBUG lines, drop redundant logs (#11391) * logging: gate LOG_TRACE behind MESHTASTIC_TRACE_LOGGING, drop redundant reclock logs LOG_TRACE now compiles out by default so trace-level diagnostics cost no flash; enable with -DMESHTASTIC_TRACE_LOGGING. Portduino keeps it on for the traceFilename packet-trace feature. Remove the 66 caller-side I2C reclock/restore log lines in the telemetry sensors: ReClockI2C::setClock/restoreClock already log both frequencies internally (now at trace level, since they fire every sensor read). Also unify near-duplicate literals (colon/case/punctuation variants) so linker string dedup applies, and drop an information-free bare 'done'. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1 * logging: demote chatty per-packet/per-poll DEBUG lines to trace level With LOG_TRACE compiled out by default, per-iteration chatter (packet bookkeeping, sensor poll values, e-ink refresh reasons, GPS pin states, UI runState traces) now costs no flash on device builds while remaining one -DMESHTASTIC_TRACE_LOGGING away. 108 lines demoted, 4 information- free lines removed; failure paths, drop reasons, and one-time init logs all stay at debug level. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1 * logging: address CodeRabbit review on trace-gate PR - GPS: pass serial-derived buffers as %s args, never as format strings (untrusted bytes could contain % directives) - 0x%08x for packet id / NodeNum per convention (Router, CannedMessage, NeighborInfo); unsigned casts for size_t args; %u for uint32_t delta - EInk: async full-refresh begin/complete back to DEBUG (rare state transitions); per-frame SKIPPED lines stay trace Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1 * logging: gate trace on the flag's value, not its presence -DMESHTASTIC_TRACE_LOGGING=0 previously *enabled* trace logging because the gate tested definedness. The flag now defaults per-platform (portduino 1, else 0) and both backends test the value, so =0 disables, =1 or a bare -D enables. Also cast tx_after-millis() to uint32_t for %u (millis() is unsigned long on native). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1 * logging: clang-format rewrap after specifier widening Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1 * Even fewer bytes! * logging: keep compile-gated debug lines at debug level; fix native-suite-count Lines already inside default-off #ifdef blocks (GPS_DEBUG, DEBUG_LOOP_TIMING) cost no flash and should stay visible at debug level when their gate is enabled, rather than also requiring MESHTASTIC_TRACE_LOGGING. test/native-suite-count lags the two test_event_channel_* suites added by #11045 (develop's Native Suite Count check has the same mismatch); bump 46 -> 47. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1 * gps: route GPS_DEBUG diagnostics through a LOG_DEBUG_GPS() macro (#11414) Replaces 27 log-only #ifdef GPS_DEBUG blocks across GPS.cpp, PositionModule, MeshService, and GPSStatus.h with a single-line LOG_DEBUG_GPS() call (src/gps/GPSLog.h, modeled on LOG_MIGRATION: value-gated, ((void)0) when off). Blocks containing declarations, control flow, hexDump, or nested conditionals keep an explicit '#if GPS_DEBUG' guard. RTC.cpp's per-reading raw time dumps and per-candidate rejection chatter fold under the same gate; quality transitions and boot-time seeding stay at debug. Also fixes the '// define GPS_DEBUG' missing-# typo in two variant headers and updates all seven commented examples to the value form ('#define GPS_DEBUG 1') required by the value-based gate. Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1 Co-authored-by: Claude * gps: declare RTC gmtime result as pointer to const (cppcheck) With the setTime debug dump gated behind GPS_DEBUG, all remaining uses of t are reads; cppcheck (constVariablePointer) now flags it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1 --------- Co-authored-by: Claude --- .github/copilot-instructions.md | 1 + src/DebugConfiguration.h | 18 +++ src/FSCommon.cpp | 4 +- src/GPSStatus.h | 5 +- src/Power.cpp | 6 +- src/detect/ReClockI2C.h | 12 +- src/gps/GPS.cpp | 115 ++++++------------ src/gps/GPSLog.h | 14 +++ src/gps/RTC.cpp | 37 +++--- src/graphics/EInkDisplay2.cpp | 1 - src/graphics/EInkDynamicDisplay.cpp | 26 ++-- .../niche/InkHUD/PlatformioConfig.ini | 2 +- src/graphics/niche/Utils/FlashData.h | 2 +- src/mesh/MeshService.cpp | 5 +- src/mesh/NextHopRouter.cpp | 6 +- src/mesh/NodeDB.cpp | 10 +- src/mesh/PacketHistory.cpp | 2 +- src/mesh/PhoneAPI.cpp | 16 +-- src/mesh/RadioLibInterface.cpp | 14 +-- src/mesh/Router.cpp | 10 +- src/mesh/SX126xInterface.cpp | 2 +- src/modules/AdminModule.cpp | 4 +- src/modules/CannedMessageModule.cpp | 11 +- src/modules/NeighborInfoModule.cpp | 17 ++- src/modules/PositionModule.cpp | 23 ++-- src/modules/RangeTestModule.cpp | 2 +- src/modules/Telemetry/AirQualityTelemetry.cpp | 4 +- src/modules/Telemetry/PowerTelemetry.cpp | 2 +- src/modules/Telemetry/Sensor/BME680Sensor.cpp | 4 +- src/modules/Telemetry/Sensor/DS248XSensor.cpp | 5 - src/modules/Telemetry/Sensor/HM330XSensor.cpp | 7 -- .../Telemetry/Sensor/MAX17048Sensor.cpp | 12 +- .../Telemetry/Sensor/PMSA003ISensor.cpp | 9 +- src/modules/Telemetry/Sensor/SCD30Sensor.cpp | 13 -- src/modules/Telemetry/Sensor/SCD4XSensor.cpp | 27 +--- src/modules/Telemetry/Sensor/SEN5XSensor.cpp | 26 ++-- src/modules/Telemetry/Sensor/SFA30Sensor.cpp | 19 +-- src/modules/Telemetry/Sensor/SHTXXSensor.cpp | 4 +- src/modules/TrafficManagementModule.cpp | 7 +- src/platform/portduino/SimRadio.cpp | 8 +- variants/esp32/chatter2/variant.h | 2 +- variants/esp32/tbeam/variant.h | 2 +- .../diy/nrf52_promicro_diy_tcxo/variant.h | 2 +- variants/nrf52840/dls_Minimesh_Lite/variant.h | 2 +- .../nrf52840/seeed_wio_tracker_L1/variant.h | 2 +- .../seeed_wio_tracker_L1_eink/variant.h | 2 +- variants/nrf52840/t-echo-lite/variant.h | 2 +- 47 files changed, 216 insertions(+), 310 deletions(-) create mode 100644 src/gps/GPSLog.h diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5c1c99e97..114afd1a2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -332,6 +332,7 @@ firmware/ - Follow existing code style - run `trunk fmt` before commits - Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging +- **Three logging tiers for diagnostics.** `LOG_TRACE` is the per-packet/per-poll firehose - compiled out by default (`MESHTASTIC_TRACE_LOGGING=1` enables; always on for portduino). Subsystem bring-up detail routes through a per-subsystem gate macro instead, e.g. `LOG_DEBUG_GPS(...)` in `src/gps/GPSLog.h` (`GPS_DEBUG=1` enables; costs no flash when off) - model new subsystem gates on it or on `LOG_MIGRATION` (`src/mesh/WarmNodeStore.h`): `#ifndef` value-default, `#if SYM` value test, `((void)0)` off-branch. Genuine anomalies stay unconditional `LOG_WARN`/`LOG_ERROR`. - **Format node IDs and packet IDs as `0x%08x` in logs.** This covers `NodeNum`/`PacketId` and the `uint32_t` packet fields `from`, `to`, `id`, `dest`, `source`, `request_id`, and `node_id`. They are 32-bit, so 8 hex digits is exact - `%08x` never truncates or leaves a value ragged. Do **not** use `%x` (variable width) or `%0x` (a no-op typo for `%08x` - the `0` flag does nothing without a width). User-facing display uses `!%08x` (the `!xxxxxxxx` convention), e.g. `Applet::hexifyNodeNum`. - **Do not zero-pad one-byte values to 8.** `next_hop`, `relay_node`, and the next-hop hint are `uint8_t` last-byte route hints, and `channel` is a one-byte hash/index - log these as `0x%x` (or `%d`). Padding a byte to `0x000000ab` falsely implies a full node number. The same goes for I2C addresses, register values, flags/bitmasks, and error/reason codes: they are not IDs, so leave them `0x%x`. - Use `assert()` for invariants that should never fail diff --git a/src/DebugConfiguration.h b/src/DebugConfiguration.h index 65b258fc1..247776a89 100644 --- a/src/DebugConfiguration.h +++ b/src/DebugConfiguration.h @@ -48,6 +48,16 @@ extern MemGet memGet; #define DEBUG_PORT (*console) // Serial debug port +// LOG_TRACE costs no flash unless enabled: -DMESHTASTIC_TRACE_LOGGING(=1) turns it on, =0 forces it off. +// Default is on only for portduino (traceFilename packet traces, logoutputlevel=trace), off elsewhere. +#ifndef MESHTASTIC_TRACE_LOGGING +#ifdef ARCH_PORTDUINO +#define MESHTASTIC_TRACE_LOGGING 1 +#else +#define MESHTASTIC_TRACE_LOGGING 0 +#endif +#endif + #ifdef USE_SEGGER // #undef DEBUG_PORT #define LOG_DEBUG(...) SEGGER_RTT_printf(0, __VA_ARGS__) @@ -55,16 +65,24 @@ extern MemGet memGet; #define LOG_WARN(...) SEGGER_RTT_printf(0, __VA_ARGS__) #define LOG_ERROR(...) SEGGER_RTT_printf(0, __VA_ARGS__) #define LOG_CRIT(...) SEGGER_RTT_printf(0, __VA_ARGS__) +#if MESHTASTIC_TRACE_LOGGING #define LOG_TRACE(...) SEGGER_RTT_printf(0, __VA_ARGS__) #else +#define LOG_TRACE(...) +#endif +#else #if defined(DEBUG_PORT) && !defined(DEBUG_MUTE) #define LOG_DEBUG(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_DEBUG, __VA_ARGS__) #define LOG_INFO(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_INFO, __VA_ARGS__) #define LOG_WARN(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_WARN, __VA_ARGS__) #define LOG_ERROR(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_ERROR, __VA_ARGS__) #define LOG_CRIT(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_CRIT, __VA_ARGS__) +#if MESHTASTIC_TRACE_LOGGING #define LOG_TRACE(...) DEBUG_PORT.log(MESHTASTIC_LOG_LEVEL_TRACE, __VA_ARGS__) #else +#define LOG_TRACE(...) +#endif +#else #define LOG_DEBUG(...) #define LOG_INFO(...) #define LOG_WARN(...) diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index 38b704e73..c00b07684 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -340,7 +340,7 @@ void listDir(const char *dirname, uint8_t levels, bool del) file.close(); FSCom.remove(buffer); } else { - LOG_DEBUG(" %s (%i Bytes)", filepath, file.size()); + LOG_TRACE(" %s (%i Bytes)", filepath, file.size()); file.close(); } } @@ -394,7 +394,7 @@ void fsInit() #if defined(ARCH_ESP32) LOG_DEBUG("Filesystem files (%d/%d Bytes):", FSCom.usedBytes(), FSCom.totalBytes()); #else - LOG_DEBUG("Filesystem files:"); + LOG_TRACE("Filesystem files:"); #endif listDir("/", 10); #endif diff --git a/src/GPSStatus.h b/src/GPSStatus.h index 2a384c8b5..e9f7325c8 100644 --- a/src/GPSStatus.h +++ b/src/GPSStatus.h @@ -2,6 +2,7 @@ #include "NodeDB.h" #include "Status.h" #include "configuration.h" +#include "gps/GPSLog.h" #include namespace meshtastic @@ -92,9 +93,7 @@ class GPSStatus : public Status bool matches(const GPSStatus *newStatus) const { -#ifdef GPS_DEBUG - LOG_DEBUG("GPSStatus.match() new pos@%x to old pos@%x", newStatus->p.timestamp, p.timestamp); -#endif + LOG_DEBUG_GPS("GPSStatus.match() new pos@%x to old pos@%x", newStatus->p.timestamp, p.timestamp); return (newStatus->hasLock != hasLock || newStatus->isConnected != isConnected || newStatus->hasTime != hasTime || newStatus->isPowerSaving != isPowerSaving || newStatus->p.latitude_i != p.latitude_i || newStatus->p.longitude_i != p.longitude_i || newStatus->p.altitude != p.altitude || diff --git a/src/Power.cpp b/src/Power.cpp index 2a0938e2a..d347e7f93 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -606,7 +606,7 @@ class AnalogBatteryLevel : public HasBatteryLevel // get current flow from INA sensor - negative value means power flowing // into the battery default assuming BATTERY+ <--> INA_VIN+ <--> SHUNT // RESISTOR <--> INA_VIN- <--> LOAD - LOG_DEBUG("Using INA on I2C addr 0x%x for charging detection", config.power.device_battery_ina_address); + LOG_TRACE("Using INA on I2C addr 0x%x for charging detection", config.power.device_battery_ina_address); #if defined(INA_CHARGING_DETECTION_INVERT) return getINACurrent() > 0; #else @@ -1881,10 +1881,10 @@ class LipoCharger : public HasBatteryLevel bool isCharging = PPM->isCharging(); if (bq) { if (isCharging) { - LOG_DEBUG("BQ27220 time to full charge: %d min", bq->getTimeToFull()); + LOG_TRACE("BQ27220 time to full charge: %d min", bq->getTimeToFull()); } else { if (!PPM->isVbusIn()) { - LOG_DEBUG("BQ27220 time to empty: %d min (%d mAh)", bq->getTimeToEmpty(), bq->getRemainingCapacity()); + LOG_TRACE("BQ27220 time to empty: %d min (%d mAh)", bq->getTimeToEmpty(), bq->getRemainingCapacity()); } } } diff --git a/src/detect/ReClockI2C.h b/src/detect/ReClockI2C.h index 503f1a48b..24a166d53 100644 --- a/src/detect/ReClockI2C.h +++ b/src/detect/ReClockI2C.h @@ -35,20 +35,20 @@ class ReClockI2C uint32_t currentClock = this->getClock(); if (currentClock) { - LOG_DEBUG("Current I2C frequency: %uHz", currentClock); + LOG_TRACE("Current I2C frequency: %uHz", currentClock); } if (currentClock != desiredClock) { - LOG_DEBUG("Changing I2C clock to %uHz", desiredClock); + LOG_TRACE("Changing I2C clock to %uHz", desiredClock); this->i2cBus->setClock(desiredClock); // If the clock is 0Hz, we still store it // We'll check in restoreClock function setPreviousClock(currentClock); - LOG_DEBUG("Stored previous clock I2C clock: %uHz", this->previousClock); + LOG_TRACE("Stored previous clock I2C clock: %uHz", this->previousClock); return true; } - LOG_DEBUG("I2C clock was already %uHz. Skipping", desiredClock); + LOG_TRACE("I2C clock was already %uHz. Skipping", desiredClock); setPreviousClock(0); return false; } @@ -56,12 +56,12 @@ class ReClockI2C bool restoreClock() { if (this->previousClock) { - LOG_DEBUG("Restoring I2C clock to %uHz", this->previousClock); + LOG_TRACE("Restoring I2C clock to %uHz", this->previousClock); i2cBus->setClock(this->previousClock); setPreviousClock(0); return true; } - LOG_DEBUG("I2C clock was unknown. Not restored"); + LOG_TRACE("I2C clock was unknown. Not restored"); return false; } diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 0a8c7252c..fd5be417e 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -5,6 +5,7 @@ #if !MESHTASTIC_EXCLUDE_GPS #include "Default.h" #include "GPS.h" +#include "GPSLog.h" #include "GpioLogic.h" #include "NodeDB.h" #include "PowerMon.h" @@ -337,7 +338,7 @@ uint8_t GPS::makeCASPacket(uint8_t class_id, uint8_t msg_id, uint8_t payload_siz } CASChecksum(UBXscratch, (payload_size + 10)); -#if defined(GPS_DEBUG) && defined(DEBUG_PORT) +#if GPS_DEBUG && defined(DEBUG_PORT) LOG_DEBUG("CAS packet: "); DEBUG_PORT.hexDump(MESHTASTIC_LOG_LEVEL_DEBUG, UBXscratch, payload_size + 10); #endif @@ -350,26 +351,22 @@ GPS_RESPONSE GPS::getACK(const char *message, uint32_t waitMillis) uint8_t b; int bytesRead = 0; uint32_t startTimeout = millis() + waitMillis; -#ifdef GPS_DEBUG +#if GPS_DEBUG std::string debugmsg = ""; #endif while (millis() < startTimeout) { if (_serial_gps->available()) { b = _serial_gps->read(); -#ifdef GPS_DEBUG +#if GPS_DEBUG debugmsg += vformat("%c", (b >= 32 && b <= 126) ? b : '.'); #endif buffer[bytesRead] = b; bytesRead++; if ((bytesRead == 767) || (b == '\r')) { -#ifdef GPS_DEBUG - LOG_DEBUG(debugmsg.c_str()); -#endif + LOG_DEBUG_GPS("%s", debugmsg.c_str()); if (strnstr((char *)buffer, message, bytesRead) != nullptr) { -#ifdef GPS_DEBUG - LOG_DEBUG("Found: %s", message); // Log the found message -#endif + LOG_DEBUG_GPS("Found: %s", message); // Log the found message return GNSS_RESPONSE_OK; } else { bytesRead = 0; @@ -418,17 +415,13 @@ GPS_RESPONSE GPS::getACKCas(uint8_t class_id, uint8_t msg_id, uint32_t waitMilli // Check for an ACK-ACK for the specified class and message id if ((msg_cls == 0x05) && (msg_msg_id == 0x01) && payload_cls == class_id && payload_msg == msg_id) { -#ifdef GPS_DEBUG - LOG_INFO("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime); -#endif + LOG_DEBUG_GPS("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime); return GNSS_RESPONSE_OK; } // Check for an ACK-NACK for the specified class and message id if ((msg_cls == 0x05) && (msg_msg_id == 0x00) && payload_cls == class_id && payload_msg == msg_id) { -#ifdef GPS_DEBUG - LOG_WARN("Got NACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime); -#endif + LOG_DEBUG_GPS("Got NACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime); return GNSS_RESPONSE_NAK; } @@ -450,7 +443,7 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis) uint32_t startTime = millis(); const char frame_errors[] = "More than 100 frame errors"; int sCounter = 0; -#ifdef GPS_DEBUG +#if GPS_DEBUG std::string debugmsg = ""; #endif @@ -467,9 +460,7 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis) while (Throttle::isWithinTimespanMs(startTime, waitMillis)) { if (ack > 9) { -#ifdef GPS_DEBUG - LOG_INFO("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime); -#endif + LOG_DEBUG_GPS("Got ACK for class %02X msg %02X in %dms", class_id, msg_id, millis() - startTime); return GNSS_RESPONSE_OK; // ACK received } if (_serial_gps->available()) { @@ -477,25 +468,20 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis) if (b == frame_errors[sCounter]) { sCounter++; if (sCounter == 26) { -#ifdef GPS_DEBUG - - LOG_DEBUG(debugmsg.c_str()); -#endif + LOG_DEBUG_GPS("%s", debugmsg.c_str()); return GNSS_RESPONSE_FRAME_ERRORS; } } else { sCounter = 0; } -#ifdef GPS_DEBUG +#if GPS_DEBUG debugmsg += vformat("%02X", b); #endif if (b == buf[ack]) { ack++; } else { if (ack == 3 && b == 0x00) { // UBX-ACK-NAK message -#ifdef GPS_DEBUG - LOG_DEBUG(debugmsg.c_str()); -#endif + LOG_DEBUG_GPS("%s", debugmsg.c_str()); LOG_WARN("Got NAK for class %02X msg %02X", class_id, msg_id); return GNSS_RESPONSE_NAK; // NAK received } @@ -503,10 +489,8 @@ GPS_RESPONSE GPS::getACK(uint8_t class_id, uint8_t msg_id, uint32_t waitMillis) } } } -#ifdef GPS_DEBUG - LOG_DEBUG(debugmsg.c_str()); - LOG_WARN("No response for class %02X msg %02X", class_id, msg_id); -#endif + LOG_DEBUG_GPS("%s", debugmsg.c_str()); + LOG_DEBUG_GPS("No response for class %02X msg %02X", class_id, msg_id); return GNSS_RESPONSE_NONE; // No response received within timeout } @@ -577,9 +561,7 @@ int GPS::getACK(uint8_t *buffer, uint16_t size, uint8_t requestedClass, uint8_t ubxFrameCounter = 0; } else { // return payload length -#ifdef GPS_DEBUG - LOG_INFO("Got ACK for class %02X msg %02X in %dms", requestedClass, requestedID, millis() - startTime); -#endif + LOG_DEBUG_GPS("Got ACK for class %02X msg %02X in %dms", requestedClass, requestedID, millis() - startTime); return needRead; } break; @@ -1234,9 +1216,7 @@ void GPS::writePinEN(bool on) // Write and log enablePin->set(on); -#ifdef GPS_DEBUG - LOG_DEBUG("Pin EN %s", on == HIGH ? "HI" : "LOW"); -#endif + LOG_DEBUG_GPS("Pin EN %s", on == HIGH ? "HI" : "LOW"); } // Set the value of the STANDBY pin, if relevant @@ -1259,9 +1239,7 @@ void GPS::writePinStandby(bool standby) _serial_gps->write("$PMTK225,4*2F\r\n"); } -#ifdef GPS_DEBUG - LOG_DEBUG("Pin STANDBY %s", val == HIGH ? "HI" : "LOW"); -#endif + LOG_DEBUG_GPS("Pin STANDBY %s", val == HIGH ? "HI" : "LOW"); #endif } @@ -1272,9 +1250,7 @@ void GPS::writePinRFEN(bool on) bool val = on ? GPS_RF_EN_ACTIVE : !GPS_RF_EN_ACTIVE; pinMode(PIN_GPS_RF_EN, OUTPUT); digitalWrite(PIN_GPS_RF_EN, val); -#ifdef GPS_DEBUG - LOG_DEBUG("Pin RF EN %s", val == HIGH ? "HI" : "LOW"); -#endif + LOG_DEBUG_GPS("Pin RF EN %s", val == HIGH ? "HI" : "LOW"); #else (void)on; #endif @@ -1310,9 +1286,7 @@ void GPS::setPowerPMU(bool on) // t-beam v1.1 GNSS power channel on ? PMU->enablePowerOutput(XPOWERS_LDO3) : PMU->disablePowerOutput(XPOWERS_LDO3); } -#ifdef GPS_DEBUG - LOG_DEBUG("PMU %s", on ? "on" : "off"); -#endif + LOG_DEBUG_GPS("PMU %s", on ? "on" : "off"); #endif } @@ -1358,9 +1332,7 @@ void GPS::setPowerUBLOX(bool on, uint32_t sleepMs) // Send the UBX packet gps->_serial_gps->write(gps->UBXscratch, msglen); -#ifdef GPS_DEBUG - LOG_DEBUG("UBLOX: sleep for %dmS", sleepMs); -#endif + LOG_DEBUG_GPS("UBLOX: sleep for %dmS", sleepMs); } } @@ -1546,7 +1518,7 @@ int32_t GPS::runOnce() // 2. Got a lock for the first time, or 3. Got a lock after turning back on bool gotLoc = lookForLocation(); if (gotLoc) { -#ifdef GPS_DEBUG +#if GPS_DEBUG if (!hasValidLocation) { // declare that we have location ASAP LOG_DEBUG("hasValidLocation RISING EDGE"); } @@ -1561,9 +1533,7 @@ int32_t GPS::runOnce() if (holdTime > GPS_FIX_HOLD_MAX_MS) holdTime = GPS_FIX_HOLD_MAX_MS; fixHoldEnds = millis() + holdTime; -#ifdef GPS_DEBUG - LOG_DEBUG("Holding for %ums after lock", holdTime); -#endif + LOG_DEBUG_GPS("Holding for %ums after lock", holdTime); } } @@ -1575,9 +1545,7 @@ int32_t GPS::runOnce() p = meshtastic_Position_init_default; hasValidLocation = false; shouldPublish = true; -#ifdef GPS_DEBUG - LOG_DEBUG("hasValidLocation FALLING EDGE"); -#endif + LOG_DEBUG_GPS("hasValidLocation FALLING EDGE"); } } @@ -1597,7 +1565,7 @@ int32_t GPS::runOnce() down(); } -#ifdef GPS_DEBUG +#if GPS_DEBUG } else if (fixHoldEnds != 0) { LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - millis(), p.sats_in_view); #endif @@ -1824,7 +1792,6 @@ GnssModel_t GPS::probe(int serialSpeed) break; } - LOG_DEBUG("Module Info : "); LOG_DEBUG("Soft version: %s", ublox_info.swVersion); LOG_DEBUG("Hard version: %s", ublox_info.hwVersion); LOG_DEBUG("Extensions:%d", ublox_info.extensionNo); @@ -1904,27 +1871,21 @@ GnssModel_t GPS::getProbeResponse(unsigned long timeout, const std::vector= 2 && response[responseLen - 2] == '\r' && response[responseLen - 1] == '\n') { -#ifdef GPS_DEBUG - LOG_DEBUG(response.get()); -#endif + LOG_DEBUG_GPS("%s", response.get()); // Reset the response buffer for the next potential message responseLen = 0; response[0] = '\0'; } } } -#ifdef GPS_DEBUG - LOG_DEBUG(response.get()); -#endif + LOG_DEBUG_GPS("%s", response.get()); return GNSS_MODEL_UNKNOWN; // Return unknown on timeout } @@ -2125,7 +2086,7 @@ bool GPS::lookForLocation() #ifndef TINYGPS_OPTION_NO_STATISTICS if (reader.failedChecksum() > lastChecksumFailCount) { // In a GPS_DEBUG build we want to log all of these. In production, we only care if there are many of them. -#ifndef GPS_DEBUG +#if !GPS_DEBUG if (reader.failedChecksum() > 4) #endif LOG_WARN("%u new GPS checksum failures, total %u", reader.failedChecksum() - lastChecksumFailCount, @@ -2142,7 +2103,7 @@ bool GPS::lookForLocation() if (!hasLock()) return false; -#ifdef GPS_DEBUG +#if GPS_DEBUG LOG_DEBUG("AGE: LOC=%d FIX=%d DATE=%d TIME=%d", reader.location.age(), #ifndef TINYGPS_OPTION_NO_CUSTOM_FIELDS gsafixtype.age(), @@ -2173,15 +2134,11 @@ bool GPS::lookForLocation() // Bail out EARLY to avoid overwriting previous good data (like #857) if (toDegInt(loc.lat) > 900000000) { -#ifdef GPS_DEBUG - LOG_DEBUG("Bail out EARLY on LAT %i", toDegInt(loc.lat)); -#endif + LOG_DEBUG_GPS("Bail out EARLY on LAT %i", toDegInt(loc.lat)); return false; } if (toDegInt(loc.lng) > 1800000000) { -#ifdef GPS_DEBUG - LOG_DEBUG("Bail out EARLY on LNG %i", toDegInt(loc.lng)); -#endif + LOG_DEBUG_GPS("Bail out EARLY on LNG %i", toDegInt(loc.lng)); return false; } @@ -2266,7 +2223,7 @@ bool GPS::whileActive() { unsigned int charsInBuf = 0; bool isValid = false; -#ifdef GPS_DEBUG +#if GPS_DEBUG std::string debugmsg = ""; #endif if (powerState != GPS_ACTIVE) { @@ -2283,7 +2240,7 @@ bool GPS::whileActive() while (_serial_gps->available() > 0) { int c = _serial_gps->read(); UBXscratch[charsInBuf] = c; -#ifdef GPS_DEBUG +#if GPS_DEBUG debugmsg += vformat("%c", (c >= 32 && c <= 126) ? c : '.'); #endif isValid |= reader.encode(c); @@ -2296,9 +2253,9 @@ bool GPS::whileActive() charsInBuf++; } } -#ifdef GPS_DEBUG +#if GPS_DEBUG if (debugmsg != "") { - LOG_DEBUG(debugmsg.c_str()); + LOG_DEBUG("%s", debugmsg.c_str()); } #endif return isValid; diff --git a/src/gps/GPSLog.h b/src/gps/GPSLog.h new file mode 100644 index 000000000..9ee85096d --- /dev/null +++ b/src/gps/GPSLog.h @@ -0,0 +1,14 @@ +#pragma once + +#include "DebugConfiguration.h" + +// GPS_DEBUG=1 enables verbose GNSS diagnostics (probe/ACK byte dumps, pin states, NMEA ages). +// Costs no flash when off. Genuine LOG_WARN anomalies stay unconditional. +#ifndef GPS_DEBUG +#define GPS_DEBUG 0 +#endif +#if GPS_DEBUG +#define LOG_DEBUG_GPS(...) LOG_DEBUG(__VA_ARGS__) +#else +#define LOG_DEBUG_GPS(...) ((void)0) +#endif diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 5e65ac8ad..99153e764 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -2,6 +2,7 @@ #include "configuration.h" #include "detect/ScanI2C.h" #include "detect/ScanI2CTwoWire.h" +#include "gps/GPSLog.h" #include "main.h" #include "mesh/MeshService.h" #include "modules/NodeInfoModule.h" @@ -127,8 +128,8 @@ RTCSetResult readFromRTC() } #endif - LOG_DEBUG("RTC time from RV3028 getTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, t.tm_mon + 1, t.tm_mday, - t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); + LOG_DEBUG_GPS("RTC time from RV3028 getTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, t.tm_mon + 1, + t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; timeStartMsec = now; @@ -173,8 +174,8 @@ RTCSetResult readFromRTC() } #endif - LOG_DEBUG("RTC time from %s getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t.tm_year + 1900, - t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); + LOG_DEBUG_GPS("RTC time from %s getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t.tm_year + 1900, + t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; timeStartMsec = now; @@ -200,8 +201,8 @@ RTCSetResult readFromRTC() tv.tv_usec = 0; uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms - LOG_DEBUG("RTC time from RX8130CE getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, t.tm_mon + 1, - t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); + LOG_DEBUG_GPS("RTC time from RX8130CE getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", t.tm_year + 1900, + t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); #ifdef BUILD_EPOCH if (tv.tv_sec < BUILD_EPOCH) { if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) { @@ -294,14 +295,14 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd LOG_DEBUG("Upgrade time to quality %s", RtcName(q)); } else if (q == RTCQualityGPS) { shouldSet = true; - LOG_DEBUG("Reapply GPS time: %ld secs", printableEpoch); + LOG_DEBUG_GPS("Reapply GPS time: %ld secs", printableEpoch); } else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (30 * 60 * 1000UL))) { // Every 30 minutes we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift shouldSet = true; - LOG_DEBUG("Reapply external time to fix clock drift %ld secs", printableEpoch); + LOG_DEBUG_GPS("Reapply external time to fix clock drift %ld secs", printableEpoch); } else { shouldSet = false; - LOG_DEBUG("RTC quality: %s. Ignore time of quality %s", RtcName(currentQuality), RtcName(q)); + LOG_DEBUG_GPS("RTC quality: %s. Ignore time of quality %s", RtcName(currentQuality), RtcName(q)); } if (shouldSet) { @@ -327,10 +328,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd // tv_sec is a long, which is not time_t everywhere: on Windows // time_t is 64-bit while long is 32-bit. Copy before taking &. time_t setSecs = tv->tv_sec; - tm *t = gmtime(&setSecs); + const tm *t = gmtime(&setSecs); rtc.setTime(t->tm_year + 1900, t->tm_mon + 1, t->tm_wday, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec); - LOG_DEBUG("RV3028_RTC setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, - t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + LOG_DEBUG_GPS("RV3028_RTC setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, + t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); } else { LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address); } @@ -352,10 +353,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd // tv_sec is a long, which is not time_t everywhere: on Windows // time_t is 64-bit while long is 32-bit. Copy before taking &. time_t setSecs = tv->tv_sec; - tm *t = gmtime(&setSecs); + const tm *t = gmtime(&setSecs); rtc.setDateTime(*t); - LOG_DEBUG("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, t->tm_mon + 1, - t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + LOG_DEBUG_GPS("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, + t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); } else { LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address); } @@ -369,10 +370,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd // tv_sec is a long, which is not time_t everywhere: on Windows // time_t is 64-bit while long is 32-bit. Copy before taking &. time_t setSecs = tv->tv_sec; - tm *t = gmtime(&setSecs); + const tm *t = gmtime(&setSecs); if (rtc.setTime(*t)) { - LOG_DEBUG("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, - t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); + LOG_DEBUG_GPS("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, + t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); } else { LOG_WARN("RX8130CE set time failed"); } diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index a44e8ef4b..dca31be60 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -99,7 +99,6 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit) // End the update process endUpdate(); - LOG_DEBUG("done"); return true; } diff --git a/src/graphics/EInkDynamicDisplay.cpp b/src/graphics/EInkDynamicDisplay.cpp index a48ba5c93..be05cd0c3 100644 --- a/src/graphics/EInkDynamicDisplay.cpp +++ b/src/graphics/EInkDynamicDisplay.cpp @@ -157,7 +157,7 @@ bool EInkDynamicDisplay::determineMode() resetRateLimiting(); // Once determineMode() ends, will have to wait again hashImage(); // Generate here, so we can still copy it to previousImageHash, even if we skip the comparison check - LOG_DEBUG("determineMode(): "); // Begin log entry + LOG_TRACE("determineMode(): "); // Begin log entry // Once mode determined, any remaining checks will bypass checkCosmetic(); @@ -254,7 +254,7 @@ void EInkDynamicDisplay::checkRateLimiting() if (Throttle::isWithinTimespanMs(previousRunMs, 1000)) { refresh = SKIPPED; reason = EXCEEDED_RATELIMIT_FAST; - LOG_DEBUG("refresh=SKIPPED, reason=EXCEEDED_RATELIMIT_FAST, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=SKIPPED, reason=EXCEEDED_RATELIMIT_FAST, frameFlags=0x%x", frameFlags); return; } } @@ -271,7 +271,7 @@ void EInkDynamicDisplay::checkCosmetic() if (frameFlags & COSMETIC) { refresh = FULL; reason = FLAGGED_COSMETIC; - LOG_DEBUG("refresh=FULL, reason=FLAGGED_COSMETIC, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FULL, reason=FLAGGED_COSMETIC, frameFlags=0x%x", frameFlags); } } @@ -286,7 +286,7 @@ void EInkDynamicDisplay::checkDemandingFast() if (frameFlags & DEMAND_FAST) { refresh = FAST; reason = FLAGGED_DEMAND_FAST; - LOG_DEBUG("refresh=FAST, reason=FLAGGED_DEMAND_FAST, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FAST, reason=FLAGGED_DEMAND_FAST, frameFlags=0x%x", frameFlags); } } @@ -306,7 +306,7 @@ void EInkDynamicDisplay::checkFrameMatchesPrevious() if (frameFlags == BACKGROUND && fastRefreshCount > 0) { refresh = FULL; reason = REDRAW_WITH_FULL; - LOG_DEBUG("refresh=FULL, reason=REDRAW_WITH_FULL, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FULL, reason=REDRAW_WITH_FULL, frameFlags=0x%x", frameFlags); return; } #endif @@ -314,7 +314,7 @@ void EInkDynamicDisplay::checkFrameMatchesPrevious() // Not redrawn, not COSMETIC, not DEMAND_FAST refresh = SKIPPED; reason = FRAME_MATCHED_PREVIOUS; - LOG_DEBUG("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags); } // Have too many fast-refreshes occurred consecutively, since last full refresh? @@ -328,7 +328,7 @@ void EInkDynamicDisplay::checkConsecutiveFastRefreshes() if (frameFlags & UNLIMITED_FAST) { refresh = FAST; reason = NO_OBJECTIONS; - LOG_DEBUG("refresh=FAST, reason=UNLIMITED_FAST_MODE_ACTIVE, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FAST, reason=UNLIMITED_FAST_MODE_ACTIVE, frameFlags=0x%x", frameFlags); return; } @@ -336,7 +336,7 @@ void EInkDynamicDisplay::checkConsecutiveFastRefreshes() if (fastRefreshCount >= EINK_LIMIT_FASTREFRESH) { refresh = FULL; reason = EXCEEDED_LIMIT_FASTREFRESH; - LOG_DEBUG("refresh=FULL, reason=EXCEEDED_LIMIT_FASTREFRESH, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FULL, reason=EXCEEDED_LIMIT_FASTREFRESH, frameFlags=0x%x", frameFlags); } } @@ -351,13 +351,13 @@ void EInkDynamicDisplay::checkFastRequested() // If we want BACKGROUND to use fast. (FULL only when a limit is hit) refresh = FAST; reason = BACKGROUND_USES_FAST; - LOG_DEBUG("refresh=FAST, reason=BACKGROUND_USES_FAST, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, + LOG_TRACE("refresh=FAST, reason=BACKGROUND_USES_FAST, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags); #else // If we do want to use FULL for BACKGROUND updates refresh = FULL; reason = FLAGGED_BACKGROUND; - LOG_DEBUG("refresh=FULL, reason=FLAGGED_BACKGROUND"); + LOG_TRACE("refresh=FULL, reason=FLAGGED_BACKGROUND"); #endif } @@ -365,7 +365,7 @@ void EInkDynamicDisplay::checkFastRequested() if (frameFlags & RESPONSIVE) { refresh = FAST; reason = NO_OBJECTIONS; - LOG_DEBUG("refresh=FAST, reason=NO_OBJECTIONS, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags); + LOG_TRACE("refresh=FAST, reason=NO_OBJECTIONS, fastRefreshCount=%lu, frameFlags=0x%x", fastRefreshCount, frameFlags); } } @@ -430,7 +430,7 @@ void EInkDynamicDisplay::countGhostPixels() } } - LOG_DEBUG("ghostPixels=%hu, ", ghostPixelCount); + LOG_TRACE("ghostPixels=%hu, ", ghostPixelCount); } // Check if ghost pixel count exceeds the defined limit @@ -446,7 +446,7 @@ void EInkDynamicDisplay::checkExcessiveGhosting() if (ghostPixelCount > EINK_LIMIT_GHOSTING_PX) { refresh = FULL; reason = EXCEEDED_GHOSTINGLIMIT; - LOG_DEBUG("refresh=FULL, reason=EXCEEDED_GHOSTINGLIMIT, frameFlags=0x%x", frameFlags); + LOG_TRACE("refresh=FULL, reason=EXCEEDED_GHOSTINGLIMIT, frameFlags=0x%x", frameFlags); } } diff --git a/src/graphics/niche/InkHUD/PlatformioConfig.ini b/src/graphics/niche/InkHUD/PlatformioConfig.ini index 4c03773b9..f5bc5c28c 100644 --- a/src/graphics/niche/InkHUD/PlatformioConfig.ini +++ b/src/graphics/niche/InkHUD/PlatformioConfig.ini @@ -24,4 +24,4 @@ build_flags = -D HAS_BUTTON=0 ; Suppress default ButtonThread lib_deps = # renovate: datasource=github-tags depName=GFX_Root packageName=ZinggJM/GFX_Root - https://github.com/ZinggJM/GFX_Root/archive/3195764e352a0d2567c8d277ac408ca7293a99b0.zip ; Used by InkHUD as a "slimmer" version of AdafruitGFX + https://github.com/ZinggJM/GFX_Root.git#3195764e352a0d2567c8d277ac408ca7293a99b0 ; Used by InkHUD as a "slimmer" version of AdafruitGFX diff --git a/src/graphics/niche/Utils/FlashData.h b/src/graphics/niche/Utils/FlashData.h index 3c12fc930..43fcd7355 100644 --- a/src/graphics/niche/Utils/FlashData.h +++ b/src/graphics/niche/Utils/FlashData.h @@ -96,7 +96,7 @@ template class FlashData f.close(); } else { - LOG_ERROR("Can't open / read %s", filename.c_str()); + LOG_ERROR("Can't open/read %s", filename.c_str()); okay = false; } #else diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index b67d9f44d..77540660b 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -13,6 +13,7 @@ #include "PowerFSM.h" #include "TypeConversions.h" #include "UptimeClock.h" +#include "gps/GPSLog.h" #include "gps/RTC.h" #include "graphics/draw/MessageRenderer.h" #include "main.h" @@ -596,9 +597,7 @@ int MeshService::onGPSChanged(const meshtastic::GPSStatus *newStatus) pos = gps->p; } else { // The GPS has lost lock -#ifdef GPS_DEBUG - LOG_DEBUG("onGPSchanged() - lost validLocation"); -#endif + LOG_DEBUG_GPS("onGPSchanged() - lost validLocation"); } // Used fixed position if configured regardless of GPS lock if (config.position.fixed_position) { diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index c4eb5c681..d7d396f60 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -67,7 +67,7 @@ ErrorCode NextHopRouter::send(meshtastic_MeshPacket *p) wasSeenRecently(p); // FIXME, move this to a sniffSent method p->next_hop = getNextHop(p->to, p->relay_node).value_or(NO_NEXT_HOP_PREFERENCE); // set the next hop - LOG_DEBUG("Set next hop for dest 0x%08x to 0x%x", p->to, p->next_hop); + LOG_TRACE("Set next hop for dest 0x%08x to 0x%x", p->to, p->next_hop); // If it's from us, ReliableRouter already handles retransmissions if want_ack is set. If a next hop is set and hop limit is // not 0 or want_ack is set, start retransmissions @@ -314,7 +314,7 @@ std::optional NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node) } ResolvedNode r = nodeDB->resolveLastByte(hint, /*requireDirectNeighbor=*/true); if (r.status == LastByteResolution::Unique) { - LOG_DEBUG("Next hop for 0x%08x is 0x%x (TMM cache)", to, hint); + LOG_TRACE("Next hop for 0x%08x is 0x%x (TMM cache)", to, hint); return hint; } LOG_WARN("TMM next hop 0x%x for 0x%08x %s; set no pref", hint, to, @@ -512,7 +512,7 @@ void NextHopRouter::setNextTx(PendingPacket *pending) assert(iface); auto d = iface->getRetransmissionMsec(pending->packet); pending->nextTxMsec = millis() + d; - LOG_DEBUG("Next retransmission in %u msecs", d); + LOG_TRACE("Next retransmission in %u msecs", d); printPacket("", pending->packet); setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time } diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 9ccc8065d..e6c1ac67e 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3434,9 +3434,9 @@ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxS if (t.which_variant == meshtastic_Telemetry_device_metrics_tag) { if (src == RX_SRC_LOCAL) { - LOG_DEBUG("updateTelemetry LOCAL device"); + LOG_TRACE("updateTelemetry LOCAL device"); } else { - LOG_DEBUG("updateTelemetry REMOTE device node=0x%08x", nodeId); + LOG_TRACE("updateTelemetry REMOTE device node=0x%08x", nodeId); } #if !MESHTASTIC_EXCLUDE_TELEMETRYDB concurrency::LockGuard guard(&satelliteMutex); @@ -3446,9 +3446,9 @@ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxS } else if (t.which_variant == meshtastic_Telemetry_environment_metrics_tag) { if (src == RX_SRC_LOCAL) { - LOG_DEBUG("updateTelemetry LOCAL env"); + LOG_TRACE("updateTelemetry LOCAL env"); } else { - LOG_DEBUG("updateTelemetry REMOTE env node=0x%08x", nodeId); + LOG_TRACE("updateTelemetry REMOTE env node=0x%08x", nodeId); } #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB concurrency::LockGuard guard(&satelliteMutex); @@ -3649,7 +3649,7 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) return; } if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) { - LOG_DEBUG("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time); + LOG_TRACE("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time); // mp.from is unauthenticated, so rate-limit admission once the database is full: otherwise // invented node numbers churn it at packet rate and push real neighbours out. diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index 87a5c69d7..da745a25e 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -107,7 +107,7 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd // Check for hop_limit upgrade scenario if (seenRecently && wasUpgraded && getHighestHopLimit(*found) < p->hop_limit) { - LOG_DEBUG("Packet History - Hop limit upgrade: packet 0x%08x hop_limit=%d -> %d", p->id, getHighestHopLimit(*found), + LOG_TRACE("Packet History - Hop limit upgrade: packet 0x%08x hop_limit=%d -> %d", p->id, getHighestHopLimit(*found), p->hop_limit); *wasUpgraded = true; } else if (wasUpgraded) { diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 696c370fa..e093d5be0 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -486,7 +486,7 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) break; #if !MESHTASTIC_EXCLUDE_MQTT case meshtastic_ToRadio_mqttClientProxyMessage_tag: - LOG_DEBUG("Got MqttClientProxy message"); + LOG_TRACE("Got MqttClientProxy message"); if (state != STATE_SEND_PACKETS) { LOG_WARN("Ignore MqttClientProxy msg during config handshake"); break; @@ -514,7 +514,7 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength) nodeInfoModule->sendOurNodeInfo(NODENUM_BROADCAST, true, 0, true); } } else { - LOG_DEBUG("Got client heartbeat"); + LOG_TRACE("Got client heartbeat"); heartbeatReceived = true; } break; @@ -558,7 +558,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.queueStatus = router->getQueueStatus(); heartbeatReceived = false; size_t numbytes = pb_encode_to_bytes(buf, meshtastic_FromRadio_size, &meshtastic_FromRadio_msg, &fromRadioScratch); - LOG_DEBUG("FromRadio=STATE_SEND_QUEUE_STATUS, numbytes=%u", numbytes); + LOG_TRACE("FromRadio=STATE_SEND_QUEUE_STATUS, numbytes=%u", (unsigned)numbytes); return numbytes; } @@ -571,7 +571,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) // Advance states as needed switch (state) { case STATE_SEND_NOTHING: - LOG_DEBUG("FromRadio=STATE_SEND_NOTHING"); + LOG_TRACE("FromRadio=STATE_SEND_NOTHING"); break; case STATE_SEND_MY_INFO: LOG_DEBUG("FromRadio=STATE_SEND_MY_INFO"); @@ -1002,7 +1002,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) } else { fromRadioScratch.which_payload_variant = meshtastic_FromRadio_fileInfo_tag; fromRadioScratch.fileInfo = filesManifest.at(config_state); - LOG_DEBUG("File: %s (%d) bytes", fromRadioScratch.fileInfo.file_name, fromRadioScratch.fileInfo.size_bytes); + LOG_TRACE("File: %s (%d) bytes", fromRadioScratch.fileInfo.file_name, fromRadioScratch.fileInfo.size_bytes); config_state++; } break; @@ -1015,7 +1015,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) case STATE_SEND_PACKETS: pauseBluetoothLogging = false; // Do we have a message from the mesh or packet from the local device? - LOG_DEBUG("FromRadio=STATE_SEND_PACKETS"); + LOG_TRACE("FromRadio=STATE_SEND_PACKETS"); if (queueStatusPacketForPhone) { fromRadioScratch.which_payload_variant = meshtastic_FromRadio_queueStatus_tag; fromRadioScratch.queueStatus = *queueStatusPacketForPhone; @@ -1100,7 +1100,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) return numbytes; } - LOG_DEBUG("No FromRadio packet available"); + LOG_TRACE("No FromRadio packet available"); return 0; } @@ -1204,7 +1204,7 @@ void PhoneAPI::prefetchNodeInfos() nodeInfoQueue.push_back(info); // Log progress here (at fetch time) so readIndex is accurate and each value logs only once. if (readIndex == 2 || readIndex % 20 == 0) { - LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); + LOG_TRACE("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); } added = true; } diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 7c45728cc..a826a5131 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -131,14 +131,14 @@ bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHead if (!(irq & syncWordHeaderValidFlag)) { // The HEADER_VALID flag should be set by now if it was really a packet, so ignore PREAMBLE_DETECTED flag activeReceiveStart = 0; - LOG_DEBUG("Ignore false preamble detection"); + LOG_TRACE("Ignore false preamble detection"); return false; } else { uint32_t maxPacketTimeMsec = getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader)); if (!Throttle::isWithinTimespanMs(activeReceiveStart, maxPacketTimeMsec)) { // We should have gotten an RX_DONE IRQ by now if it was really a packet, so ignore HEADER_VALID flag activeReceiveStart = 0; - LOG_DEBUG("Ignore false header detection"); + LOG_TRACE("Ignore false header detection"); return false; } } @@ -187,7 +187,7 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) #ifndef LORA_DISABLE_SENDING printPacket("enqueue for send", p); - LOG_DEBUG("txGood=%d,txRelay=%d,rxGood=%d,rxBad=%d", txGood, txRelay, rxGood, rxBad); + LOG_TRACE("txGood=%d,txRelay=%d,rxGood=%d,rxBad=%d", txGood, txRelay, rxGood, rxBad); bool dropped = false; ErrorCode res = txQueue.enqueue(p, &dropped) ? ERRNO_OK : ERRNO_UNKNOWN; @@ -290,7 +290,7 @@ void RadioLibInterface::updateNoiseFloor() currentNoiseFloor = getAverageNoiseFloorInternal(); - LOG_DEBUG("Noise floor: %d dBm (samples: %d, latest: %d dBm)", currentNoiseFloor, getNoiseFloorSampleCountInternal(), rssi); + LOG_TRACE("Noise floor: %d dBm (samples: %d, latest: %d dBm)", currentNoiseFloor, getNoiseFloorSampleCountInternal(), rssi); } uint8_t RadioLibInterface::getNoiseFloorSampleCountInternal() const @@ -468,7 +468,7 @@ void RadioLibInterface::onNotify(uint32_t notification) txp = txQueue.dequeue(); assert(txp); startSend(txp); - LOG_DEBUG("%d packets in TX queue", txQueue.getMaxLen() - txQueue.getFree()); + LOG_TRACE("%d packets in TX queue", txQueue.getMaxLen() - txQueue.getFree()); } } } @@ -505,7 +505,7 @@ void RadioLibInterface::setTransmitDelay() startTransmitTimer(true); } else { // If there is a SNR, start a timer scaled based on that SNR. - LOG_DEBUG("rx_snr found. hop_limit:%d rx_snr:%f", p->hop_limit, p->rx_snr); + LOG_TRACE("rx_snr found. hop_limit:%d rx_snr:%f", p->hop_limit, p->rx_snr); startTransmitTimerRebroadcast(p); } } @@ -539,7 +539,7 @@ void RadioLibInterface::clampToLateRebroadcastWindow(NodeNum from, PacketId id) p->tx_after = millis() + getTxDelayMsecWeightedWorst(p->rx_snr); bool dropped = false; if (txQueue.enqueue(p, &dropped)) { - LOG_DEBUG("Move queued packet to late rebroadcast window %dms from now", p->tx_after - millis()); + LOG_TRACE("Move queued packet to late rebroadcast window %ums from now", (uint32_t)(p->tx_after - millis())); } else { packetPool.release(p); } diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 5e33db235..3a938e03c 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -311,7 +311,7 @@ PacketId generatePacketId() rollingPacketId &= ID_COUNTER_MASK; // Mask out the top 22 bits PacketId id = rollingPacketId | random(UINT32_MAX & 0x7fffffff) << 10; // top 22 bits - LOG_DEBUG("Partially randomized packet id %u", id); + LOG_TRACE("Partially randomized packet id 0x%08x", id); return id; } @@ -408,7 +408,7 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) ChannelIndex chIndex = getEffectiveChannelIndex(p); if (chIndex) { p->channel = chIndex; - LOG_DEBUG("localSend to channel %d", p->channel); + LOG_TRACE("localSend to channel %d", p->channel); } } @@ -701,7 +701,7 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) if (!node) return false; nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); - LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from); + LOG_TRACE("Verified XEdDSA signature from 0x%08x", p->from); } else { LOG_WARN("XEdDSA signature verify failed from 0x%08x, drop", p->from); return false; @@ -884,7 +884,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) licensedPkiCandidate = true; } else if (pkiCandidate) { pkiAttempted = true; - LOG_DEBUG("Attempt PKI decryption"); + LOG_TRACE("Attempt PKI decryption"); // Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel // packet: copyPublicKeyForDecrypt() can fall through to a linear scan of TrafficManagement's large // NodeInfo cache. It returns authoritative keys (hot/warm), or a cold-tier cache key only when it is @@ -1146,7 +1146,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) { p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; - LOG_DEBUG("XEdDSA signed packet 0x%08x", p->id); + LOG_TRACE("XEdDSA signed packet 0x%08x", p->id); } } #endif diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index e8d5baf10..750ebbbef 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -336,7 +336,7 @@ template void SX126xInterface::addReceiveMetadata(meshtastic_Mes 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()); + LOG_TRACE("Corrected frequency offset: %f", lora.getFrequencyError()); } /** We override to turn on transmitter power as needed. diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 296798923..80bb79903 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -151,7 +151,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Ignore admin response from 0x%08x, no outstanding request", mp.from); return handled; } - LOG_DEBUG("Allow admin response message"); + LOG_TRACE("Allow admin response message"); } else if (mp.from == 0) { // Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the // mesh: RF drops packets without a sender (RadioLibInterface) and MQTT treats @@ -2168,7 +2168,7 @@ bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r) void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent) { - LOG_DEBUG("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code, + LOG_TRACE("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code, inputEvent.kb_char, inputEvent.touch_x, inputEvent.touch_y); // Create InputEvent for injection. diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 02f94b22a..001301275 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -112,7 +112,7 @@ void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChan e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; notifyObservers(&e); - LOG_DEBUG("[CannedMessage] LaunchWithDestination dest=0x%08x ch=%d", dest, channel); + LOG_TRACE("[CannedMessage] LaunchWithDestination dest=0x%08x ch=%d", dest, channel); } void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t newChannel) @@ -135,7 +135,7 @@ void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; notifyObservers(&e); - LOG_DEBUG("[CannedMessage] LaunchFreetextWithDestination dest=0x%08x ch=%d", dest, channel); + LOG_TRACE("[CannedMessage] LaunchFreetextWithDestination dest=0x%08x ch=%d", dest, channel); } static bool returnToCannedList = false; @@ -891,8 +891,8 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event) // Confirm select (Enter) bool isSelect = isSelectEvent(event); if (isSelect) { - LOG_DEBUG("[SELECT] handleFreeTextInput: runState=%d, dest=%u, channel=%d, freetext='%s'", (int)runState, dest, channel, - freetext.c_str()); + LOG_TRACE("[SELECT] handleFreeTextInput: runState=%d, dest=0x%08x, channel=%d, freetext='%s'", (int)runState, dest, + channel, freetext.c_str()); if (dest == 0) dest = NODENUM_BROADCAST; // Defensive: If channel isn't valid, pick the first available channel @@ -2336,7 +2336,6 @@ AdminMessageHandleResult CannedMessageModule::handleAdminMessageForModule(const void CannedMessageModule::handleGetCannedMessageModuleMessages(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response) { - LOG_DEBUG("*** handleGetCannedMessageModuleMessages"); if (req.decoded.want_response) { response->which_payload_variant = meshtastic_AdminMessage_get_canned_message_module_messages_response_tag; strncpy(response->get_canned_message_module_messages_response, cannedMessageModuleConfig.messages, @@ -2351,7 +2350,7 @@ void CannedMessageModule::handleSetCannedMessageModuleMessages(const char *from_ if (*from_msg) { changed |= strcmp(cannedMessageModuleConfig.messages, from_msg); strncpy(cannedMessageModuleConfig.messages, from_msg, sizeof(cannedMessageModuleConfig.messages)); - LOG_DEBUG("*** from_msg.text:%s", from_msg); + LOG_TRACE("*** from_msg.text:%s", from_msg); } if (changed) { diff --git a/src/modules/NeighborInfoModule.cpp b/src/modules/NeighborInfoModule.cpp index 3803c953e..a05b09b0f 100644 --- a/src/modules/NeighborInfoModule.cpp +++ b/src/modules/NeighborInfoModule.cpp @@ -14,11 +14,11 @@ NOTE: For debugging only */ void NeighborInfoModule::printNeighborInfo(const char *header, const meshtastic_NeighborInfo *np) { - LOG_DEBUG("%s NEIGHBORINFO PACKET from Node 0x%08x to Node 0x%08x (last sent by 0x%08x)", header, np->node_id, + LOG_TRACE("%s NEIGHBORINFO PACKET from Node 0x%08x to Node 0x%08x (last sent by 0x%08x)", header, np->node_id, nodeDB->getNodeNum(), np->last_sent_by_id); - LOG_DEBUG("Packet contains %d neighbors", np->neighbors_count); + LOG_TRACE("Packet contains %d neighbors", np->neighbors_count); for (int i = 0; i < np->neighbors_count; i++) { - LOG_DEBUG("Neighbor %d: node_id=0x%08x, snr=%.2f", i, np->neighbors[i].node_id, np->neighbors[i].snr); + LOG_TRACE("Neighbor %d: node_id=0x%08x, snr=%.2f", i, np->neighbors[i].node_id, np->neighbors[i].snr); } } @@ -28,9 +28,9 @@ NOTE: for debugging only */ void NeighborInfoModule::printNodeDBNeighbors() { - LOG_DEBUG("Our NodeDB contains %d neighbors", neighbors.size()); + LOG_TRACE("Our NodeDB contains %u neighbors", (unsigned)neighbors.size()); for (size_t i = 0; i < neighbors.size(); i++) { - LOG_DEBUG("Node %d: node_id=0x%08x, snr=%.2f", i, neighbors[i].node_id, neighbors[i].snr); + LOG_TRACE("Node %u: node_id=0x%08x, snr=%.2f", (unsigned)i, neighbors[i].node_id, neighbors[i].snr); } } @@ -162,18 +162,18 @@ Pass it to an upper client; do not persist this data on the mesh */ bool NeighborInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_NeighborInfo *np) { - LOG_DEBUG("NeighborInfo: handleReceivedProtobuf"); + LOG_TRACE("NeighborInfo: handleReceivedProtobuf"); if (np) { printNeighborInfo("RECEIVED", np); // Ignore dummy/interceptable packets: single neighbor with nodeId 0 and snr 0 if (np->neighbors_count != 1 || np->neighbors[0].node_id != 0 || np->neighbors[0].snr != 0.0f) { - LOG_DEBUG(" Updating neighbours"); + LOG_TRACE(" Updating neighbours"); updateNeighbors(mp, np); } else { LOG_DEBUG(" Ignoring dummy neighbor info packet (single neighbor with nodeId 0, snr 0)"); } } else if (getHopsAway(mp) == 0) { - LOG_DEBUG("Get or create neighbor: %u with snr %f", mp.from, mp.rx_snr); + LOG_TRACE("Get or create neighbor: 0x%08x with snr %f", mp.from, mp.rx_snr); // If the hopLimit is the same as hopStart, then it is a neighbor getOrCreateNeighbor(mp.from, mp.from, 0, mp.rx_snr); // Set the broadcast interval to 0, as we don't know it @@ -202,7 +202,6 @@ void NeighborInfoModule::resetNeighbors() void NeighborInfoModule::updateNeighbors(const meshtastic_MeshPacket &mp, const meshtastic_NeighborInfo *np) { - LOG_DEBUG("updateNeighbors"); // The last sent ID will be 0 if the packet is from the phone, which we don't // count as an edge. So we assume that if it's zero, then this packet is from // our node. diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 893cfd87c..9ee985b15 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -10,6 +10,7 @@ #include "TypeConversions.h" #include "airtime.h" #include "configuration.h" +#include "gps/GPSLog.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" #include "main.h" @@ -81,13 +82,13 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes nodeDB->setLocalPosition(p, true); return false; } else { - LOG_DEBUG("Incoming update from MYSELF"); + LOG_TRACE("Incoming update from MYSELF"); nodeDB->setLocalPosition(p); } } // Log packet size and data fields - LOG_DEBUG("POSITION node=0x%08x l=%d lat=%d lon=%d msl=%d hae=%d geo=%d pdop=%d hdop=%d vdop=%d siv=%d fxq=%d fxt=%d pts=%d " + LOG_TRACE("POSITION node=0x%08x l=%d lat=%d lon=%d msl=%d hae=%d geo=%d pdop=%d hdop=%d vdop=%d siv=%d fxq=%d fxt=%d pts=%d " "time=%d", getFrom(&mp), mp.decoded.payload.size, p.latitude_i, p.longitude_i, p.altitude, p.altitude_hae, p.altitude_geoidal_separation, p.PDOP, p.HDOP, p.VDOP, p.sats_in_view, p.fix_quality, p.fix_type, p.timestamp, @@ -363,7 +364,7 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli() memcpy(mp->decoded.payload.bytes + 1, protobuf_bytes, proto_size); mp->decoded.payload.size = proto_size + 1; - LOG_DEBUG("TAK V2 PLI payload: %zu bytes (1 flags + %zu protobuf)", mp->decoded.payload.size, proto_size); + LOG_TRACE("TAK V2 PLI payload: %zu bytes (1 flags + %zu protobuf)", mp->decoded.payload.size, proto_size); return mp; } @@ -557,9 +558,7 @@ int32_t PositionModule::runOnce() if (lastGpsSend == 0 || msSinceLastSend >= effectiveIntervalMs) { if (waitingForFreshPosition) { -#ifdef GPS_DEBUG - LOG_DEBUG("Skip initial position send; no fresh position since boot"); -#endif + LOG_DEBUG_GPS("Skip initial position send; no fresh position since boot"); } else if (nodeDB->hasValidPosition(node)) { lastGpsSend = now; @@ -591,11 +590,7 @@ int32_t PositionModule::runOnce() if (smartPosition.hasTraveledOverThreshold && Throttle::execute( &lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); }, - []() { -#ifdef GPS_DEBUG - LOG_DEBUG("Skip smart broadcast: time throttled"); -#endif - })) { + []() { LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); })) { LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " "minTimeInterval=%ims)", @@ -701,11 +696,7 @@ void PositionModule::handleNewPosition() if (smartPosition.hasTraveledOverThreshold && Throttle::execute( &lastGpsSend, minimumTimeThreshold, []() { positionModule->sendOurPosition(); }, - []() { -#ifdef GPS_DEBUG - LOG_DEBUG("Skip smart broadcast: time throttled"); -#endif - })) { + []() { LOG_DEBUG_GPS("Skip smart broadcast: time throttled"); })) { LOG_DEBUG("Sent smart pos@%x:6 to mesh (distanceTraveled=%fm, minDistanceThreshold=%im, timeElapsed=%ims, " "minTimeInterval=%ims)", localPosition.timestamp, smartPosition.distanceTraveled, smartPosition.distanceThreshold, msSinceLastSend, diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index 11a2cdc64..46475a3ae 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -99,7 +99,7 @@ int32_t RangeTestModule::runOnce() } } } else { - LOG_INFO("Range Test Module - Disabled"); + LOG_INFO("Range Test Module Disabled"); } #endif diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index 5418d6620..b67a18327 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -187,7 +187,7 @@ int32_t AirQualityTelemetryModule::runOnce() // - We can publish the data on the mesh shortly // - Or we can send it to the phone // TODO: This will need to be refurbished once we implement separate intervals - LOG_INFO("Waking up sensors"); + LOG_INFO("Waking sensors"); for (TelemetrySensor *sensor : sensors) { if (!sensor->canSleep()) { LOG_DEBUG("%s: no sleep support, skip", sensor->sensorName); @@ -207,7 +207,7 @@ int32_t AirQualityTelemetryModule::runOnce() } if (!sensor->isActive()) { - LOG_DEBUG("Waking up: %s", sensor->sensorName); + LOG_DEBUG("Waking %s", sensor->sensorName); if (awakeAheadOfTimeMs == 0) startAirQualityTelemetryCycle = millis(); awakeAheadOfTimeMs = max(awakeAheadOfTimeMs, sensor->wakeUpTimeMs()); diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index a3e588778..b00672d2d 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -312,7 +312,7 @@ bool PowerTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) LOG_WARN("Power telemetry unavailable this cycle, sleep without sending"); sleepOnNextExecution = true; preflightSleepDeferrals = 0; - LOG_DEBUG("Start next execution in 5s then sleep"); + LOG_DEBUG("Start next execution in 5s, then sleep"); setIntervalFromNow(FIVE_SECONDS_MS); } return validTelemetry; diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.cpp b/src/modules/Telemetry/Sensor/BME680Sensor.cpp index 5130e12be..e3badbe26 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.cpp +++ b/src/modules/Telemetry/Sensor/BME680Sensor.cpp @@ -136,7 +136,7 @@ void BME680Sensor::loadState() file.read((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE); file.close(); bme680.setState(bsecState); - LOG_INFO("%s state read from %s", sensorName, bsecConfigFileName); + LOG_INFO("%s: state read from %s", sensorName, bsecConfigFileName); } else { LOG_INFO("No %s state found (File: %s)", sensorName, bsecConfigFileName); } @@ -177,7 +177,7 @@ void BME680Sensor::updateState() } auto file = FSCom.open(bsecConfigFileName, FILE_O_WRITE); if (file) { - LOG_INFO("%s state write to %s", sensorName, bsecConfigFileName); + LOG_INFO("%s: state write to %s", sensorName, bsecConfigFileName); file.write((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE); file.flush(); file.close(); diff --git a/src/modules/Telemetry/Sensor/DS248XSensor.cpp b/src/modules/Telemetry/Sensor/DS248XSensor.cpp index f3158d432..d0e138552 100644 --- a/src/modules/Telemetry/Sensor/DS248XSensor.cpp +++ b/src/modules/Telemetry/Sensor/DS248XSensor.cpp @@ -63,8 +63,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef DS248X_I2C_CLOCK_SPEED reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, DS248X_I2C_CLOCK_SPEED); reClockI2C.setClock(DS248X_I2C_CLOCK_SPEED); #endif /* DS248X_I2C_CLOCK_SPEED */ @@ -176,7 +174,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } #ifdef DS248X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* DS248X_I2C_CLOCK_SPEED */ @@ -193,7 +190,6 @@ bool DS248XSensor::isValidROM(const uint8_t *rom) float DS248XSensor::readTemperatureROM(const uint8_t *rom) { #ifdef DS248X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: attempting to reclock speed to %uHz", sensorName, DS248X_I2C_CLOCK_SPEED); reClockI2C.setClock(DS248X_I2C_CLOCK_SPEED); #endif /* DS248X_I2C_CLOCK_SPEED */ @@ -224,7 +220,6 @@ float DS248XSensor::readTemperatureROM(const uint8_t *rom) } #ifdef DS248X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* DS248X_I2C_CLOCK_SPEED */ diff --git a/src/modules/Telemetry/Sensor/HM330XSensor.cpp b/src/modules/Telemetry/Sensor/HM330XSensor.cpp index 1d44cd133..20b2a5e66 100644 --- a/src/modules/Telemetry/Sensor/HM330XSensor.cpp +++ b/src/modules/Telemetry/Sensor/HM330XSensor.cpp @@ -17,14 +17,11 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef HM330X_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, HM330X_I2C_CLOCK_SPEED); reClockI2C.setClock(HM330X_I2C_CLOCK_SPEED); #endif /* HM330X_I2C_CLOCK_SPEED */ if (hm330x.init(_bus) != HM330XErrorCode::NO_ERROR) { #ifdef HM330X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* HM330X_I2C_CLOCK_SPEED */ LOG_WARN("%s error in sensor init", sensorName); @@ -32,7 +29,6 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } #ifdef HM330X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* HM330X_I2C_CLOCK_SPEED */ @@ -83,21 +79,18 @@ int32_t HM330XSensor::pendingForReadyMs() bool HM330XSensor::getMetrics(meshtastic_Telemetry *measurement) { #ifdef HM330X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: attempting to reclock speed to %uHz", sensorName, HM330X_I2C_CLOCK_SPEED); reClockI2C.setClock(HM330X_I2C_CLOCK_SPEED); #endif /* HM330X_I2C_CLOCK_SPEED */ if (hm330x.read_sensor_value(buffer, 29)) { LOG_WARN("%s: read result failed", sensorName); #ifdef HM330X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* HM330X_I2C_CLOCK_SPEED */ return false; } #ifdef HM330X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* HM330X_I2C_CLOCK_SPEED */ diff --git a/src/modules/Telemetry/Sensor/MAX17048Sensor.cpp b/src/modules/Telemetry/Sensor/MAX17048Sensor.cpp index 1a6792d3a..8e6406b80 100644 --- a/src/modules/Telemetry/Sensor/MAX17048Sensor.cpp +++ b/src/modules/Telemetry/Sensor/MAX17048Sensor.cpp @@ -53,7 +53,7 @@ bool MAX17048Singleton::isBatteryCharging() chargeState = MAX17048ChargeState::IDLE; } - LOG_DEBUG("%s::isBatteryCharging %s volts: %.3f soc: %.3f rate: %.3f", sensorStr, chargeLabels[chargeState], volts, + LOG_TRACE("%s::isBatteryCharging %s volts: %.3f soc: %.3f rate: %.3f", sensorStr, chargeLabels[chargeState], volts, sample.cellPercent, sample.chargeRate); return chargeState == MAX17048ChargeState::IMPORT; } @@ -65,14 +65,14 @@ uint16_t MAX17048Singleton::getBusVoltageMv() LOG_DEBUG("%s::getBusVoltageMv is not connected", sensorStr); return 0; } - LOG_DEBUG("%s::getBusVoltageMv %.3fmV", sensorStr, volts); + LOG_TRACE("%s::getBusVoltageMv %.3fmV", sensorStr, volts); return (uint16_t)(volts * 1000.0f); } uint8_t MAX17048Singleton::getBusBatteryPercent() { float soc = cellPercent(); - LOG_DEBUG("%s::getBusBatteryPercent %.1f%%", sensorStr, soc); + LOG_TRACE("%s::getBusBatteryPercent %.1f%%", sensorStr, soc); return clamp(static_cast(round(soc)), static_cast(0), static_cast(100)); } @@ -82,7 +82,7 @@ uint16_t MAX17048Singleton::getTimeToGoSecs() float soc = cellPercent(); // state of charge in percent 0 to 100 soc = clamp(soc, 0.0f, 100.0f); // clamp soc between 0 and 100% float ttg = ((100.0f - soc) / rate) * 3600.0f; // calculate seconds to charge/discharge - LOG_DEBUG("%s::getTimeToGoSecs %.0f seconds", sensorStr, ttg); + LOG_TRACE("%s::getTimeToGoSecs %.0f seconds", sensorStr, ttg); return (uint16_t)ttg; } @@ -108,7 +108,7 @@ bool MAX17048Singleton::isExternallyPowered() } // if the bus voltage is over MAX17048_BUS_POWER_VOLTS, then the external power // is assumed to be connected - LOG_DEBUG("%s::isExternallyPowered %s connected", sensorStr, volts >= MAX17048_BUS_POWER_VOLTS ? "is" : "is not"); + LOG_TRACE("%s::isExternallyPowered %s connected", sensorStr, volts >= MAX17048_BUS_POWER_VOLTS ? "is" : "is not"); return volts >= MAX17048_BUS_POWER_VOLTS; } @@ -140,7 +140,7 @@ void MAX17048Sensor::setup() {} bool MAX17048Sensor::getMetrics(meshtastic_Telemetry *measurement) { - LOG_DEBUG("MAX17048 getMetrics id: %i", measurement->which_variant); + LOG_TRACE("MAX17048 getMetrics id: %i", measurement->which_variant); float volts = max17048->cellVoltage(); if (isnan(volts)) { diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp index 7fae87b98..c36605773 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp @@ -24,8 +24,6 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef PMSA003I_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: attempting to reclock speed to %uHz", sensorName, PMSA003I_I2C_CLOCK_SPEED); reClockI2C.setClock(PMSA003I_I2C_CLOCK_SPEED); #endif /* PMSA003I_I2C_CLOCK_SPEED */ @@ -33,7 +31,6 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (_bus->endTransmission() != 0) { LOG_WARN("%s not found on I2C at 0x12", sensorName); #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* PMSA003I_I2C_CLOCK_SPEED */ sleep(); @@ -41,7 +38,6 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* PMSA003I_I2C_CLOCK_SPEED */ @@ -61,7 +57,6 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) } #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_DEBUG("%s: attempting to reclock speed to %uHz", sensorName, PMSA003I_I2C_CLOCK_SPEED); reClockI2C.setClock(PMSA003I_I2C_CLOCK_SPEED); #endif /* PMSA003I_I2C_CLOCK_SPEED */ @@ -69,7 +64,6 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) if (_bus->available() < PMSA003I_FRAME_LENGTH) { LOG_WARN("%s: read failed: incomplete data (%d bytes)", sensorName, _bus->available()); #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* PMSA003I_I2C_CLOCK_SPEED */ return false; @@ -80,7 +74,6 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) } #ifdef PMSA003I_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* PMSA003I_I2C_CLOCK_SPEED */ @@ -199,7 +192,7 @@ void PMSA003ISensor::sleep() uint32_t PMSA003ISensor::wakeUp() { #ifdef PMSA003I_ENABLE_PIN - LOG_INFO("%s: Waking up", sensorName); + LOG_INFO("%s Waking", sensorName); digitalWrite(PMSA003I_ENABLE_PIN, HIGH); state = PMSA003I_ACTIVE; pmMeasureStarted = getTime(); diff --git a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp index 79819a9ce..c380f0f42 100644 --- a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp @@ -18,8 +18,6 @@ bool SCD30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SCD30_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: reclock to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ @@ -28,7 +26,6 @@ bool SCD30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (!startMeasurement()) { LOG_ERROR("%s: Periodic measurement start failed", sensorName); #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ return false; @@ -39,7 +36,6 @@ bool SCD30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ @@ -59,21 +55,18 @@ bool SCD30Sensor::getMetrics(meshtastic_Telemetry *measurement) float co2, temperature, humidity; #ifdef SCD30_I2C_CLOCK_SPEED - LOG_DEBUG("%s: reclock to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ if (scd30.readMeasurementData(co2, temperature, humidity) != SCD30_NO_ERROR) { LOG_ERROR("%s: Measurement read failed", sensorName); #ifdef SCD30_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ return false; } #ifdef SCD30_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ @@ -366,14 +359,12 @@ bool SCD30Sensor::isActive() uint32_t SCD30Sensor::wakeUp() { #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: reclock to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ startMeasurement(); #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ @@ -387,14 +378,12 @@ uint32_t SCD30Sensor::wakeUp() void SCD30Sensor::sleep() { #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: reclock to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ stopMeasurement(); #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ } @@ -420,7 +409,6 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa AdminMessageHandleResult result; #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: reclock to %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ @@ -478,7 +466,6 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa } #ifdef SCD30_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD30_I2C_CLOCK_SPEED */ diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp index 4a4388113..7c6bc3ecf 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp @@ -19,8 +19,6 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SCD4X_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s: reclock to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -32,7 +30,6 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) // Stop periodic measurement if (!stopMeasurement()) { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; @@ -46,7 +43,6 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (!powerUp()) { LOG_ERROR("%s: powerUp() failed", sensorName); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; @@ -56,7 +52,6 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (!getASC(ascActive)) { LOG_ERROR("%s: Can't check if ASC enabled", sensorName); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; @@ -66,14 +61,12 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (!startMeasurement()) { LOG_ERROR("%s: Can't start measurement", sensorName); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; } #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -100,7 +93,6 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) float temperature, humidity; #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: reclock to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -118,7 +110,6 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) if (error != SCD4X_NO_ERROR || !dataReady) { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ LOG_ERROR("SCD4X: Data is not ready"); @@ -128,7 +119,6 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) error = scd4x.readMeasurement(co2, temperature, humidity); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -290,7 +280,7 @@ bool SCD4XSensor::getASC(uint16_t &_ascActive) return false; } - LOG_INFO("%s ASC is %s", sensorName, _ascActive ? "enabled" : "disabled"); + LOG_INFO("%s: ASC is %s", sensorName, _ascActive ? "enabled" : "disabled"); return true; } @@ -308,7 +298,7 @@ bool SCD4XSensor::setASC(bool ascEnabled) { uint16_t error; - LOG_INFO("%s %s ASC", sensorName, ascEnabled ? "Enabling" : "Disabling"); + LOG_INFO("%s: %s ASC", sensorName, ascEnabled ? "Enabling" : "Disabling"); if (!stopMeasurement()) { return false; @@ -644,13 +634,11 @@ bool SCD4XSensor::powerDown() } #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: reclock to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ if (!stopMeasurement()) { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; @@ -659,14 +647,12 @@ bool SCD4XSensor::powerDown() if (scd4x.powerDown() != SCD4X_NO_ERROR) { LOG_ERROR("%s: sleep() failed", sensorName); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return false; } #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -687,7 +673,7 @@ bool SCD4XSensor::powerDown() */ bool SCD4XSensor::powerUp() { - LOG_INFO("%s: Waking up", sensorName); + LOG_INFO("%s Waking", sensorName); if (scd4x.wakeUp() != SCD4X_NO_ERROR) { LOG_ERROR("%s: wakeUp() failed", sensorName); @@ -715,21 +701,18 @@ uint32_t SCD4XSensor::wakeUp() { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: reclock to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ if (startMeasurement()) { co2MeasureStarted = getTime(); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ return SCD4X_WARMUP_MS; } #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -743,14 +726,12 @@ uint32_t SCD4XSensor::wakeUp() void SCD4XSensor::sleep() { #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: reclock to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ stopMeasurement(); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ } @@ -792,7 +773,6 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa AdminMessageHandleResult result; #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: reclock to %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -897,7 +877,6 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa this->startMeasurement(); #ifdef SCD4X_I2C_CLOCK_SPEED - LOG_INFO("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp index 02105e137..7a721433c 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp @@ -132,7 +132,6 @@ bool SEN5XSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNum } #ifdef SEN5X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: Reclock to %uHz", sensorName, SEN5X_I2C_CLOCK_SPEED); reClockI2C.setClock(SEN5X_I2C_CLOCK_SPEED); #endif /* SEN5X_I2C_CLOCK_SPEED */ @@ -145,7 +144,6 @@ bool SEN5XSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNum uint8_t i2c_error = _bus->endTransmission(); #ifdef SEN5X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SEN5X_I2C_CLOCK_SPEED */ @@ -164,7 +162,6 @@ bool SEN5XSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNum uint8_t SEN5XSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) { #ifdef SEN5X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: Reclock to %uHz", sensorName, SEN5X_I2C_CLOCK_SPEED); reClockI2C.setClock(SEN5X_I2C_CLOCK_SPEED); #endif /* SEN5X_I2C_CLOCK_SPEED */ @@ -172,7 +169,6 @@ uint8_t SEN5XSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) if (readBytes != byteNumber) { LOG_ERROR("%s: Error reading I2C bus", sensorName); #ifdef SEN5X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SEN5X_I2C_CLOCK_SPEED */ return 0; @@ -188,7 +184,6 @@ uint8_t SEN5XSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) if (recvCRC != calcCRC) { LOG_ERROR("%s: Checksum error receiving msg", sensorName); #ifdef SEN5X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SEN5X_I2C_CLOCK_SPEED */ return 0; @@ -198,7 +193,6 @@ uint8_t SEN5XSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) } #ifdef SEN5X_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SEN5X_I2C_CLOCK_SPEED */ @@ -489,7 +483,7 @@ bool SEN5XSensor::isActive() uint32_t SEN5XSensor::wakeUp() { - LOG_DEBUG("%s: Waking up sensor", sensorName); + LOG_TRACE("%s Waking", sensorName); if (!sendCommand(SEN5X_START_MEASUREMENT)) { LOG_ERROR("%s: Error starting measurement", sensorName); @@ -513,7 +507,7 @@ bool SEN5XSensor::vocStateStable() uint32_t now; now = getTime(); uint32_t sinceFirstMeasureStarted = (now - rhtGasMeasureStarted); - LOG_DEBUG("%s: sinceFirstMeasureStarted: %us", sensorName, sinceFirstMeasureStarted); + LOG_TRACE("%s: sinceFirstMeasureStarted: %us", sensorName, sinceFirstMeasureStarted); return sinceFirstMeasureStarted > SEN5X_VOC_STATE_WARMUP_S; } @@ -661,7 +655,7 @@ bool SEN5XSensor::readValues() LOG_ERROR("%s: Error sending read command", sensorName); return false; } - LOG_DEBUG("%s: Reading PM Values", sensorName); + LOG_TRACE("%s: Reading PM Values", sensorName); delay(20); // From Sensirion Datasheet uint8_t dataBuffer[16]{}; @@ -692,16 +686,16 @@ bool SEN5XSensor::readValues() sen5xmeasurement.vocIndex = !isnan(int_vocIndex) ? int_vocIndex / 10.0f : FLT_MAX; sen5xmeasurement.noxIndex = !isnan(int_noxIndex) ? int_noxIndex / 10.0f : FLT_MAX; - LOG_DEBUG("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, sen5xmeasurement.pM1p0, + LOG_TRACE("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, sen5xmeasurement.pM1p0, sen5xmeasurement.pM2p5, sen5xmeasurement.pM4p0, sen5xmeasurement.pM10p0); if (model != SEN50) { - LOG_DEBUG("%s: Got readings: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", sensorName, sen5xmeasurement.humidity, + LOG_TRACE("%s: Got readings: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", sensorName, sen5xmeasurement.humidity, sen5xmeasurement.temperature, sen5xmeasurement.vocIndex); } if (model == SEN55) { - LOG_DEBUG("%s: Got readings: noxIndex=%.2f", sensorName, sen5xmeasurement.noxIndex); + LOG_TRACE("%s: Got readings: noxIndex=%.2f", sensorName, sen5xmeasurement.noxIndex); } return true; @@ -714,7 +708,7 @@ bool SEN5XSensor::readPNValues(bool cumulative) return false; } - LOG_DEBUG("%s: Reading PN Values", sensorName); + LOG_TRACE("%s: Reading PN Values", sensorName); delay(20); // From Sensirion Datasheet uint8_t dataBuffer[20]{}; @@ -754,7 +748,7 @@ bool SEN5XSensor::readPNValues(bool cumulative) sen5xmeasurement.pN1p0 -= sen5xmeasurement.pN0p5; } - LOG_DEBUG("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", sensorName, + LOG_TRACE("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", sensorName, sen5xmeasurement.pN0p5, sen5xmeasurement.pN1p0, sen5xmeasurement.pN2p5, sen5xmeasurement.pN4p0, sen5xmeasurement.pN10p0, sen5xmeasurement.tSize); @@ -813,7 +807,7 @@ int32_t SEN5XSensor::pendingForReadyMs() uint32_t now; now = getTime(); uint32_t sincePmMeasureStarted = (now - pmMeasureStarted) * 1000; - LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); + LOG_TRACE("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); switch (state) { case SEN5X_MEASUREMENT: { @@ -857,7 +851,7 @@ bool SEN5XSensor::getMetrics(meshtastic_Telemetry *measurement) { LOG_INFO("%s: Get metrics", sensorName); if (!isActive()) { - LOG_INFO("%s: not in measurement mode", sensorName); + LOG_INFO("%s: Not in measurement mode", sensorName); return false; } diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp index 5befb4474..aa19baf0f 100644 --- a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp @@ -17,8 +17,6 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SFA30_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - - LOG_INFO("%s attempting to reclock speed to %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ @@ -27,7 +25,6 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (this->isError(sfa30.deviceReset())) { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_INFO("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ return false; @@ -36,7 +33,6 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) state = State::IDLE; if (this->isError(sfa30.startContinuousMeasurement())) { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_INFO("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ return false; @@ -45,14 +41,13 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) LOG_INFO("%s starting measurement", sensorName); #ifdef SFA30_I2C_CLOCK_SPEED - LOG_INFO("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ status = 1; state = State::ACTIVE; measureStarted = getTime(); - LOG_INFO("%s Enabled", sensorName); + LOG_INFO("%s: Enabled", sensorName); initI2CSensor(); return true; @@ -71,17 +66,15 @@ bool SFA30Sensor::isError(uint16_t response) void SFA30Sensor::sleep() { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s attempting to reclock speed to %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ // Note - not recommended for this sensor on a periodic basis if (this->isError(sfa30.stopMeasurement())) { - LOG_ERROR("%s: can't stop measurement", sensorName); + LOG_ERROR("%s: Can't stop measurement", sensorName); }; #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ @@ -93,21 +86,18 @@ void SFA30Sensor::sleep() uint32_t SFA30Sensor::wakeUp() { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s attempting to reclock speed to %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ - LOG_DEBUG("Waking up %s", sensorName); + LOG_DEBUG("Waking %s", sensorName); if (this->isError(sfa30.startContinuousMeasurement())) { #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ return 0; } #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ @@ -154,21 +144,18 @@ bool SFA30Sensor::getMetrics(meshtastic_Telemetry *measurement) float temperature = 0.0; #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s attempting to reclock speed to %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ if (this->isError(sfa30.readMeasuredValues(hcho, humidity, temperature))) { LOG_WARN("%s: No values", sensorName); #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ return false; } #ifdef SFA30_I2C_CLOCK_SPEED - LOG_DEBUG("%s restoring clock speed", sensorName); reClockI2C.restoreClock(); #endif /* SFA30_I2C_CLOCK_SPEED */ diff --git a/src/modules/Telemetry/Sensor/SHTXXSensor.cpp b/src/modules/Telemetry/Sensor/SHTXXSensor.cpp index 92cac7f77..1512e7cc8 100644 --- a/src/modules/Telemetry/Sensor/SHTXXSensor.cpp +++ b/src/modules/Telemetry/Sensor/SHTXXSensor.cpp @@ -50,12 +50,12 @@ bool SHTXXSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) _address = dev->address.address; if (sht.init(*_bus)) { - LOG_INFO("%s: init(): success", sensorName); + LOG_INFO("%s init success", sensorName); getSensorVariant(sht.mSensorType); LOG_INFO("%s Sensor detected: %s on 0x%x", sensorName, sensorVariant, _address); status = 1; } else { - LOG_ERROR("%s: init(): failed", sensorName); + LOG_ERROR("%s init failed", sensorName); } initI2CSensor(); diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 5c8f86eb0..b4c5fae98 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -19,6 +19,7 @@ #include #include +#define TM_LOG_TRACE(fmt, ...) LOG_TRACE("[TM] " fmt, ##__VA_ARGS__) #define TM_LOG_DEBUG(fmt, ...) LOG_DEBUG("[TM] " fmt, ##__VA_ARGS__) #define TM_LOG_INFO(fmt, ...) LOG_INFO("[TM] " fmt, ##__VA_ARGS__) #define TM_LOG_WARN(fmt, ...) LOG_WARN("[TM] " fmt, ##__VA_ARGS__) @@ -634,7 +635,7 @@ void TrafficManagementModule::maintainNodeInfoCacheLocked() // O(entries x members) every 60 s under cacheLock. The hourly reconcile pass // owns it (see reconcileNodeInfoFromNodeDBLocked). } - TM_LOG_DEBUG("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), + TM_LOG_TRACE("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoSaturated)); // Anti-entropy: seed identities NodeDB knows but this cache lacks - a full pass at @@ -1323,7 +1324,7 @@ int32_t TrafficManagementModule::runOnce() } } - TM_LOG_DEBUG("Maintenance: %u active, %u expired, %u/%u slots, %lums elapsed", activeEntries, expiredEntries, + TM_LOG_TRACE("Maintenance: %u active, %u expired, %u/%u slots, %lums elapsed", activeEntries, expiredEntries, static_cast(activeEntries), static_cast(cacheSize()), static_cast(TrafficManagementModule::clockMs() - sweepStartMs)); @@ -1400,7 +1401,7 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, const bool withinInterval = hasPositionState && (windowTicks != 0) && (static_cast(nowPosTick - entry->pos_time) < windowTicks); - TM_LOG_DEBUG("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, + TM_LOG_TRACE("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, entry->pos_fingerprint, samePosition, withinInterval, isNew); // Update cache entry (raw tick; 0 is a valid tick value) diff --git a/src/platform/portduino/SimRadio.cpp b/src/platform/portduino/SimRadio.cpp index c7aac40f3..d60c34db0 100644 --- a/src/platform/portduino/SimRadio.cpp +++ b/src/platform/portduino/SimRadio.cpp @@ -27,7 +27,7 @@ ErrorCode SimRadio::send(meshtastic_MeshPacket *p) // set (random) transmit delay to let others reconfigure their radio, // to avoid collisions and implement timing-based flooding - LOG_DEBUG("Set random delay before tx"); + LOG_TRACE("Set random delay before tx"); setTransmitDelay(); return res; } @@ -47,7 +47,7 @@ void SimRadio::setTransmitDelay() startTransmitTimer(true); } else { // If there is a SNR, start a timer scaled based on that SNR. - LOG_DEBUG("rx_snr found. hop_limit:%d rx_snr:%f", p->hop_limit, p->rx_snr); + LOG_TRACE("rx_snr found. hop_limit:%d rx_snr:%f", p->hop_limit, p->rx_snr); startTransmitTimerRebroadcast(p); } } @@ -169,7 +169,7 @@ void SimRadio::onNotify(uint32_t notification) startTransmitTimer(); break; } - LOG_DEBUG("delay done"); + LOG_TRACE("delay done"); // If we are not currently in receive mode, then restart the random delay (this can happen if the main thread // has placed the unit into standby) FIXME, how will this work if the chipset is in sleep mode? @@ -363,7 +363,7 @@ void SimRadio::handleReceiveInterrupt() return; } - LOG_DEBUG("HANDLE RECEIVE INTERRUPT"); + LOG_TRACE("HANDLE RECEIVE INTERRUPT"); rxGood++; meshtastic_MeshPacket *mp = packetPool.allocCopy(*receivingPacket); // keep a copy in packetPool diff --git a/variants/esp32/chatter2/variant.h b/variants/esp32/chatter2/variant.h index d13db08c6..0dc5cfaa9 100644 --- a/variants/esp32/chatter2/variant.h +++ b/variants/esp32/chatter2/variant.h @@ -5,7 +5,7 @@ ////////////////////////////////////////////////////////////////////////////////// // Debugging -// #define GPS_DEBUG +// #define GPS_DEBUG 1 // Lora #define USE_LLCC68 // Original Chatter2 with LLCC68 module diff --git a/variants/esp32/tbeam/variant.h b/variants/esp32/tbeam/variant.h index 1bab8c3c3..3d13f9cbd 100644 --- a/variants/esp32/tbeam/variant.h +++ b/variants/esp32/tbeam/variant.h @@ -43,7 +43,7 @@ #define GPS_UBLOX #define GPS_RX_PIN 34 #define GPS_TX_PIN 12 -// #define GPS_DEBUG +// #define GPS_DEBUG 1 // Used when the display shield is chosen #ifdef USE_ST7796 diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h index 323873660..5a6f0074e 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h @@ -135,7 +135,7 @@ https://github.com/brad112358/easy_E22 #endif #define GPS_UBLOX -// define GPS_DEBUG +// #define GPS_DEBUG 1 // UART interfaces #define PIN_SERIAL1_TX GPS_TX_PIN diff --git a/variants/nrf52840/dls_Minimesh_Lite/variant.h b/variants/nrf52840/dls_Minimesh_Lite/variant.h index 32c16f06d..47c6727dd 100644 --- a/variants/nrf52840/dls_Minimesh_Lite/variant.h +++ b/variants/nrf52840/dls_Minimesh_Lite/variant.h @@ -57,7 +57,7 @@ extern "C" { #define PIN_GPS_EN (0 + 24) #define GPS_UBLOX -// define GPS_DEBUG +// #define GPS_DEBUG 1 // UART interfaces #define PIN_SERIAL1_TX GPS_TX_PIN diff --git a/variants/nrf52840/seeed_wio_tracker_L1/variant.h b/variants/nrf52840/seeed_wio_tracker_L1/variant.h index 9e1df0fa3..aeaa8f44a 100644 --- a/variants/nrf52840/seeed_wio_tracker_L1/variant.h +++ b/variants/nrf52840/seeed_wio_tracker_L1/variant.h @@ -129,7 +129,7 @@ static const uint8_t SCL = PIN_WIRE_SCL; #define PIN_GPS_STANDBY D0 -// #define GPS_DEBUG +// #define GPS_DEBUG 1 // #define GPS_EN D18 // P1.05 #endif diff --git a/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h b/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h index 1ff18ec2f..9dd92a5f5 100644 --- a/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h +++ b/variants/nrf52840/seeed_wio_tracker_L1_eink/variant.h @@ -137,7 +137,7 @@ static const uint8_t SCL = PIN_WIRE_SCL; #define PIN_GPS_STANDBY D0 -// #define GPS_DEBUG +// #define GPS_DEBUG 1 // #define GPS_EN D18 // P1.05 #endif diff --git a/variants/nrf52840/t-echo-lite/variant.h b/variants/nrf52840/t-echo-lite/variant.h index 54c7bdfb5..fe2c3076c 100644 --- a/variants/nrf52840/t-echo-lite/variant.h +++ b/variants/nrf52840/t-echo-lite/variant.h @@ -131,7 +131,7 @@ static const uint8_t A0 = PIN_A0; #define PIN_SPI1_SCK PIN_EINK_SCLK // GPS pins -// #define GPS_DEBUG +// #define GPS_DEBUG 1 #define GPS_L76K #define GPS_BAUDRATE 9600 #define HAS_GPS 1 From ea1e6b88e01b89571f96a71ff71fc019a1b0eeba Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:59:08 +0000 Subject: [PATCH 023/109] Update meshtastic/device-ui digest to 7bfabe5 (#11421) 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 cd229fdfc..3f562ab33 100644 --- a/platformio.ini +++ b/platformio.ini @@ -137,7 +137,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/6e5e3b69a20020dffe16384f690daa2080e1b047.zip + https://github.com/meshtastic/device-ui/archive/7bfabe5e9ba468b4f82a15bae69e6b068ca124f0.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From f960c84f5bf73894fcfd67ba7e201fa3e8d590be Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Wed, 12 Aug 2026 14:29:52 +0800 Subject: [PATCH 024/109] fix(stm32wl): reset instead of hanging on faults (#11420) * fix(stm32wl): reset instead of hanging on faults Reset instead of hanging forever on three unrecoverable faults, each of which previously required a manual power cycle to recover: - HardFault_Handler_C: blinked SOS forever with no debugger attached; now resets once the fault registers are printed. - __wrap___assert_func: silently hung on an assert failure; now prints file/line/func/expr via debug_printf, then resets. - earlyBootCheck: silently hung if the jump into the bootloader ROM failed to take; now calls the bare NVIC_SystemReset(), not the HAL wrapper, since it runs pre-HAL_Init() and MSP/VTOR are already repointed at the bootloader by this point, so a return would unwind through a corrupted stack frame. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong * refactor(stm32wl): group fault-handling code Group the fault-handling code together and drop incidental cruft: - Move __wrap___assert_func next to HardFault_Handler_C and the other fault-reporting helpers. - Add banner comments separating linker-hack wrappers from fault-handling/recovery code, matching the existing Bootloader redirect banner. - Drop the forward declaration for debug_printf, no longer needed now that __wrap___assert_func sits below its definition. - Trim the Bootloader redirect banner comment to 1-3 lines. No behavior change. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong --------- Signed-off-by: Andrew Yong --- src/platform/stm32wl/main-stm32wl.cpp | 80 ++++++--------------------- 1 file changed, 16 insertions(+), 64 deletions(-) diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index a2fed8357..50f7ae3d8 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -18,20 +18,9 @@ static bool stm32wlRtcValid = false; #endif // ─── Bootloader redirect ────────────────────────────────────────────────────── -// -// Why .noinit + constructor instead of TAMP backup registers: -// -// The STM32duino startup sequence initialises clocks which may call -// __HAL_RCC_BACKUPRESET_FORCE/RELEASE when configuring the LSE oscillator, -// wiping the entire backup domain (including TAMP->BKP0R) before setup() -// ever runs. The backup-register approach therefore cannot reliably survive -// a soft reset in this toolchain. -// -// Solution: store the magic in a .noinit SRAM variable. -// - NVIC_SystemReset() does NOT clear SRAM. -// - The linker script skips zero-init for .noinit sections. -// - __attribute__((constructor)) fires before main()/HAL_Init(), so we can -// intercept and jump before anything disturbs peripheral state. +// Uses .noinit SRAM instead of TAMP backup registers: STM32duino's clock init can wipe the +// backup domain via __HAL_RCC_BACKUPRESET_FORCE/RELEASE before setup() runs, but .noinit +// survives NVIC_SystemReset() and this constructor fires before HAL_Init() touches anything. #define BOOTLOADER_MAGIC 0xD00DB007UL #define SYS_MEM_BASE 0x1FFF0000UL @@ -58,8 +47,10 @@ __attribute__((constructor(101), used)) static void earlyBootCheck(void) SCB->VTOR = SYS_MEM_BASE; __set_MSP(*(volatile uint32_t *)SYS_MEM_BASE); ((void (*)(void))(*(volatile uint32_t *)(SYS_MEM_BASE + 4)))(); - while (1) - ; + // Should never be reached: the bootloader ROM does not return. A bare reset + // (rather than returning normally) avoids unwinding through this function's + // epilogue, which would restore registers relative to the now-repointed MSP. + NVIC_SystemReset(); } void enterDfuMode() @@ -169,15 +160,7 @@ void cpuDeepSleep(uint32_t msecToWake) #endif } -// Hacks to force more code and data out. - -// By default __assert_func uses fiprintf which pulls in stdio. -extern "C" void __wrap___assert_func(const char *, int, const char *, const char *) -{ - while (true) - ; - return; -} +// ─── Linker hacks to reduce code size ───────────────────────────────────────── // By default strerror has a lot of strings we probably don't use. Make it return an empty string instead. char empty = 0; @@ -197,6 +180,8 @@ extern "C" void __wrap__tzset_unlocked_r(struct _reent *reent_ptr) } #endif +// ─── Fault handling & recovery ──────────────────────────────────────────────── + // Taken from https://interrupt.memfault.com/blog/cortex-m-hardfault-debug typedef struct __attribute__((packed)) ContextStateFrame { uint32_t r0; @@ -233,32 +218,11 @@ static void debug_printf(const char *format, ...) uart_debug_write((uint8_t *)hardfault_message_buffer, min((unsigned int)length, sizeof(hardfault_message_buffer) - 1)); } -// N picked by guessing -#define DOT_TIME 1200000 -static void dot() +// By default __assert_func uses fiprintf which pulls in stdio. +extern "C" void __wrap___assert_func(const char *file, int line, const char *func, const char *failedexpr) { - digitalWrite(LED_POWER, LED_STATE_ON); - for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ - } - digitalWrite(LED_POWER, LED_STATE_OFF); - for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ - } -} - -static void dash() -{ - digitalWrite(LED_POWER, LED_STATE_ON); - for (volatile int i = 0; i < (DOT_TIME * 3); i++) { /* busy wait */ - } - digitalWrite(LED_POWER, LED_STATE_OFF); - for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ - } -} - -static void space() -{ - for (volatile int i = 0; i < (DOT_TIME * 3); i++) { /* busy wait */ - } + debug_printf("assert: %s:%d in %s: %s\r\n", file, line, func, failedexpr); + HAL_NVIC_SystemReset(); } // Disable optimizations for this function so "frame" argument @@ -277,17 +241,5 @@ extern "C" __attribute__((optimize("O0"))) void HardFault_Handler_C(sContextStat HALT_IF_DEBUGGING(); - // blink SOS forever - while (1) { - dot(); - dot(); - dot(); - dash(); - dash(); - dash(); - dot(); - dot(); - dot(); - space(); - } -} \ No newline at end of file + HAL_NVIC_SystemReset(); +} From b68de08c6bd5f74607324e1bf2010cb240519884 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:34:44 +0200 Subject: [PATCH 025/109] chore(deps): update esp32-ch390 to v1.1.1 (#11398) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini | 2 +- variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini index f3d5e0f3b..92236f638 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-G3/platformio.ini @@ -28,4 +28,4 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 - https://github.com/meshtastic/ESP32-CH390/archive/refs/tags/v1.1.0.zip + https://github.com/meshtastic/ESP32-CH390/archive/v1.1.1.zip diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini index bfa69ab6c..c1a872f89 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/platformio.ini @@ -28,4 +28,4 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=github-tags depName=ESP32-CH390 packageName=meshtastic/ESP32-CH390 - https://github.com/meshtastic/ESP32-CH390/archive/refs/tags/v1.1.0.zip + https://github.com/meshtastic/ESP32-CH390/archive/v1.1.1.zip From d5d5bad97cdca77da4ad77fd5fde9f84bba34d14 Mon Sep 17 00:00:00 2001 From: Michael Mohr Date: Tue, 11 Aug 2026 23:56:46 -0700 Subject: [PATCH 026/109] SEN5X: fix version parsing, VOC index reporting, and read-buffer handling (#11114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * SEN5X: validate read lengths and initialize read buffers readBuffer() returns the number of data bytes written (0 on error). Check the return value against the number of bytes each caller consumes before parsing, and zero-initialize the destination buffers: findModel (5), getMeasurements (2), readValues (16), readPNValues (20), and vocStateFromSensor (SEN5X_VOC_STATE_BUFFER_SIZE). This also resolves maybe-uninitialized compiler warnings. Small simplifications in the same area: - Assign the converted measurement values directly; the isnan() checks on integer intermediates always took the conversion branch, so this preserves behavior. - Fold a redundant state comparison in wakeUp() that immediately followed the assignment of the same value. - Add an explicit 'return false' to the non-FSCom branches of loadState() and saveState(). Co-Authored-By: Claude Fable 5 * SEN5X: correct version parsing, VOC index gating, and cleaning wait - getVersion() requested 3 raw I2C bytes (2 data bytes) but parsed versionBuffer[0..6], so the hardware and protocol versions came from the buffer's initialized-but-unwritten tail. Request the full 12-byte reply (8 data bytes, the layout used by Sensirion's embedded-i2c-sen5x driver) and validate the received length before parsing. Also make the error message specific to the version read. - Use floating-point division when deriving major.minor version numbers so minor versions below 10 are preserved (integer division reported e.g. firmware 2.2 as 2.00). - Gate pm_voc_idx on vocIndex rather than noxIndex, so SEN54 devices (VOC but no NOx) report their VOC index. - Widen the millis() snapshot in startCleaning() to uint32_t so the 10-second fan-cleaning wait always measures elapsed time correctly. Co-Authored-By: Claude Fable 5 * SEN5X: add size checks to I2C helpers, stage VOC state, handle unavailable readings - readBuffer(): accept only request sizes that are a multiple of 3 (2 data bytes + 1 CRC per group), keeping the read loop's size arithmetic in bounds for any future caller. Current callers all comply. - sendCommand(): likewise accept only even payload sizes on the write side. - vocStateFromSensor(): read into a staging buffer and copy to vocState only after the full transfer verifies, so the stored state stays consistent if a read fails partway through. - readValues()/readPNValues(): the sensor reports unavailable values as 0xFFFF (unsigned) / 0x7FFF (signed); map these to the UINT16_MAX / UINT32_MAX / FLT_MAX sentinels that getMetrics() checks, so unavailable channels are omitted from telemetry rather than scaled into numeric readings. Guard the cumulative-to-binned PN subtraction so the sentinels are preserved. - readPNValues(): convert #/cm3 to #/0.1l as raw * 10, retaining the 0.1-resolution digit that dividing before multiplying discarded. Co-Authored-By: Claude Fable 5 * SEN5X: size read buffers in data bytes and document I2C helper conventions readBuffer()'s size parameter is the raw I2C transfer size including CRC bytes, while only the verified data bytes (2/3 of the request) are written to the destination. Two call sites sized their buffers in raw units (findModel: 48 for 32 data bytes; getMeasurements: 3 for 2); both were safe over-allocations. Size them in data bytes so every call site reflects the same convention, and document the raw-vs-data contracts on the readBuffer() and sendCommand() declarations. No functional change. Co-Authored-By: Claude Fable 5 * SEN5X: use named defines for I2C reply buffer sizes Follow the SEN5X_VOC_STATE_BUFFER_SIZE pattern for all reply reads, per review feedback: define each reply's payload size in data bytes, size the destination buffer with it, request + / 2 raw bytes, and compare the received count against the same define. The version and product-name guards now compare against the full reply size rather than the bytes parsed (previously 7 and 5); readBuffer() returns either 0 or the full data count, so the conditions accept and reject the same transfers. Co-Authored-By: Claude Fable 5 * SEN5X: document I2C helper size requirements instead of checking at runtime Per review feedback: drop the runtime even-size and multiple-of-3 checks from sendCommand()/readBuffer() and state the requirements in @brief/@param documentation on the declarations. All callers pass sizes derived from the SEN5X_*_BUFFER_SIZE defines, which satisfy both requirements. Co-Authored-By: Claude Fable 5 * SEN5X: name the sensor's invalid-value constants Per review feedback, define SEN5X_UINT_INVALID (0xFFFF) and SEN5X_INT_INVALID (0x7FFF) for the values the sensor reports when a reading is unavailable, and use them in the readValues()/readPNValues() conversions in place of the numeric literals. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: oscgonfer Co-authored-by: Thomas Göttgens --- src/modules/Telemetry/Sensor/SEN5XSensor.cpp | 114 +++++++++++-------- src/modules/Telemetry/Sensor/SEN5XSensor.h | 29 ++++- 2 files changed, 92 insertions(+), 51 deletions(-) diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp index 7a721433c..37df1204b 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp @@ -22,16 +22,18 @@ bool SEN5XSensor::getVersion() } delay(20); // From Sensirion Datasheet - uint8_t versionBuffer[12]{}; - size_t charNumber = readBuffer(&versionBuffer[0], 3); - if (charNumber == 0) { - LOG_ERROR("%s: Error getting data ready flag value", sensorName); + // Version reply layout: fw major/minor, fw debug, hw major/minor, + // protocol major/minor, padding + uint8_t versionBuffer[SEN5X_VERSION_BUFFER_SIZE]{}; + size_t charNumber = readBuffer(&versionBuffer[0], SEN5X_VERSION_BUFFER_SIZE + (SEN5X_VERSION_BUFFER_SIZE / 2)); + if (charNumber < SEN5X_VERSION_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting device version value", sensorName); return false; } - firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10); - hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10); - protocolVer = versionBuffer[5] + (versionBuffer[6] / 10); + firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10.0f); + hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10.0f); + protocolVer = versionBuffer[5] + (versionBuffer[6] / 10.0f); LOG_INFO("%s: Firmware Version: %0.2f", sensorName, firmwareVer); LOG_INFO("%s: Hardware Version: %0.2f", sensorName, hardwareVer); @@ -48,12 +50,11 @@ bool SEN5XSensor::findModel() } delay(50); // From Sensirion Datasheet - const uint8_t nameSize = 48; - uint8_t name[nameSize]; - size_t charNumber = readBuffer(&name[0], nameSize); + uint8_t name[SEN5X_PRODUCT_NAME_BUFFER_SIZE]{}; + size_t charNumber = readBuffer(&name[0], SEN5X_PRODUCT_NAME_BUFFER_SIZE + (SEN5X_PRODUCT_NAME_BUFFER_SIZE / 2)); bool foundModel = false; - if (charNumber == 0) { + if (charNumber < SEN5X_PRODUCT_NAME_BUFFER_SIZE) { LOG_ERROR("%s: Error getting device name", sensorName); return foundModel; } @@ -361,15 +362,18 @@ bool SEN5XSensor::vocStateFromSensor() delay(20); // From Sensirion Datasheet - // Retrieve the data - // Allocate buffer to account for CRC - size_t receivedNumber = readBuffer(&vocState[0], SEN5X_VOC_STATE_BUFFER_SIZE + (SEN5X_VOC_STATE_BUFFER_SIZE / 2)); + // Retrieve the data into a staging buffer so a partial read (e.g. a CRC + // failure halfway through) cannot corrupt the current vocState. + // The requested size accounts for the CRC bytes + uint8_t stateBuffer[SEN5X_VOC_STATE_BUFFER_SIZE]{}; + size_t receivedNumber = readBuffer(&stateBuffer[0], SEN5X_VOC_STATE_BUFFER_SIZE + (SEN5X_VOC_STATE_BUFFER_SIZE / 2)); delay(20); // From Sensirion Datasheet - if (receivedNumber == 0) { + if (receivedNumber < SEN5X_VOC_STATE_BUFFER_SIZE) { LOG_DEBUG("%s: Error getting VOC's state", sensorName); return false; } + memcpy(vocState, stateBuffer, SEN5X_VOC_STATE_BUFFER_SIZE); // Print the state (if debug is on) LOG_DEBUG("%s: VOC state from sensor: [%u, %u, %u, %u, %u, %u, %u, %u]", sensorName, vocState[0], vocState[1], vocState[2], @@ -427,6 +431,7 @@ bool SEN5XSensor::loadState() return okay; #else LOG_ERROR("%s: Filesystem not implemented", sensorName); + return false; #endif } @@ -472,6 +477,7 @@ bool SEN5XSensor::saveState() return okay; #else LOG_ERROR("%s: Filesystem not implemented", sensorName); + return false; #endif } @@ -497,8 +503,7 @@ uint32_t SEN5XSensor::wakeUp() // keep track of how long it has passed pmMeasureStarted = getTime(); state = SEN5X_MEASUREMENT; - if (state == SEN5X_MEASUREMENT) - LOG_INFO("%s: Started measurement mode", sensorName); + LOG_INFO("%s: Started measurement mode", sensorName); return SEN5X_WARMUP_MS_1; } @@ -533,7 +538,7 @@ bool SEN5XSensor::startCleaning() // This message will be always printed so the user knows the device it's not hung LOG_INFO("%s: Started fan cleaning (10 sec)", sensorName); - uint16_t started = millis(); + uint32_t started = millis(); while (millis() - started < 10500) { delay(500); } @@ -658,9 +663,9 @@ bool SEN5XSensor::readValues() LOG_TRACE("%s: Reading PM Values", sensorName); delay(20); // From Sensirion Datasheet - uint8_t dataBuffer[16]{}; - size_t receivedNumber = readBuffer(&dataBuffer[0], 24); - if (receivedNumber == 0) { + uint8_t dataBuffer[SEN5X_READ_VALUES_BUFFER_SIZE]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_VALUES_BUFFER_SIZE + (SEN5X_READ_VALUES_BUFFER_SIZE / 2)); + if (receivedNumber < SEN5X_READ_VALUES_BUFFER_SIZE) { LOG_ERROR("%s: Error getting values", sensorName); return false; } @@ -676,15 +681,17 @@ bool SEN5XSensor::readValues() int16_t int_vocIndex = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); int16_t int_noxIndex = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); - // Convert values based on Sensirion Arduino lib - sen5xmeasurement.pM1p0 = !isnan(uint_pM1p0) ? uint_pM1p0 / 10 : UINT16_MAX; - sen5xmeasurement.pM2p5 = !isnan(uint_pM2p5) ? uint_pM2p5 / 10 : UINT16_MAX; - sen5xmeasurement.pM4p0 = !isnan(uint_pM4p0) ? uint_pM4p0 / 10 : UINT16_MAX; - sen5xmeasurement.pM10p0 = !isnan(uint_pM10p0) ? uint_pM10p0 / 10 : UINT16_MAX; - sen5xmeasurement.humidity = !isnan(int_humidity) ? int_humidity / 100.0f : FLT_MAX; - sen5xmeasurement.temperature = !isnan(int_temperature) ? int_temperature / 200.0f : FLT_MAX; - sen5xmeasurement.vocIndex = !isnan(int_vocIndex) ? int_vocIndex / 10.0f : FLT_MAX; - sen5xmeasurement.noxIndex = !isnan(int_noxIndex) ? int_noxIndex / 10.0f : FLT_MAX; + // Convert values based on Sensirion Arduino lib. + // Map values the sensor reports as unavailable (SEN5X_UINT_INVALID / + // SEN5X_INT_INVALID) to the sentinels getMetrics() checks for + sen5xmeasurement.pM1p0 = (uint_pM1p0 != SEN5X_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX; + sen5xmeasurement.pM2p5 = (uint_pM2p5 != SEN5X_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX; + sen5xmeasurement.pM4p0 = (uint_pM4p0 != SEN5X_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX; + sen5xmeasurement.pM10p0 = (uint_pM10p0 != SEN5X_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX; + sen5xmeasurement.humidity = (int_humidity != SEN5X_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX; + sen5xmeasurement.temperature = (int_temperature != SEN5X_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX; + sen5xmeasurement.vocIndex = (int_vocIndex != SEN5X_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX; + sen5xmeasurement.noxIndex = (int_noxIndex != SEN5X_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX; LOG_TRACE("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, sen5xmeasurement.pM1p0, sen5xmeasurement.pM2p5, sen5xmeasurement.pM4p0, sen5xmeasurement.pM10p0); @@ -711,9 +718,9 @@ bool SEN5XSensor::readPNValues(bool cumulative) LOG_TRACE("%s: Reading PN Values", sensorName); delay(20); // From Sensirion Datasheet - uint8_t dataBuffer[20]{}; - size_t receivedNumber = readBuffer(&dataBuffer[0], 30); - if (receivedNumber == 0) { + uint8_t dataBuffer[SEN5X_READ_PM_BUFFER_SIZE]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_PM_BUFFER_SIZE + (SEN5X_READ_PM_BUFFER_SIZE / 2)); + if (receivedNumber < SEN5X_READ_PM_BUFFER_SIZE) { LOG_ERROR("%s: Error getting PN values", sensorName); return false; } @@ -730,22 +737,29 @@ bool SEN5XSensor::readPNValues(bool cumulative) uint16_t uint_pN10p0 = static_cast((dataBuffer[16] << 8) | dataBuffer[17]); uint16_t uint_tSize = static_cast((dataBuffer[18] << 8) | dataBuffer[19]); - // Convert values based on Sensirion Arduino lib - // Multiply by 100 for converting from #/cm3 to #/0.1l for PN values - sen5xmeasurement.pN0p5 = !isnan(uint_pN0p5) ? uint_pN0p5 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.pN1p0 = !isnan(uint_pN1p0) ? uint_pN1p0 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.pN2p5 = !isnan(uint_pN2p5) ? uint_pN2p5 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.pN4p0 = !isnan(uint_pN4p0) ? uint_pN4p0 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.pN10p0 = !isnan(uint_pN10p0) ? uint_pN10p0 / 10 * 100 : UINT32_MAX; - sen5xmeasurement.tSize = !isnan(uint_tSize) ? uint_tSize / 1000.0f : FLT_MAX; + // Convert values based on Sensirion Arduino lib. + // Raw PN values are #/cm3 with 0.1 resolution; multiplying by 10 + // converts to #/0.1l without the truncation of dividing first. + // Map values the sensor reports as unavailable (SEN5X_UINT_INVALID) to the + // sentinels getMetrics() checks for + sen5xmeasurement.pN0p5 = (uint_pN0p5 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX; + sen5xmeasurement.pN1p0 = (uint_pN1p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX; + sen5xmeasurement.pN2p5 = (uint_pN2p5 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX; + sen5xmeasurement.pN4p0 = (uint_pN4p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX; + sen5xmeasurement.pN10p0 = (uint_pN10p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX; + sen5xmeasurement.tSize = (uint_tSize != SEN5X_UINT_INVALID) ? (uint_tSize / 1000.0f) : FLT_MAX; // Remove accumuluative values: // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85 if (!cumulative) { - sen5xmeasurement.pN10p0 -= sen5xmeasurement.pN4p0; - sen5xmeasurement.pN4p0 -= sen5xmeasurement.pN2p5; - sen5xmeasurement.pN2p5 -= sen5xmeasurement.pN1p0; - sen5xmeasurement.pN1p0 -= sen5xmeasurement.pN0p5; + if (sen5xmeasurement.pN10p0 != UINT32_MAX && sen5xmeasurement.pN4p0 != UINT32_MAX) + sen5xmeasurement.pN10p0 -= sen5xmeasurement.pN4p0; + if (sen5xmeasurement.pN4p0 != UINT32_MAX && sen5xmeasurement.pN2p5 != UINT32_MAX) + sen5xmeasurement.pN4p0 -= sen5xmeasurement.pN2p5; + if (sen5xmeasurement.pN2p5 != UINT32_MAX && sen5xmeasurement.pN1p0 != UINT32_MAX) + sen5xmeasurement.pN2p5 -= sen5xmeasurement.pN1p0; + if (sen5xmeasurement.pN1p0 != UINT32_MAX && sen5xmeasurement.pN0p5 != UINT32_MAX) + sen5xmeasurement.pN1p0 -= sen5xmeasurement.pN0p5; } LOG_TRACE("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", sensorName, @@ -767,10 +781,10 @@ uint8_t SEN5XSensor::getMeasurements() } delay(20); // From Sensirion Datasheet - uint8_t dataReadyBuffer[3]; - size_t charNumber = readBuffer(&dataReadyBuffer[0], 3); - if (charNumber == 0) { - LOG_ERROR("%s: Error getting device version value", sensorName); + uint8_t dataReadyBuffer[SEN5X_DATA_READY_BUFFER_SIZE]{}; + size_t charNumber = readBuffer(&dataReadyBuffer[0], SEN5X_DATA_READY_BUFFER_SIZE + (SEN5X_DATA_READY_BUFFER_SIZE / 2)); + if (charNumber < SEN5X_DATA_READY_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting data ready flag value", sensorName); return 2; } @@ -909,7 +923,7 @@ bool SEN5XSensor::getMetrics(meshtastic_Telemetry *measurement) measurement->variant.air_quality_metrics.has_pm_temperature = true; measurement->variant.air_quality_metrics.pm_temperature = sen5xmeasurement.temperature; } - if (sen5xmeasurement.noxIndex != FLT_MAX) { + if (sen5xmeasurement.vocIndex != FLT_MAX) { measurement->variant.air_quality_metrics.has_pm_voc_idx = true; measurement->variant.air_quality_metrics.pm_voc_idx = sen5xmeasurement.vocIndex; } diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.h b/src/modules/Telemetry/Sensor/SEN5XSensor.h index 5d84b8916..eeebbd373 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.h +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.h @@ -86,6 +86,18 @@ class SEN5XSensor : public TelemetrySensor #define SEN5X_READ_RAW_VALUES 0x03D2 #define SEN5X_READ_PM_VALUES 0x0413 +// Values the sensor reports when a reading is unavailable +#define SEN5X_UINT_INVALID 0xFFFF +#define SEN5X_INT_INVALID 0x7FFF + +// Reply payload sizes in data bytes; the raw I2C transfer adds one CRC byte +// per 2-byte group, so requests are + / 2 raw bytes +#define SEN5X_VERSION_BUFFER_SIZE 8 +#define SEN5X_PRODUCT_NAME_BUFFER_SIZE 32 +#define SEN5X_DATA_READY_BUFFER_SIZE 2 +#define SEN5X_READ_VALUES_BUFFER_SIZE 16 +#define SEN5X_READ_PM_BUFFER_SIZE 20 + #define SEN5X_VOC_VALID_TIME 600 #define SEN5X_VOC_VALID_DATE 1514764800 @@ -114,8 +126,23 @@ See: https://sensirion.com/resource/application_note/low_power_mode/sen5x #define SEN5X_PN4P0_CONC_THD 100 bool sendCommand(uint16_t command); + /** + * @brief Send a command word followed by a data payload; a CRC byte is + * computed and inserted on the wire after every 2-byte pair. + * @param command 16-bit command code, sent big-endian + * @param buffer payload data bytes, without CRCs + * @param byteNumber payload size in data bytes; must be even + * @return true when the full transfer is written and acknowledged + */ bool sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber = 0); - uint8_t readBuffer(uint8_t *buffer, uint8_t byteNumber); // Return number of bytes received + /** + * @brief Read a reply, verifying and stripping the interleaved CRC bytes. + * @param buffer destination for the data bytes (byteNumber * 2 / 3 of them) + * @param byteNumber raw transfer size including CRCs; must be a multiple + * of 3 (2 data bytes + 1 CRC per group) + * @return the number of data bytes written to buffer, or 0 on any error + */ + uint8_t readBuffer(uint8_t *buffer, uint8_t byteNumber); uint8_t sen5xCRC(const uint8_t *buffer); bool startCleaning(); uint8_t getMeasurements(); From 9199e6b663a9d10644c7596f7c312c679ebd4ae4 Mon Sep 17 00:00:00 2001 From: Tadayoshi MIURA <11958457+t-miura@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:02:56 +0900 Subject: [PATCH 027/109] fix(stm32wl): smaller MAX_RX_TOPHONE and PACKETHISTORY_MAX on stm32wl (#11400) * fixes for stm32: memory optimization and constraints tuning * fold stm32wl elif to existing define * revert changes for packet pool --- src/mesh/mesh-pb-constants.h | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index 1c376818a..aa41c1695 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -26,7 +26,7 @@ // FIXME - max_count is actually 32 but we save/load this as one long string of preencoded MeshPacket bytes - not a big array in // RAM #define MAX_RX_TOPHONE (member_size(DeviceState, receive_queue) / member_size(DeviceState, receive_queue[0])) #ifndef MAX_RX_TOPHONE -#if defined(ARCH_ESP32) && !(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3)) +#if defined(ARCH_STM32WL) || (defined(ARCH_ESP32) && !(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3))) #define MAX_RX_TOPHONE 8 #elif defined(NRF52840_XXAA) // Each slot is a ~340 B MeshPacket in the static pool (Router.cpp MAX_PACKETS_STATIC), so 32 slots @@ -34,10 +34,8 @@ // the 8 classic ESP32 has shipped with for years; drops start when a stalled phone/serial client has // 16 packets queued. #define MAX_RX_TOPHONE 16 -#elif MESHTASTIC_MEM_CLASS >= MEM_CLASS_MEDIUM || defined(ARCH_RP2040) || defined(CONFIG_IDF_TARGET_ESP32C3) || \ - defined(ARCH_STM32WL) -// RP2040/RP2350, ESP32-C3 and STM32WL keep their historical 32 (no field pressure to cut them; -// STM32WL's pool is dynamic, so the constant only bounds in-flight packets there). +#elif MESHTASTIC_MEM_CLASS >= MEM_CLASS_MEDIUM || defined(ARCH_RP2040) || defined(CONFIG_IDF_TARGET_ESP32C3) +// RP2040/RP2350 and ESP32-C3 keep their historical 32. #define MAX_RX_TOPHONE 32 #else #define MAX_RX_TOPHONE 16 // unclassified small parts: fail safe-small @@ -131,8 +129,12 @@ static inline int get_max_num_nodes() /// full mesh, floored at 100. Shared by PacketHistory's constructor clamp and /// the boot-cache budget assert below so the two cannot drift. #ifndef PACKETHISTORY_MAX +#if defined(ARCH_STM32WL) +#define PACKETHISTORY_MAX (MAX_NUM_NODES * 2) // 20 entries for 10-node STM32WL +#else #define PACKETHISTORY_MAX (MAX_NUM_NODES * 2 > 100 ? (uint32_t)(MAX_NUM_NODES * 2) : (uint32_t)100) #endif +#endif /// Per-map cap (position/telemetry/environment/status): only the freshest /// MAX_SATELLITE_NODES nodes keep satellite payloads, the rest just the From 2f6906974e723843bbd8cd4be9d970e9b558f916 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Wed, 12 Aug 2026 18:27:36 +0800 Subject: [PATCH 028/109] gps: replace GeoCoord::latLongToMeter's spherical trig with equirectangular approximation (#11184) --- src/configuration.h | 6 + src/gps/GeoCoord.cpp | 39 +++++ test/test_geocoord_distance/test_main.cpp | 165 ++++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 test/test_geocoord_distance/test_main.cpp diff --git a/src/configuration.h b/src/configuration.h index f9be2fc46..0ed4dd893 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -88,6 +88,12 @@ along with this program. If not, see . #define MESHTASTIC_PREHOP_DROP 1 #endif +// Use polynomial approximations for trigonometric functions to save flash. +// Override with -D MESHTASTIC_TRIG_APPROX=0 for exact trig for special use cases e.g. close to Earth's poles. +#ifndef MESHTASTIC_TRIG_APPROX +#define MESHTASTIC_TRIG_APPROX 1 +#endif + // Debug/test only: let a wired client (serial/TCP) inject frames into the RX pipeline as if they had // arrived over LoRa - a SIMULATOR_APP ToRadio packet is delivered through the real receive path on real // hardware (see MeshService::injectAsReceived). This forges over-the-air traffic, so it MUST stay 0 in diff --git a/src/gps/GeoCoord.cpp b/src/gps/GeoCoord.cpp index 4afae9394..1fc60c304 100644 --- a/src/gps/GeoCoord.cpp +++ b/src/gps/GeoCoord.cpp @@ -1,4 +1,5 @@ #include "GeoCoord.h" +#include "configuration.h" #include // Narrow a UTM meter value to its unsigned field, clamping non-finite/negative/oversized inputs: an @@ -433,6 +434,43 @@ void GeoCoord::convertWGS84ToOSGB36(const double lat, const double lon, double & //(airyA*airyA/(airyA / sqrt(1 - airyEcc*sin(osgb.latitude)*sin(osgb.latitude)))); // Not used, no OSTN data } +#if MESHTASTIC_TRIG_APPROX +// cos(x) minimax approx for x in [-pi/2, pi/2] ("cos_52"): https://www.ganssle.com/approx.htm +static double cosLatitudeApprox(double latRad) +{ + constexpr double c1 = 0.9999932946, c2 = -0.4999124376, c3 = 0.0414877472, c4 = -0.0012712095; + double x2 = latRad * latRad; + return c1 + x2 * (c2 + x2 * (c3 + c4 * x2)); +} + +/// Approximate distance in meters via equirectangular projection (not exact spherical trig). +/// <1% error to ~500km, degrading near the poles at long range (see test_geocoord_distance). +float GeoCoord::latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b) +{ + // Don't do math if the points are the same + if (lat_a == lat_b && lng_a == lng_b) + return 0.0; + + double a1 = lat_a / DEG_CONVERT; + double a2 = lng_a / DEG_CONVERT; + double b1 = lat_b / DEG_CONVERT; + double b2 = lng_b / DEG_CONVERT; + + double meanLat = (a1 + b1) / 2; + double dLng = b2 - a2; + // Wrap to [-PI, PI]: unlike cos()/sin(), a raw longitude difference doesn't handle points that + // straddle the antimeridian (e.g. 179.9 and -179.9 are ~0.2 degrees apart, not ~360). + if (dLng > PI) + dLng -= 2 * PI; + else if (dLng < -PI) + dLng += 2 * PI; + double x = dLng * cosLatitudeApprox(meanLat); + double y = b1 - a1; + double tt = sqrt(x * x + y * y); + + return (float)(6366000 * tt); +} +#else /// Ported from my old java code, returns distance in meters along the globe /// surface (by Haversine formula) float GeoCoord::latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b) @@ -456,6 +494,7 @@ float GeoCoord::latLongToMeter(double lat_a, double lng_a, double lat_b, double return (float)(6366000 * tt); } +#endif /** * Computes the bearing in degrees between two points on Earth. Ported from my diff --git a/test/test_geocoord_distance/test_main.cpp b/test/test_geocoord_distance/test_main.cpp new file mode 100644 index 000000000..de3430f1c --- /dev/null +++ b/test/test_geocoord_distance/test_main.cpp @@ -0,0 +1,165 @@ +#include "configuration.h" +#include "gps/GeoCoord.h" +#include +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// Pins latLongToMeter()'s equirectangular-approximation accuracy against the original spherical +// law of cosines, so a future change can't silently regress it. + +static constexpr double kPi = 3.14159265358979323846; + +static double referenceSphericalLawOfCosines(double lat_a, double lng_a, double lat_b, double lng_b) +{ + double a1 = lat_a * kPi / 180.0; + double a2 = lng_a * kPi / 180.0; + double b1 = lat_b * kPi / 180.0; + double b2 = lng_b * kPi / 180.0; + double t1 = std::cos(a1) * std::cos(a2) * std::cos(b1) * std::cos(b2); + double t2 = std::cos(a1) * std::sin(a2) * std::cos(b1) * std::sin(b2); + double t3 = std::sin(a1) * std::sin(b1); + double arg = t1 + t2 + t3; + if (arg > 1.0) + arg = 1.0; + if (arg < -1.0) + arg = -1.0; + return 6366000 * std::acos(arg); +} + +// Below ~1m, relative error is dominated by rounding noise rather than the formula itself, so +// assert an absolute bound instead (still catches a badly-broken implementation). +static constexpr double kNearZeroAbsoluteToleranceMeters = 0.5; + +// An order of magnitude above what the implementation currently produces per group - tight enough to +// catch a regression, loose enough not to track float rounding. Groups differ because +// equirectangular error grows with both separation and latitude. +static constexpr double kLocalTolerancePercent = 0.01; +static constexpr double kRegionalTolerancePercent = 0.1; +static constexpr double kHighLatitudeTolerancePercent = 0.2; +static constexpr double kAntimeridianTolerancePercent = 0.01; + +static void assertWithinPercent(double expected, double actual, double pct, const char *msg) +{ + if (expected < 1.0) { + if (std::fabs(actual - expected) > kNearZeroAbsoluteToleranceMeters) { + char buf[160]; + snprintf(buf, sizeof(buf), "%s: expected=%.3f actual=%.3f (near-zero, limit %.1fm absolute)", msg, expected, actual, + kNearZeroAbsoluteToleranceMeters); + TEST_FAIL_MESSAGE(buf); + } + return; + } + double err = std::fabs(actual - expected) / expected * 100.0; + if (err > pct) { + char buf[160]; + snprintf(buf, sizeof(buf), "%s: expected=%.1f actual=%.1f err=%.2f%% (limit %.2f%%)", msg, expected, actual, err, pct); + TEST_FAIL_MESSAGE(buf); + } +} + +static void test_identical_points_is_zero(void) +{ + TEST_ASSERT_EQUAL_FLOAT(0.0f, GeoCoord::latLongToMeter(51.5, -0.1, 51.5, -0.1)); +} + +static void test_local_distances(void) +{ + // Movement-threshold scale (meters to a few km) - the most common real usage. + struct { + double la, lo, lb, lob; + } cases[] = { + {51.5074, -0.1278, 51.5080, -0.1278}, // ~67m north + {51.5074, -0.1278, 51.5074, -0.1200}, // ~540m east at London's latitude + {0.0, 0.0, 0.001, 0.001}, // ~157m near the equator + {65.0, 25.0, 65.001, 25.002}, // high-ish latitude, small delta + {-33.87, 151.21, -33.865, 151.215}, // Sydney, southern hemisphere + }; + for (auto &c : cases) { + double expected = referenceSphericalLawOfCosines(c.la, c.lo, c.lb, c.lob); + double actual = GeoCoord::latLongToMeter(c.la, c.lo, c.lb, c.lob); + assertWithinPercent(expected, actual, kLocalTolerancePercent, "local distance"); + } +} + +static void test_regional_distances(void) +{ + // City-to-city scale (tens to ~500km) below 60 degrees; see test_high_latitude_distances. + struct { + double la, lo, lb, lob; + } cases[] = { + {51.5074, -0.1278, 48.8566, 2.3522}, // London to Paris, ~344km + {40.7128, -74.0060, 42.3601, -71.0589}, // NYC to Boston, ~306km + {35.6762, 139.6503, 34.6937, 135.5023}, // Tokyo to Osaka, ~400km + {-33.8688, 151.2093, -37.8136, 144.9631}, // Sydney to Melbourne, ~714km + }; + for (auto &c : cases) { + double expected = referenceSphericalLawOfCosines(c.la, c.lo, c.lb, c.lob); + double actual = GeoCoord::latLongToMeter(c.la, c.lo, c.lb, c.lob); + assertWithinPercent(expected, actual, kRegionalTolerancePercent, "regional distance"); + } +} + +static void test_high_latitude_distances(void) +{ + // Regional scale above 60 degrees, where equirectangular error grows fastest - a 500km pair at + // 80 degrees already exceeds 1%. + struct { + double la, lo, lb, lob; + } cases[] = { + {69.6492, 18.9553, 67.2804, 14.4049}, // Tromso to Bodo, ~322km + {64.8378, -147.7164, 61.2181, -149.9003}, // Fairbanks to Anchorage, ~417km + {78.2232, 15.6469, 78.9230, 11.9219}, // Longyearbyen to Ny-Alesund, ~113km + }; + for (auto &c : cases) { + double expected = referenceSphericalLawOfCosines(c.la, c.lo, c.lb, c.lob); + double actual = GeoCoord::latLongToMeter(c.la, c.lo, c.lb, c.lob); + assertWithinPercent(expected, actual, kHighLatitudeTolerancePercent, "high-latitude distance"); + } +} + +static void test_antimeridian_wraparound(void) +{ + // Two points ~22km apart straddling the 180th meridian - regression case for the antimeridian + // wraparound fix (a naive b2-a2 would compute this as ~40,000km). + double expected = referenceSphericalLawOfCosines(0.0, 179.9, 0.0, -179.9); + double actual = GeoCoord::latLongToMeter(0.0, 179.9, 0.0, -179.9); + assertWithinPercent(expected, actual, kAntimeridianTolerancePercent, "antimeridian distance"); + TEST_ASSERT_LESS_THAN_FLOAT(1000000.0f, actual); // sanity: nowhere near the naive-bug's ~40,000km +} + +static void test_symmetry(void) +{ + // distance(a,b) should equal distance(b,a) + double d1 = GeoCoord::latLongToMeter(51.5074, -0.1278, 48.8566, 2.3522); + double d2 = GeoCoord::latLongToMeter(48.8566, 2.3522, 51.5074, -0.1278); + TEST_ASSERT_FLOAT_WITHIN(0.01f, d1, d2); +} + +static void test_no_nan_at_extreme_latitudes(void) +{ + float d1 = GeoCoord::latLongToMeter(90.0, 0.0, -90.0, 0.0); + float d2 = GeoCoord::latLongToMeter(89.9, 10.0, 89.9, -170.0); + float d3 = GeoCoord::latLongToMeter(-89.9, 45.0, -89.9, -135.0); + TEST_ASSERT_FALSE(std::isnan(d1)); + TEST_ASSERT_FALSE(std::isnan(d2)); + TEST_ASSERT_FALSE(std::isnan(d3)); + TEST_ASSERT_TRUE(d1 > 0); +} + +void setup() +{ + UNITY_BEGIN(); + RUN_TEST(test_identical_points_is_zero); + RUN_TEST(test_local_distances); + RUN_TEST(test_regional_distances); + RUN_TEST(test_high_latitude_distances); + RUN_TEST(test_antimeridian_wraparound); + RUN_TEST(test_symmetry); + RUN_TEST(test_no_nan_at_extreme_latitudes); + exit(UNITY_END()); +} + +void loop() {} From 579d26e1b2f21bc3c9153cfda6b8b1ad4073973e Mon Sep 17 00:00:00 2001 From: Clive Blackledge Date: Wed, 12 Aug 2026 05:22:53 -0700 Subject: [PATCH 029/109] fix(NodeDB): don't let an empty contact key erase a stored public key (#11432) Clients send `add_contact` before every text-message DM, because a phone often holds a larger contact database (with public keys) than the radio can keep. That makes `addFromContact()` the highest-volume key-write path on the device - and it had no protection against key erasure. Its only key guard covered the manually-verified case: if the local entry was marked manually verified and the incoming contact was not, a key mismatch aborted the update. Every ordinary entry fell straight through to `CopyUserToNodeInfoLite()`, which assigns `public_key` unconditionally. So a SharedContact with `has_user` set and an empty `public_key` overwrote a peer's stored, XEdDSA-proven key with zeros - and `addFromContact()` calls `saveNodeDatabaseToDisk()`, so the erasure survived a reboot. Subsequent DMs to that peer then failed with PKI_SEND_FAIL_PUBLIC_KEY, with no way to recover until the peer's NodeInfo was re-exchanged. `public_key` is a singular (non-optional) bytes field, so "absent" and "empty" both decode to size 0; a client that simply has no key for a contact is indistinguishable on the wire from one asking to clear it. The fix is deliberately narrow: keep the stored key when the entry already holds a full 32-byte key and the incoming contact does not. A well-formed 32-byte contact key still updates the entry exactly as before. Deliberately NOT changed here: - `updateUser()`'s first-key-wins pin is not applied to this path. Clients legitimately use add_contact to supply keys the radio never had and to update them (QR-code contact sharing); a blanket pin would break that documented flow. Only erasure is refused. - `CopyUserToNodeInfoLite()` itself is untouched - it has many other callers (self-record refresh, updateUser, warm-tier rehydration), so the guard lives at this call site. - The manually-verified branch is unchanged. - Node-number validation (reserved/broadcast/self) on this path remains open and is tracked separately. Co-authored-by: Claude Opus 5 --- src/mesh/NodeDB.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e6c1ac67e..d24473125 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3482,7 +3482,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) } } info->num = contact.node_num; + // CopyUserToNodeInfoLite assigns public_key unconditionally, and clients send add_contact before every + // DM - often from an entry that carries no key at all. A contact may still supply or update a full + // 32-byte key (that's what add_contact is for), but it must never *erase* a key we already hold, which + // would be persisted below and break subsequent DMs with PKI_SEND_FAIL_PUBLIC_KEY. + const meshtastic_NodeInfoLite_public_key_t storedKey = info->public_key; TypeConversions::CopyUserToNodeInfoLite(info, contact.user); + if (storedKey.size == 32 && info->public_key.size != 32) { + LOG_INFO("Contact 0x%08x has no key, keep the stored one", contact.node_num); + info->public_key = storedKey; + } if (contact.should_ignore) { // Block the contact and drop its rich satellite data, but keep the // public key copied above - an ignored peer keeps a usable identity From 54d6ce833e3b65380dc2d2e818f5a872c65a4730 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Wed, 12 Aug 2026 20:42:19 +0800 Subject: [PATCH 030/109] gps: avoid pow() in GPS_HARDSLEEP threshold heuristic (#11179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * gps: avoid pow() in GPS_HARDSLEEP threshold heuristic GPS::down() used pow(seconds, 1.22) to pick between GPS_SOFTSLEEP and GPS_HARDSLEEP - a curve fit the surrounding comment already describes as "not particularly accurate". On flash-constrained builds where this was the only pow() call site (e.g. wio-e5), it single-handedly pulled in the full double-precision libm pow/rem_pio2 chain for a heuristic threshold decision. Replaces it with gpsHardsleepThresholdMs(), a piecewise-linear lookup over the same curve, sampled at 16 points and verified to track the original formula within ~0.5% for inputs >=10s and ~1.6% for 5-10s (worse only in relative terms below 5s, where the absolute difference is at most a couple of seconds - negligible against update intervals measured in tens of seconds to hours). Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * gps: trim comments to repo convention (1-2 lines) Addresses a CodeRabbit nitpick: the explanatory comments in GPSUpdateScheduling.cpp and test_gps_update_scheduling/test_main.cpp had grown into multi-line blocks with provenance detail that belongs in the commit message, not inline. Trims each to 1-2 lines, keeping only the essential rationale/bounds. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * Refactor main function to setup and loop for tests Signed-off-by: Thomas Göttgens * gps: extend the hardsleep threshold table below 5s and tighten its tests The 0s-to-5s chord read 42% high at 1s, 22% at 2s and 12% at 3s, against the ~1.6% the comment claimed. Adding 1s, 2s and 3s sample points brings the worst error below 10s to 1.60% at 7s. Above 10s it is 0.55% at 728s, unchanged. Tests: sample off-breakpoint values only, including both worst-error inputs (7s and 728s). Replace the 3000ms absolute floor, which made the 1s assertion unfalsifiable given a true value of 2750ms, with 2% and 0.75% bounds. Add breakpoint-exactness and clamp-boundary coverage. --------- Signed-off-by: Andrew Yong Signed-off-by: Thomas Göttgens Co-authored-by: Austin Co-authored-by: Thomas Göttgens --- src/gps/GPS.cpp | 6 +- src/gps/GPSUpdateScheduling.cpp | 25 +++++ src/gps/GPSUpdateScheduling.h | 4 + test/test_gps_update_scheduling/test_main.cpp | 91 +++++++++++++++++++ 4 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 test/test_gps_update_scheduling/test_main.cpp diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index fd5be417e..2ca1d86d1 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1389,11 +1389,7 @@ void GPS::down() #endif if (softsleepSupported) { - // How long does gps_update_interval need to be, for GPS_HARDSLEEP to become more efficient than - // GPS_SOFTSLEEP? Heuristic equation. A compromise manually fitted to power observations from U-blox NEO-6M - // and M10050 https://www.desmos.com/calculator/6gvjghoumr This is not particularly accurate, but probably an - // improvement over a single, fixed threshold - uint32_t hardsleepThreshold = (2750 * pow(predictedSearchDuration / 1000, 1.22)); + uint32_t hardsleepThreshold = gpsHardsleepThresholdMs(predictedSearchDuration / 1000); LOG_DEBUG("gps_update_interval >= %us needed for hardsleep", hardsleepThreshold / 1000); // If update interval too short: softsleep (if supported by hardware) diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index a19d9c7d5..fe2c3ae78 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -2,6 +2,31 @@ #include "Default.h" +// Sampled from the original `2750 * seconds^1.22` curve. Interpolation tracks it within 0.6% for +// inputs >=10s and 1.7% below that; the 1s/2s/3s points keep the convex first segment from +// overshooting (a 0s-to-5s chord reads 42% high at 1s). +static constexpr uint32_t kThresholdCurveSecs[] = {0, 1, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300, 450, 600, 900}; +static constexpr uint32_t kThresholdCurveMs[] = {0, 2750, 6406, 10506, 19592, 45639, 74845, + 106314, 174350, 285925, 406141, 666053, 946093, 1551548, + 2203893, 2893481, 4745172, 6740269, 11053722}; +static constexpr size_t kThresholdCurvePoints = sizeof(kThresholdCurveSecs) / sizeof(kThresholdCurveSecs[0]); + +// How long does gps_update_interval need to be, for GPS_HARDSLEEP to become more efficient than +// GPS_SOFTSLEEP? Avoids pow() so this heuristic doesn't pull double-precision libm into the image. +uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs) +{ + if (predictedSearchSecs >= kThresholdCurveSecs[kThresholdCurvePoints - 1]) + return kThresholdCurveMs[kThresholdCurvePoints - 1]; + + size_t i = 1; + while (kThresholdCurveSecs[i] < predictedSearchSecs) + i++; + + uint32_t x0 = kThresholdCurveSecs[i - 1], x1 = kThresholdCurveSecs[i]; + uint32_t y0 = kThresholdCurveMs[i - 1], y1 = kThresholdCurveMs[i]; + return y0 + (uint32_t)((uint64_t)(y1 - y0) * (predictedSearchSecs - x0) / (x1 - x0)); +} + // Mark the time when searching for GPS position begins void GPSUpdateScheduling::informSearching() { diff --git a/src/gps/GPSUpdateScheduling.h b/src/gps/GPSUpdateScheduling.h index 120605c4e..d7609d704 100644 --- a/src/gps/GPSUpdateScheduling.h +++ b/src/gps/GPSUpdateScheduling.h @@ -2,6 +2,10 @@ #include "configuration.h" +// Approximates the GPS_HARDSLEEP/GPS_SOFTSLEEP crossover curve without pow(); see .cpp for the +// sampled reference values it interpolates between. +uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs); + // Encapsulates code responsible for the timing of GPS updates class GPSUpdateScheduling { diff --git a/test/test_gps_update_scheduling/test_main.cpp b/test/test_gps_update_scheduling/test_main.cpp new file mode 100644 index 000000000..00c01c0f6 --- /dev/null +++ b/test/test_gps_update_scheduling/test_main.cpp @@ -0,0 +1,91 @@ +#include "Arduino.h" +#include "TestUtil.h" +#include "gps/GPSUpdateScheduling.h" +#include +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +// Confirms gpsHardsleepThresholdMs()'s pow()-free lookup table tracks the original +// `2750 * pow(seconds, 1.22)` curve closely. +static double originalFormula(uint32_t seconds) +{ + return 2750.0 * std::pow((double)seconds, 1.22); +} + +static void test_matches_original_formula_at_sampled_points(void) +{ + // Off-breakpoint values only - a breakpoint interpolates exactly by construction, so it would + // test nothing here (test_exact_at_table_breakpoints covers those). Includes both worst-error + // inputs: 7s (1.60%) and 728s (0.55%). Capped at 900s, the pre-existing 15-minute search clamp. + const uint32_t samples[] = {4, 6, 7, 8, 9, 33, 100, 150, 500, 728, 899}; + for (uint32_t s : samples) { + double expected = originalFormula(s); + uint32_t actual = gpsHardsleepThresholdMs(s); + // Pure integer arithmetic, so results are bit-identical everywhere - no float noise to + // leave headroom for, and these sit just above the measured worst cases. + double tolerance = expected * (s < 10 ? 0.02 : 0.0075); + TEST_ASSERT_DOUBLE_WITHIN(tolerance, expected, (double)actual); + } +} + +static void test_zero_seconds_is_zero(void) +{ + TEST_ASSERT_EQUAL_UINT32(0, gpsHardsleepThresholdMs(0)); +} + +static void test_monotonically_nondecreasing(void) +{ + uint32_t prev = gpsHardsleepThresholdMs(0); + for (uint32_t s = 1; s <= 1200; s += 7) { + uint32_t cur = gpsHardsleepThresholdMs(s); + TEST_ASSERT_GREATER_OR_EQUAL_UINT32(prev, cur); + prev = cur; + } +} + +static void test_exact_at_table_breakpoints(void) +{ + // Every breakpoint must return its own sampled value. Catches an off-by-one in the segment + // scan, which a percentage bound on interpolated points would absorb. + const uint32_t breakpoints[] = {0, 1, 2, 3, 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300, 450, 600, 900}; + for (uint32_t s : breakpoints) { + char msg[64]; + snprintf(msg, sizeof(msg), "breakpoint %us", s); + // Within 2ms, not exact: the 30s entry is rounded 1ms high, and pow() can differ by an ULP + // across libm implementations. A real off-by-one in the scan misses by thousands. + TEST_ASSERT_UINT32_WITHIN_MESSAGE(2, (uint32_t)(originalFormula(s) + 0.5), gpsHardsleepThresholdMs(s), msg); + } +} + +static void test_clamps_above_table_range(void) +{ + uint32_t atMax = gpsHardsleepThresholdMs(900); + TEST_ASSERT_EQUAL_UINT32(atMax, gpsHardsleepThresholdMs(2000)); + TEST_ASSERT_EQUAL_UINT32(atMax, gpsHardsleepThresholdMs(UINT32_MAX)); +} + +static void test_clamp_boundary(void) +{ + // The clamp must engage exactly at the last table point, not before or after it. + TEST_ASSERT_LESS_THAN_UINT32(gpsHardsleepThresholdMs(900), gpsHardsleepThresholdMs(899)); + TEST_ASSERT_EQUAL_UINT32(gpsHardsleepThresholdMs(900), gpsHardsleepThresholdMs(901)); +} + +void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_matches_original_formula_at_sampled_points); + RUN_TEST(test_zero_seconds_is_zero); + RUN_TEST(test_monotonically_nondecreasing); + RUN_TEST(test_exact_at_table_breakpoints); + RUN_TEST(test_clamps_above_table_range); + RUN_TEST(test_clamp_boundary); + exit(UNITY_END()); +} + +void loop() {} From db3eb91015f1e09234a713cd68be5d41fae1c758 Mon Sep 17 00:00:00 2001 From: Clive Blackledge Date: Wed, 12 Aug 2026 06:05:20 -0700 Subject: [PATCH 031/109] fix(security): never log the X25519 identity private key (#11435) installDefaultConfig() restores a preserved identity key when the config is reset with preserveKey=true. On that path it called: printBytes("Restored key", config.security.private_key.bytes, config.security.private_key.size); printBytes() hex-dumps the buffer to LOG_DEBUG, so this emitted all 32 bytes of the raw X25519 identity private key to the serial/BLE debug log. Debug logs are not a private channel. They are routinely captured over serial or BLE and pasted verbatim into GitHub issues, Discord threads and support requests. Anyone who reads such a log recovers the node's identity private key, and can then impersonate the node and decrypt every PKI direct message addressed to it -- past messages included, since the key is long-lived and the DH shared secret is static per node pair. There is no revocation story short of generating a new identity. Replaced with a LOG_DEBUG that records that a key was restored and contains no key-derived bytes. The restore/no-restore signal is the genuinely useful diagnostic here ("did my key survive the reset?"), and it costs nothing to keep; the bytes were never what made the line useful. Log level is unchanged -- printBytes() already logged at LOG_DEBUG. Deliberately NOT a truncated prefix or a hash. A prefix is still key material: it hands an attacker free bytes and shrinks the search space. A hash is a confirmation oracle -- it lets anyone holding a candidate key verify it against the log, which is exactly the check an attacker needs. Neither is a compromise; both leak. If a log line survives at all it must carry zero key-derived bytes. Sites changed: - src/mesh/NodeDB.cpp:988 -- the only full private-key dump in src/. Audited and deliberately left alone: - NodeDB.cpp:3552,3604 ("Incoming Pubkey", "Saved Pubkey") -- public keys, published to the mesh by design; not secret. - CryptoEngine.cpp:245,285 -- nonces, not key material. - CryptoEngine.cpp:246,286 -- first 8 bytes of the derived shared_key, and AdminModule.cpp:2006,2013,2014 -- the 8-byte admin session passkey. Both are secrets rather than public values, but neither is the identity private key and both are out of scope for this fix; noted for follow-up. No unused-variable fallout: private_key_temp is still read by the memcpy above, and printBytes() is still used by the two pubkey sites, so the meshUtils.h include is still required. Co-authored-by: Claude Opus 5 --- src/mesh/NodeDB.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index d24473125..e31df4faa 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -985,7 +985,8 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) if (shouldPreserveKey) { config.security.private_key.size = 32; memcpy(config.security.private_key.bytes, private_key_temp, config.security.private_key.size); - printBytes("Restored key", config.security.private_key.bytes, config.security.private_key.size); + // Never log the key bytes: debug logs get pasted into public bug reports. + LOG_DEBUG("Restored preserved private key"); } else { config.security.private_key.size = 0; } From fdb644e0b7c1d3626f62bb8bba5900397bd62248 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:49:17 +0200 Subject: [PATCH 032/109] Fix `millis()` rollover in deadline, interval, and timestamp handling (#11291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add native test coverage for the UptimeClock monotonic seam src/UptimeClock.{h,cpp} shipped without a dedicated test suite. Port the six tests from the monotonic-time branch (test/test_time), retargeted to the renamed header. The wrap test crosses 0xFFFFFFFF via advanceTestMillis() rather than a second setTestMillis(): setTestMillis() sets clockSourceChanged, which makes getMillis64() rebase its accumulator and swallow the wrap. * NextHopRouter: fix 49.7-day millis() rollover in retransmission timing Resolves the "FIXME, handle 51 day rolloever here!!!" in NextHopRouter::doRetransmissions() by switching the retransmission-due comparison from plain unsigned <= to a signed-difference cast. The previous p.nextTxMsec <= now comparison silently breaks across the ~49.7 day millis() wraparound: pending retransmissions either stall for the remainder of the wrap window, or all fire simultaneously at the rollover boundary. Long-running router/infrastructure nodes do hit this in practice. The replacement (int32_t)(p.nextTxMsec - now) <= 0 is the standard Arduino/embedded idiom for rollover-safe deadline checks and behaves identically to the original for any non-wrap timing. * Address Copilot review: use unsigned half-range for rollover-safe retransmit check Review feedback from @Copilot on PR #10227: casting a uint32_t subtraction to int32_t is implementation-defined in C++ when the unsigned value exceeds INT32_MAX (even though it works on typical two's-complement targets). Switch to the fully well-defined unsigned half-range form: nextTxMsec is in the past-or-equal iff (now - nextTxMsec) has not wrapped past 2^31 ms. Future offsets < 2^31 ms wrap into the top half and read as 'not yet'. Same semantics as the signed-cast version on every two's-complement platform we care about, but portable to any conforming C++ impl. * Use monotonic time for airtime windows * Document monotonic airtime windows * Fix test_packet_signing sentinel that #10227's rollover fix inverts test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state parked a pending packet at nextTxMsec = UINT32_MAX to mean "never retransmit", then asserted that a rejected repeated packet leaves the retry state untouched. NextHopRouter::doRetransmissions() now tests whether a retransmit is due with an unsigned half-range compare, (uint32_t)(now - nextTxMsec) < 0x80000000u, so that retransmission timing survives the ~49.7 day millis() wrap. Under it now - 0xFFFFFFFF == now + 1, a small positive delta, so UINT32_MAX reads as ~1ms in the past: the retransmit fires and rewrites nextTxMsec, and the test failed with "Expected 4294967295 Was 6247". Use a representable future time instead. Production is unaffected either way - nextTxMsec is only ever written as millis() + d, and UINT32_MAX came from the test harness alone - so the sentinel is what needs to go, not the comparison. Special-casing UINT32_MAX in the retransmit path would keep a value that reads as "expired" under any wrap-correct compare. The value is held in a local because millis() advances across runPipelineIngress(), so recomputing it at the assertion would compare against a different number. Reported upstream on meshtastic/firmware#10227, whose branch predates this test. * Make Throttle time-injectable and add hasElapsed() Throttle backs ~94 call sites, which makes it the highest-leverage place in the tree to put the clock seam: reading Time::getMillis() instead of millis() in its three call sites turns all of them into time-injectable code at once, without touching any of them. The 32-bit millis() wrap is not otherwise reachable from a native test. The read is behaviour-preserving - Time::getMillis() returns millis() unless a test injects a clock - and the full native suite passes with it live. Also add hasElapsed(), the complement of isWithinTimespanMs(), because 51 of the 94 call sites are spelled !isWithinTimespanMs and read poorly. Its boundary is inclusive (>=) since isWithinTimespanMs uses <; both are documented. It deliberately does not treat lastExecutionMs == 0 as "never run": call sites pair that test with the interval check themselves, and absorbing a sentinel into the one helper every module depends on is exactly the value-overloading hazard being removed elsewhere. Migrating the existing !isWithinTimespanMs sites is cosmetic and deliberately left out of this commit. test/test_throttle/ covers window semantics, both boundaries, the complement identity, execute()'s first-run and throttled paths, and - the point of the exercise - a window opened before the wrap closing correctly after it, including at the 24h interval that is the longest in the tree. * Stop disarmed deadline sentinels reaching the comparison Two deadline variables encoded "inactive" as a magic value that only reads as "never" because the comparison against it is a naive millis() compare. Under any rollover-correct comparison both invert to "expired ~49 days ago", so they have to be untangled before those comparisons can be fixed. Power::reboot() set rebootAtMsec = -1 on platforms with no reboot implementation, intending "never fire". Every reader already treats 0 as the disarm value - powerCommandsCheck() tests `if (rebootAtMsec && ...)`, and AdminModule writes 0 to cancel - so -1 was both wrong and unnecessary. Use 0. Left as UINT32_MAX it would reboot-loop the moment the comparison is corrected. ExternalNotificationModule's nag window compared against nagCycleCutoff, which holds UINT32_MAX once stopped and 1 at boot. isNagging is the real armed flag, so test it first and short-circuit: a disarmed cutoff can no longer reach the arithmetic, while an idle module still takes the same sleep path that the boot-time value of 1 was relying on. Note this fixes the sentinel only. The comparison itself is still a naive `nagCycleCutoff < millis()` and remains on the list to convert. * Fix millis() rollover in every deadline and interval comparison Roughly 20 sites compared against millis() directly - `millis() > deadline`, `deadline < millis()`, `last + interval < millis()`. All of them break for about 24 days after the 32-bit millis() wrap: depending on which side of the wrap each value sits, the action either stalls for weeks or fires immediately and repeatedly. The longest affected interval is the 12 hour NTP renewal, a ~50x margin against the wrap, so none of these needed the range - only the correct comparison. Add Throttle::deadlinePassed(deadlineMs) for sites that store an absolute deadline they cannot re-express as "interval since an event". It uses the same unsigned half-range test as NextHopRouter::doRetransmissions() rather than introducing a competing signed-cast idiom, and unlike the signed cast it is defined for every input. Sites that do store an event use the existing isWithinTimespanMs / hasElapsed. Nothing gained new state. Because both helpers read Time::getMillis(), every converted site is now reachable from a native test that drives the clock across the wrap; the comparison itself is covered directly in test/test_throttle/. Sentinel handling is the reason this could not be a mechanical rewrite. The disarm convention is not uniform: 0 means "inactive" for rebootAtMsec, shutdownAtMsec, alertBannerUntil, fixHoldEnds, suppressUntilMs and touchResumeBlockUntilMs; 0 means "due now" for ntp_renew, which is forced to 0 at link-up; UINT32_MAX means "inactive" for nagCycleCutoff; and alertBannerUntil == 0 in isOverlayBannerShowing() means "show indefinitely". Every inactive marker is arithmetically far in the past, so a correct comparison fires on it - each site tests its sentinel before the arithmetic, and keeps the meaning it had. Two sites carried a second bug found on the way: BME680Sensor tested (stateUpdateCounter * STATE_SAVE_PERIOD) < millis(). With a 6 hour period and a uint16_t counter that product overflows uint32_t after about 198 saves, independently of the millis() wrap. It now measures the interval since the last save. EInkDynamicDisplay had `if (previousRunMs > millis()) return;` as a millis() overflow guard, which skipped rate limiting entirely for the whole post-wrap period - the bug it meant to prevent. Every check below it already goes through Throttle, so the guard is removed rather than fixed. MotionSensor's calibration countdown is converted to a signed delta rather than deadlinePassed, because it needs the remaining magnitude and not a boolean; that matches the already-correct check in the same file. * Remove getMillis64() and use Throttle for the NodeInfo reply window getMillis64() had exactly one caller and no callers in tests. It also carried obligations that made it the wrong shape for this firmware: a wrap accumulator in mutable statics, which is not ISR-safe, and which must be polled at least once every ~49.7 days or it silently misses a wrap and returns a time ~49 days short. Its one caller only wanted to know whether a 12 hour suppression window had elapsed - which Throttle answers correctly across the wrap without any accumulator. NodeInfoModule now stores Time::getMillis() in lastNodeInfoSeen and tests the window with Throttle::isWithinTimespanMs, so the map holds milliseconds rather than seconds derived from a 64-bit read. USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS is user-overridable and now feeds a multiply by 1000, so a static_assert rejects any value too large to express in milliseconds instead of letting it wrap. clockSourceChanged goes too. It existed solely to rebase getMillis64()'s accumulator when a test swapped clock sources, and it made the wrap untestable through the injection API: setTestMillis() set the flag, so a wrap crossed by two setTestMillis() calls was swallowed. With the accumulator gone the flag has nothing to rebase, and the injection API is a plain settable clock. The three getMillis64 tests are dropped as they no longer describe anything. One test replaces them, pinning that advanceTestMillis() wraps past 0xFFFFFFFF rather than saturating, since the Throttle wrap tests rely on it. Also fix eviction in pruneLastNodeInfoCache(): it picked the entry with the smallest stored stamp, which is the wrong victim once some stamps sit on the far side of the wrap. It now evicts the largest elapsed time. * Add CI guard and docs rule against naive millis() comparisons Fixing the existing sites does not stop the next one being added. The millis-deadline-check job rejects millis() placed directly next to a comparison operator, in either order, anywhere in src/. It lives in test_native.yml alongside suite-count-check, which sets the precedent for a repo-hygiene guard that CI enforces and bin/run-tests.sh does not. The correct idioms all subtract before comparing, so none of them match the pattern. Line comments are stripped first, so documentation is free to name the broken form - as the guard's own comment and the coding conventions both do. Writing the check before finishing the sweep turned out to be worth it: it found roughly 14 sites that a by-hand audit of deadline variables had missed, including two extra nagCycleCutoff compares, both boot-screen timeouts, and a 6 hour sensor save interval that was also overflowing a uint32_t multiply. .github/millis-deadline-allowlist.txt covers the cases that are genuinely not deadline tests. Both current entries are uptime thresholds - "has the device been up N ms" - with no stored deadline and no event to measure from: a 30s button holdoff against phantom shutdown from floating pins, and a 10s window for the OEM boot logo. Each re-crosses its threshold once per wrap, which is harmless for boot-holdoff logic and not worth new state to avoid. Entries are keyed on file plus exact source text, without line numbers, so an edit above an entry does not silently invalidate it. Locally the guard reports 19 matches before the sweep and 2 after, both allowlisted. The Throttle bullet in the coding conventions is rewritten from "prefer Throttle for rate limiting" to "never compare against millis() directly", lists all four helpers with when to use which, names the CI guard, and documents the sentinel hazard with the rebootAtMsec = -1 case that would have become a reboot loop. Mirrored into AGENTS.md; CLAUDE.md gets a pointer row. * Trim rollover comments to what the code needs The comments added with the millis() rollover fixes carried too much of the investigation that produced them: how many sites were found, which document recorded them, what the old code used to do. That belongs in the commit history, not in the source, and some of it was already stale - Power::reboot() still described the check it disarms as "a naive millis() > deadline" when that comparison had been fixed in the same series. What stays is the non-obvious part at each site: which sentinel value the variable overloads and what it means there, since that differs between call sites and is what a correct comparison gets wrong. 0 means "not scheduled" for rebootAtMsec, "renew now" for ntp_renew, and "show indefinitely" in isOverlayBannerShowing(). Exposition is kept where it earns its place: the Throttle helpers, the uptime clock's note on why there is no 64-bit variant, and the tests. The Throttle docs lose only the site count and the "longest interval in the firmware" statistic, both of which would age badly; the range trade-off between the two forms is what a caller actually needs. Comments only - no code changed, verified by diff. * possible fixes * Address review feedback on the rollover fixes - BME680Sensor: checkpoint lastStateSaveMs after a successful write instead of at the interval test. The first save (IAQ accuracy >= 2) left it at 0, timing the next save from boot, and stamping before the write deferred the retry a full period when the write failed. Reads Time::getMillis(), the same clock Throttle compares against. - Throttle: add deadlinePassedAt(now, deadline) for loops that snapshot the clock once and test many deadlines; deadlinePassed() now delegates to it. NextHopRouter::doRetransmissions() uses it, replacing the inline half-range compare adopted from #10227 (nightjoker7) - same arithmetic, credited at the call site - and takes its snapshot from Time::getMillis() so setNextTx() deadlines and the due test cannot diverge under an injected test clock. - test_native.yml: set -euo pipefail in the millis-deadline guard, matching the sibling suite-count job. Without -e a partially failed scan could report "no violations" from truncated output. - test_packet_signing: build the not-due deadline from Time::getMillis() rather than millis(), so the test and the router read one clock. - test_throttle: cover deadlinePassedAt(), and correct a wrapped-value comment (0xFFFFFF00 + 400 is 0x00000090, not 0x00000094). Two review comments were declined: the AirTime mutex (every airTime-> caller runs in the single cooperative loop, WebServerThread included) and the MotionSensor 0-sentinel countdown (the calibration frame is only installed while a window is open). clod helped out here * Correct the described failure window of a naive millis() compare The comments and agent docs said a bare `millis() > deadline` "breaks for ~24 days after the wrap". That figure belongs to the fix, not the bug: it is the half-range limit of deadlinePassed(), which reads deadlines more than 2^31 ms ahead as already passed, and the range over which a UINT32_MAX sentinel reads as passed. The naive compare's actual failure is an inversion lasting only while the deadline sits on the far side of the wrap, so it is bounded by the interval: the action fires immediately and loses its wait, or blocks for about the wait it should have performed - days for the nRF52 flash-corruption backoff, one skipped cycle for a seconds-long retransmit timer. Comments and docs only; the ~24.8 day statements that correctly describe deadlinePassed()'s own range are left as they were. clod helped out here * Restore a monotonic uptime clock and consolidate the wrap counters Time::getMillisMonotonic() is the getMillis64() shape - a 32-bit wrap counter carried across reads - promoted to the shared timebase, with Time::getUptimeSecs() as the derived whole-seconds view. This deliberately reverses the earlier removal of getMillis64(), and the distinction matters: removal was right for a lazily-read accumulator with one rare caller, where a 49.7-day gap between reads silently swallowed a wrap. Here every read is the poll and AirTime::runOnce() guarantees one per second; the missed-wrap contract is pinned by a test rather than left as a footnote. Three private wrap counters collapse into it: - AirTime::syncNow() takes its seconds from Time::getUptimeSecs() and drops its lastSyncMsec checkpoint; window rotation is unchanged. - DeviceTelemetryModule loses refreshUptime()/uptimeWrapCount/uptimeLastMs; uptime_seconds comes from Time::getUptimeSecs(), which also removes the 0.296s-per-wrap truncation of (0xFFFFFFFF / 1000) * wraps. Its two interval checks move to Throttle::hasElapsed(). - HostMetricsModule's copies of those members were never read (its uptime comes from /proc/uptime) - deleted. Not ISR-safe (unguarded mutable carry): ISRs keep using getMillis(), which stays a pure read. Audited: no interrupt-context file reads getTime(), getValidTime(), or the new accessors. test/native-suite-count 44 -> 45: the bump for test_uptime_clock was lost in a branch history rewrite, leaving every later value off by one - run-tests.sh reports AMBER and CI's suite-count-check fails on the current push until this correction. * Anchor the wall clock in monotonic milliseconds getTime() computed elapsed-since-time-set as a 32-bit millis() delta, so a node that took time once and stayed up past 49.7 days reported a wall clock one full cycle in the past - and last_heard, rx_time, message and position stamps all inherited it. The anchor is now the 64-bit monotonic count (timeStartMsec -> timeStartMs64) and the elapsed term is computed in 64-bit, so the wall clock is exact at any uptime. All six anchor writers follow: the five hardware-RTC read branches and perhapsSetRTC(), which keeps a truncated 32-bit copy of the same instant for its Throttle-checked rate-limit stamps. The test seams anchor the same way. Two native regression tests drive getTime() across the wrap through the Time seam - one anchored before the wrap and read after it, one anchored after a counted wrap - with the test epoch derived from BUILD_EPOCH so the plausibility window cannot rot as the build date advances. * Stamp the rx_time placeholder in monotonic uptime seconds computeRxTimeStamp() stamped Time::getMillis() when the clock was untrusted, and reconcilePendingRxTimes() back-calculated with a 32-bit millis() delta - correct within one wrap, but a placeholder older than 49.7 days aliased to a small elapsed value and reconciled to a plausible-but-wrong recent epoch: the exact failure has_rx_time exists to prevent, reachable by an ordinary unattended router whose phone connects two months in. The placeholder is now Time::getUptimeSecs(). Both stamps come off the monotonic counter, so the elapsed term is exact at any age and the aliasing window is gone outright rather than widened. If elapsed somehow exceeds the epoch itself, the packet stays un-dated (absent, never wrong) instead of clamping to a pre-1970 value. Defence in depth: a placeholder that leaks needs ~50 years of uptime to cross MIN_PLAUSIBLE_EPOCH, where milliseconds took 18.3 days. The stream-API reconciliation tests keep their scenarios with the placeholder unit switched, and ScopedTimeFixture resets the monotonic carry so uptime seconds are deterministic per case. * Date nodes heard before the clock arrives, without polluting last_heard A node first heard while the wall clock was untrusted got no last_heard at all, and nothing backfilled it once time arrived - the phone showed "Last heard: unknown" for a node it had just announced. The arrival instant now waits in a RAM-only sidecar (NodeNum -> uptime seconds, 32 slots, reuse-oldest - the RouteHealth shape) and is converted to a real epoch on the clock-becoming-trusted transition, beside the existing rx_time reconciliation. last_heard itself never holds anything but a real epoch or 0: it persists to flash and the warm tier, where an uptime-relative value would be meaningless after reboot. The sidecar's write sites are updateFrom()'s no-trusted-clock path (the rx_time placeholder already carries the arrival instant, so this is a store, not a second clock read) and addFromContact's anti-eviction stamps, which previously wrote a bare getTime() - boot-relative seconds on a clockless node, the exact value lastHeardIsWallClock() exists to catch. Eviction ranking honours the stamps: heard-this-boot outranks every stored epoch, ordered among themselves, so a stamped contact is not the first victim. PhoneAPI re-reads last_heard at nodeinfo send time: a record prefetched before the clock became trusted can carry 0 while the store has since been backfilled, and re-reading at the pop makes handshake ordering (time-set vs node-list download) irrelevant. Backfill never moves last_heard backwards and skips the pathological elapsed-exceeds-epoch case. A node evicted to the warm tier before time arrives is still absorbed with last_heard 0 - same as before, bounded to the untrusted window. * Update the agent docs for the monotonic timebase The conventions bullet asserted there is deliberately no 64-bit millis; the monotonic uptime clock restored for timestamps changes that contract. State the split explicitly: Throttle for deadlines and intervals (no carry state), Time::getMillisMonotonic()/getUptimeSecs() for timestamps, polled by construction and not ISR-safe. * Publish the monotonic wrap carry from a single writer getMillisMonotonic() was a read-modify-write on two unguarded statics, and it is reached off the main loop: the nRF52 Bluefruit task via onFromRadioAuthorize() -> PhoneAPI::getFromRadio -> getValidTime(), and the portduino civetweb workers via the same path. Two readers interleaving inside the wrap window could each increment the carry, putting every uptime and wall-clock reading 2^32 ms ahead for the rest of the boot - a permanent ~49.7 day jump in rx_time, last_heard and ClientNotification.time. Readers no longer write. serviceMonotonic() publishes a snapshot behind a seqlock and is the only writer; a reader adds its own unsigned elapsed time to that snapshot, which is exact across the wrap, so it never inspects the boundary and cannot miscount it. The main loop publishes every iteration, so the once-per-49.7-days obligation now has the whole window of margin instead of resting on an instruction-wide race. AirTime was the guaranteed poller and is now a pure reader, so the two airtime wrap tests step the clock the way loop() does. The test clock itself is atomic so a suite can drive it from one thread while others read. * Re-arm the GPS ephemeris hold when none is in force The rollover sweep guarded the hold re-arm with `fixHoldEnds != 0 &&`, which reads like the sentinel rule but inverts this site. The comparison it replaced, `(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, was always true when nothing was armed - that was the point, since 0 means "not holding" and so is a reason to arm. With the guard, a publish that cleared the hold without sleeping (the `shouldPublish && !tooLong && !holdExpired` path, which does not call down()) left hasValidLocation set and prev_fixQual non-zero, so no disjunct held: nothing re-armed, nothing published, and the receiver stayed powered at the 200ms poll until searchedTooLong() fired. State the question positively instead. fixHoldInForce() is the only place the sentinel is interpreted, and both of runOnce()'s decisions derive from it - the asymmetry is now visible rather than implied, since arming does not require a prior hold but expiring does. Its `!= 0` test is not redundant with the arithmetic: deadlinePassed() is an unsigned half-range test, so past 2^31 ms of uptime the sentinel reads as a deadline ~24.9 days in the future. Kept beside its caller rather than in a header; the native test build compiles GPS.cpp, so the suite declares the prototypes. Also converts the getACK() wait to isWithinTimespanMs(start, interval): it has both the start instant and the interval in hand, which gives the full 49.7-day range instead of 24.8 days ahead, and takes its anchor from Time::getMillis() so the wait is injectable. * Date the NodeInfo reply window in uptime seconds The 12h reply-suppression stamp regressed from wrap-immune 64-bit seconds to raw 32-bit milliseconds, and pruneLastNodeInfoCache() evicts only by node count and DB membership - never by age. A stable mesh under the node cap therefore keeps every stamp indefinitely, and once uptime passes 49.7 days an old one aliases back into the window: `now - stamp` computes as ~0 and a legitimate NodeInfo request goes unanswered for up to 12h. It self-heals and repeats once per wrap cycle. Store Time::getUptimeSecs() instead, which does not wrap for 136 years, and drop the millisecond conversion the previous shape needed. Entries past the window are now evicted too: they can only ever decide "don't suppress". N8-N11 cover the window from both sides, and N10 pins the regression - it needs a full 2^32 ms of uptime to elapse, not merely a crossing of the boundary, because that is when a millisecond stamp reads as "answered this instant". tearDown() now restores the injected clock and C14's region and TX bucket. A failing assertion aborts the test body, so restoring at the end of it leaked that state into every later case. * Update the agent docs for the single-writer clock and sentinel direction Two rules the preceding three commits changed. The monotonic clock is no longer maintained by whoever happens to read it: serviceMonotonic() is the only writer, readers are pure, and calling it from anywhere but the main loop reintroduces the double-count. The sentinel guidance gained the half it was missing. It named UINT32_MAX as a sentinel while prescribing an idiom that only covers 0, and it assumed the sentinel always means "suppress" - at the GPS fix-hold site it meant "fire", which is how that regression passed review looking like the rule. * Name the fix-hold expiry predicate and arm it from the injected clock holdJustExpired() gives the second reading of the fixHoldEnds sentinel a name beside the first, so both are pinned by test/test_gps_fix_hold/ and neither can be respelled at the call site. The old inline form could not be tested: written as a literal, its guard folds at compile time and the assertion asserts nothing. The arm site used bare millis() while the evaluation reads the Throttle clock; same value in production, but it kept that write out of reach of Time::setTestMillis(). Remap a deadline that lands on 0, which would otherwise read as no hold at all. * Share the extend formula between the clock's reader and writer getMillisMonotonic() and serviceMonotonic() carried byte-identical wrap arithmetic. A one-sided edit to either would drift the published carry from what readers report, so keep one copy. * Trim the NodeInfo dedup comment to the house limit * todo note for potential future imrpovments * fix some simple deadlines * Trim the hold-expiry test comment to the house limit * Fix non-blocking uptime publication and pre-clock recency edges (#29) * fix(time): avoid blocking monotonic readers * test(time): make paused-publisher check deterministic * fix(time): address review portability gaps * Init the eviction sentinel to the newest possible recency EvictionRecency{} is {0, false}, which evictionRecencyOlder() ranks as older than every candidate: without the oldestIndex/oldestBoringIndex guards nothing would ever be selected and a full node DB would stop evicting entirely. Init to the genuine maximum instead, so the sentinel is correct on its own. The index guards stay: two independent reasons the scan is right beats one. * Keep the deadline-guard check name branch protection matches The guard was widened to cover Time::getMillis() and unqualified getMillis(), and renamed to suit. Upstream branch protection matches required checks by name, so a rename means the old name never reports and merges block on a check that will never arrive. Widen the guard, keep the name; the descriptive text carries the broader scope. * Correct native-suite-count to 47 after the develop merge Upstream #11293 added test_nmea_wpl and took develop's count to 43; this branch had independently reached 46. Merging develop resolved the counter textually, keeping 46, while the directory set became the union of both sides at 47. The suite-count CI gate fails on the mismatch, and it gates the native test jobs, so the tests themselves were being skipped. * test(uptime): make the wrap fall where the comment says it does The concurrent-reader case started at 0xFFFFF000, leaving 0x1000 to the wrap, so the 0x800 advance annotated "cross the wrap" fell short and the wrap actually happened during the following 60s advance. Start at 0xFFFFF800 instead, so the first advance lands exactly on the wrap while the readers are running and the second is the ordinary time after it - the shape both comments already described. Total elapsed is unchanged, so the closing assertion still holds. * Respond to human comments * Did I ever tell you about the time I went to Shelbyville? I wore an onion on my belt, which was the style at the time. * Convert the I2S nag deadline develop dragged in The HAS_I2S_SPEAKER_NRF52 RTTTL block arrived from develop with a raw nagCycleCutoff >= millis(), which the deadline guard rejects. Use the same Throttle::deadlinePassed() form as the two sibling paths in this function. * Arm the LittleFS format guard with a flag, not a zero timestamp preFSBegin() runs in the first millisecond of boot, so millis() can legitimately return 0 there. Both readers of last_format_ms treated 0 as "nothing formatted this boot", which would skip the repeat-corruption escalation and let a dead flash reformat-loop instead of reporting FLASH_CORRUPTION_UNRECOVERABLE. * Note the single-thread contract on AirTime * Note the AirTime locking TODO, and tighten the thread note The two constant getters are not constrained, and getSilentMinutes() reads the buckets without rotating them, so "the accessors mutate" was not accurate. * trunk: ignore trufflehog false positives on millis-wrap test constants test_throttle and test_uptime_clock pin dense clusters of hex boundary constants (0xFFFFFF00u and neighbors) to exercise 32-bit millis() rollover. trufflehog's Lob detector stitches nearby hex literals into one candidate string, and the result happens to match a Lob API key shape - not a secret, just test fixtures. Same pattern already used for the gitleaks/nodedb-fixture false positive in this file. --------- Co-authored-by: nightjoker7 Co-authored-by: Clive Blackledge Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: Thomas Göttgens Co-authored-by: Ben Meadors --- .github/copilot-instructions.md | 13 +- .github/millis-deadline-allowlist.txt | 22 ++ .github/workflows/test_native.yml | 66 ++++ .trunk/trunk.yaml | 9 + AGENTS.md | 13 +- CLAUDE.md | 13 +- src/Power.cpp | 12 +- src/PowerFSMThread.h | 7 +- src/UptimeClock.cpp | 99 ++++- src/UptimeClock.h | 63 +++- src/airtime.cpp | 164 ++++---- src/airtime.h | 10 + src/gps/GPS.cpp | 40 +- src/gps/RTC.cpp | 49 ++- src/graphics/EInkDynamicDisplay.cpp | 4 +- src/graphics/Screen.cpp | 6 +- src/graphics/draw/NotificationRenderer.cpp | 9 +- src/input/RotaryEncoderImpl.cpp | 3 +- src/main.cpp | 4 + src/mesh/MeshService.cpp | 18 +- src/mesh/MeshService.h | 4 +- src/mesh/NextHopRouter.cpp | 13 +- src/mesh/NodeDB.cpp | 116 +++++- src/mesh/NodeDB.h | 37 ++ src/mesh/PhoneAPI.cpp | 8 + src/mesh/Router.cpp | 2 +- src/mesh/Router.h | 3 +- src/mesh/Throttle.cpp | 15 +- src/mesh/Throttle.h | 44 +++ src/mesh/eth/ethClient.cpp | 5 +- src/modules/DropzoneModule.cpp | 3 +- src/modules/ExternalNotificationModule.cpp | 19 +- src/modules/NodeInfoModule.cpp | 30 +- src/modules/NodeInfoModule.h | 2 + src/modules/StatusLEDModule.cpp | 9 +- src/modules/Telemetry/DeviceTelemetry.cpp | 19 +- src/modules/Telemetry/DeviceTelemetry.h | 21 -- src/modules/Telemetry/HostMetrics.h | 4 - src/modules/Telemetry/Sensor/BME680Sensor.cpp | 7 +- src/modules/Telemetry/Sensor/BME680Sensor.h | 1 + src/motion/MotionSensor.cpp | 7 +- .../extra_variants/t5s3_epaper/variant.cpp | 28 +- src/platform/nrf52/NRF52Bluetooth.cpp | 2 +- src/platform/nrf52/main-nrf52.cpp | 19 +- test/test_airtime/test_main.cpp | 196 ++++++++++ test/test_gps_fix_hold/test_main.cpp | 218 +++++++++++ .../ports/test_timestamp.cpp | 2 +- .../test_meshpacket_serializer/test_helpers.h | 2 +- test/test_nodedb_blocked/test_main.cpp | 23 ++ test/test_packet_signing/test_main.cpp | 131 ++++++- test/test_stream_api/test_main.cpp | 73 +++- test/test_throttle/test_main.cpp | 237 ++++++++++++ test/test_uptime_clock/test_main.cpp | 356 ++++++++++++++++++ 53 files changed, 2006 insertions(+), 274 deletions(-) create mode 100644 .github/millis-deadline-allowlist.txt create mode 100644 test/test_airtime/test_main.cpp create mode 100644 test/test_gps_fix_hold/test_main.cpp create mode 100644 test/test_throttle/test_main.cpp create mode 100644 test/test_uptime_clock/test_main.cpp diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 114afd1a2..d8c5fed32 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -338,7 +338,18 @@ firmware/ - Use `assert()` for invariants that should never fail - C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.) - **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. -- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check - raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly. +- **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). + - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. + - `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`. + - `Throttle::execute(&lastMs, intervalMs, func)` - function-pointer form that updates the timestamp on fire. + - `Throttle::deadlinePassed(deadlineMs)` - for a stored absolute deadline that cannot be re-expressed as "interval since an event". Uses an unsigned half-range compare; reads deadlines more than ~24.8 days out as already passed, which no interval in this firmware approaches (the longest is 24 h). + - `Throttle::deadlinePassedAt(nowMs, deadlineMs)` - the same test against a caller-supplied `now`, for a loop that snapshots the clock once and then tests many deadlines (`NextHopRouter::doRetransmissions()`). Take the snapshot from `Time::getMillis()`, not `millis()`. + + Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: the comparison inverts while the deadline sits on the far side of the 32-bit wrap, so the action fires immediately (losing its whole wait) or blocks for roughly the interval it should have waited - days, for the nRF52 flash-corruption backoff. All five helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), which means every one of its ~94 call sites is time-injectable - a native test can drive `Time::setTestMillis(0xFFFFFF00)` across the wrap. For _timestamps_ (not deadlines) there is `Time::getMillisMonotonic()` / `Time::getUptimeSecs()` - a 64-bit monotonic uptime read. Readers are pure: they add their own wrap-immune elapsed time to a snapshot published by `Time::serviceMonotonic()`, which the main loop calls every iteration and which is **the only writer**. Never call `serviceMonotonic()` from anywhere else - two writers can count one wrap twice, putting every uptime and wall-clock reading ~49.7 days into the future for the rest of the boot. Not ISR-safe (the snapshot is read under a seqlock); see the contract in `UptimeClock.h`. Deadline and interval checks should still use `Throttle`, which needs no carry state at all. + + **Sentinel hazard.** If a deadline variable also encodes "inactive" - `0` for `rebootAtMsec`, `shutdownAtMsec`, `alertBannerUntil`, `fixHoldEnds`; `UINT32_MAX` for `nagCycleCutoff` - test that sentinel _before_ the elapsed comparison, and match the test to the sentinel actually in use. `if (deadline && Throttle::deadlinePassed(deadline))` covers the `0` family only; `nagCycleCutoff` needs `deadline != UINT32_MAX`, or a separate armed flag as `ExternalNotificationModule` does with `isNagging`. Every sentinel value is arithmetically far in the past, so a correct comparison reads it as "expired" and fires immediately: `rebootAtMsec = -1` meaning "never" is what would have become a reboot loop. Never fold the sentinel into the helper. + + **And decide which way the sentinel should fall.** "Inactive" does not always mean "suppress". At the GPS fix-hold site `fixHoldEnds == 0` means _no hold is in force_, which is exactly when a new hold must be armed - the naive comparison it replaced was `(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, always true when nothing was armed. Guarding it with `fixHoldEnds != 0 &&` looks like this rule and inverts the site: nothing re-arms, nothing publishes, and the receiver stays powered until the search timeout. Read the surrounding logic before adding the guard. `fixHoldInForce()` in `src/gps/GPS.cpp` is the worked example - state the predicate positively, so the sentinel has an honest answer, and derive both decisions from it - with `test/test_gps_fix_hold/` pinning both directions. ### Naming Conventions diff --git a/.github/millis-deadline-allowlist.txt b/.github/millis-deadline-allowlist.txt new file mode 100644 index 000000000..1363acec0 --- /dev/null +++ b/.github/millis-deadline-allowlist.txt @@ -0,0 +1,22 @@ +# Allowlist for the millis-deadline-check guard in .github/workflows/test_native.yml. +# +# That guard rejects comparisons made directly against millis(), because they invert while the +# deadline sits on the far side of the 32-bit wrap. Use Throttle::deadlinePassed(deadline) or +# Throttle::hasElapsed(lastEvent, intervalMs) instead - see .github/copilot-instructions.md. +# +# Only add a line here when the comparison genuinely is not a deadline test. The usual valid case is +# an *uptime threshold*: "has the device been up for at least N ms", where there is no stored +# deadline and no event to measure from. Those still misbehave briefly after a wrap - the threshold +# is simply re-crossed - which is harmless for boot-holdoff logic and not worth new state. +# +# Format: +# Line numbers are deliberately absent so edits above an entry do not invalidate it. A `#` comment +# on the code line is stripped before matching, so do not include one here. + +# Boot holdoff, not a deadline: suppresses a phantom shutdown from floating pins during the first +# 30s of uptime. Pairs with the buttonPressStartTime > 30000 test on the same line. +src/input/ButtonThread.cpp if (millis() > 30000 && buttonPressStartTime > 30000 && _longLongPress != INPUT_BROKER_NONE && + +# Boot-window check, not a deadline: draws the custom OEM logo only during the first 10s of uptime, +# so the ordinary Meshtastic logo is used at shutdown. +src/graphics/niche/InkHUD/Applets/System/Logo/LogoApplet.cpp if (millis() < 10 * 1000UL) { diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 2e171e46a..04e6b3a23 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -79,6 +79,72 @@ jobs: done <<<"$removed" exit $fail + # Reject naive deadline comparisons against the 32-bit uptime clocks. `millis() > deadline` and + # `deadline < millis()` invert while the deadline sits on the far side of the 32-bit wrap: the + # action fires immediately, or blocks for about the interval it should have waited. The correct + # forms are + # Throttle::isWithinTimespanMs / hasElapsed (elapsed since a stored event) and + # Throttle::deadlinePassed (an absolute deadline). See .github/copilot-instructions.md. + millis-deadline-check: + # Name is load-bearing: upstream branch protection matches the check by name. Widen the guard, + # not this string. + name: Naive millis() Deadline Compare + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Reject 32-bit uptime clocks used directly in a deadline comparison + shell: bash + run: | + set -euo pipefail + allowlist=".github/millis-deadline-allowlist.txt" + + # Flag millis() or its Time::getMillis() wrapper directly adjacent to a comparison + # operator, in either order. The correct idioms subtract first, so they are not matched. + # + # Line comments are stripped before matching, so prose may name the broken idiom (this + # guard's own documentation does). Block comments are not stripped; keep `millis() >` out + # of /* */ blocks. mawk-compatible - ubuntu-latest has no gawk. + find src -type f \( -name '*.cpp' -o -name '*.h' -o -name '*.hpp' -o -name '*.ino' \) \ + ! -path 'src/mesh/generated/*' -print0 | + xargs -0 awk ' + { + line = $0 + sub(/\/\/.*/, "", line) + if (line ~ /((millis|getMillis)\(\)[ \t]*[<>]=?)|([<>]=?[ \t]*(millis|getMillis)\(\))/) { + code = line + sub(/^[ \t]+/, "", code); sub(/[ \t]+$/, "", code) + printf "%s\t%s\t%s\n", FILENAME, FNR, code + } + }' > /tmp/millis-hits.tsv + + # Allowlisted entries are keyed on file + exact source text, deliberately without a line + # number, so unrelated edits above them do not invalidate the entry. + : > /tmp/millis-allowed.tsv + if [[ -f $allowlist ]]; then + grep -vE '^[[:space:]]*(#|$)' "$allowlist" > /tmp/millis-allowed.tsv || true + fi + + violations=0 + while IFS=$'\t' read -r file line code; do + [[ -n ${file:-} ]] || continue + if grep -qxF "$(printf '%s\t%s' "$file" "$code")" /tmp/millis-allowed.tsv; then + continue + fi + echo "$file:$line: $code" + violations=$((violations + 1)) + done < /tmp/millis-hits.tsv + + if [[ $violations -gt 0 ]]; then + echo "::error title=Naive uptime deadline compare::$violations line(s) compare a 32-bit uptime clock directly, which inverts while the deadline is on the far side of the 32-bit wrap - the action fires immediately, or blocks for about the interval it should have waited. Use Throttle::deadlinePassed(deadline) for a stored absolute deadline, or Throttle::hasElapsed(lastEvent, intervalMs) for an interval. If a match genuinely is not a deadline test (an uptime threshold, say), add it to $allowlist with a reason." + exit 1 + fi + echo "No naive 32-bit uptime deadline comparisons in src/ (allowlist: $(wc -l < /tmp/millis-allowed.tsv) entr(y/ies))." + simulator-tests: name: Native Simulator Tests runs-on: ubuntu-24.04-arm diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 7b45c5f83..099a6a491 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -151,6 +151,15 @@ lint: - linters: [ascii-dash] paths: - src/graphics/fonts/** + # millis()-wraparound tests pin dense clusters of hex boundary constants + # (0xFFFFFF00u and neighbors). trufflehog's Lob detector stitches nearby + # hex literals into one candidate string and the result happens to match + # a Lob API key shape. Not secrets - deterministic test fixtures for the + # 32-bit rollover. + - linters: [trufflehog] + paths: + - test/test_throttle/test_main.cpp + - test/test_uptime_clock/test_main.cpp runtimes: enabled: - python@3.14.4 diff --git a/AGENTS.md b/AGENTS.md index f154b7824..5c67d124d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,18 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor - **Never edit or commit files under `src/mesh/generated/`.** They are regenerated from the [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) repo by the `update_protobufs.yml` workflow (entry point: `bin/regen-protos.sh`). Local edits will be overwritten and create merge conflicts. If a `.proto` change is needed, open a PR against the protobufs repo first, then let the workflow re-sync this repo. - **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. - **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. -- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check - raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly. +- **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). + - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. + - `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`. + - `Throttle::execute(&lastMs, intervalMs, func)` - function-pointer form that updates the timestamp on fire. + - `Throttle::deadlinePassed(deadlineMs)` - for a stored absolute deadline that cannot be re-expressed as "interval since an event". + - `Throttle::deadlinePassedAt(nowMs, deadlineMs)` - the same test against a caller-supplied `now`, for a loop that snapshots the clock once and tests many deadlines. Snapshot from `Time::getMillis()`. + + Raw `millis() > deadline` or `deadline < millis()` is rollover-unsafe: the comparison inverts while the deadline sits on the far side of the 32-bit wrap, so the action fires immediately or blocks for roughly the interval it should have waited. All five helpers subtract first, so unsigned wraparound cancels out. `Throttle` reads the clock through `Time::getMillis()` (`src/UptimeClock.h`), so all ~94 of its call sites are time-injectable and a native test can drive the wrap with `Time::setTestMillis()`. + + **Sentinel hazard.** If a deadline variable also encodes "inactive" (`0` for `rebootAtMsec`, `shutdownAtMsec`, `alertBannerUntil`, `fixHoldEnds`; `UINT32_MAX` for `nagCycleCutoff`), test that sentinel _before_ the elapsed comparison - every such value is arithmetically far in the past, so a correct comparison fires on it immediately. Match the test to the sentinel in use: `if (deadline && Throttle::deadlinePassed(deadline))` covers the `0` family, `nagCycleCutoff` needs `deadline != UINT32_MAX` or a separate armed flag (`isNagging`). + + Then decide which way the sentinel should fall - "inactive" does not always mean "suppress". At the GPS fix-hold site `fixHoldEnds == 0` means _no hold is in force_, which is exactly when one must be armed; guarding it with `fixHoldEnds != 0 &&` looks like this rule and inverts the site. See `fixHoldInForce()` in `src/gps/GPS.cpp` and `test/test_gps_fix_hold/`. ## Typical agent workflows diff --git a/CLAUDE.md b/CLAUDE.md index c7150cd2d..325fb7100 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,12 +11,13 @@ > > **Need this? It's here.** > -> | | | -> | ------------------------------------------- | ---------------------------------------------------------- | -> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | -> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | -> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | -> | Observer / event wiring | `src/Observer.h` | +> | | | +> | --------------------------------------------------------- | ---------------------------------------------------------- | +> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` | +> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` | +> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` | +> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` | +> | Observer / event wiring | `src/Observer.h` | **Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions, the build system, CI/CD, the native C++ test suite, and the MCP Server & Hardware Test Harness. Read it top-to-bottom before starting any non-trivial change. diff --git a/src/Power.cpp b/src/Power.cpp index d347e7f93..aa752cf63 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -837,12 +837,13 @@ bool Power::setup() void Power::powerCommandsCheck() { - if (rebootAtMsec && millis() > rebootAtMsec) { + // 0 means "not scheduled" for both, and reads as long expired - test it first. + if (rebootAtMsec && Throttle::deadlinePassed(rebootAtMsec)) { LOG_INFO("Rebooting"); reboot(); } - if (shutdownAtMsec && millis() > shutdownAtMsec) { + if (shutdownAtMsec && Throttle::deadlinePassed(shutdownAtMsec)) { shutdownAtMsec = 0; shutdown(); } @@ -884,9 +885,10 @@ void Power::reboot() #elif defined(ARCH_STM32) HAL_NVIC_SystemReset(); #else - rebootAtMsec = -1; - LOG_WARN("FIXME implement reboot for this platform; some settings " - "need restart to apply"); + // 0 disarms; UINT32_MAX would read as long expired and reboot-loop. + rebootAtMsec = 0; + LOG_WARN("FIXME implement reboot for this platform. Note that some settings " + "require a restart to be applied"); #endif } diff --git a/src/PowerFSMThread.h b/src/PowerFSMThread.h index 47c45c262..60a52e71d 100644 --- a/src/PowerFSMThread.h +++ b/src/PowerFSMThread.h @@ -5,6 +5,7 @@ #include "concurrency/OSThread.h" #include "configuration.h" #include "main.h" +#include "mesh/Throttle.h" namespace concurrency { @@ -29,9 +30,9 @@ class PowerFSMThread : public OSThread if (powerStatus->getHasUSB()) { timeLastPowered = millis(); } else if (config.power.on_battery_shutdown_after_secs > 0 && config.power.on_battery_shutdown_after_secs != UINT32_MAX && - millis() > (timeLastPowered + - Default::getConfiguredOrDefaultMs( - config.power.on_battery_shutdown_after_secs))) { // shutdown after 30 minutes unpowered + Throttle::hasElapsed( + timeLastPowered, + Default::getConfiguredOrDefaultMs(config.power.on_battery_shutdown_after_secs))) { // unpowered too long powerFSM.trigger(EVENT_SHUTDOWN); } diff --git a/src/UptimeClock.cpp b/src/UptimeClock.cpp index 5f85f50ff..45f9affad 100644 --- a/src/UptimeClock.cpp +++ b/src/UptimeClock.cpp @@ -1,33 +1,98 @@ // See UptimeClock.h for the full contract. #include "UptimeClock.h" #include +#include uint32_t Time::getMillis() { #ifdef PIO_UNIT_TESTING - if (Time::useTestClock) - return Time::testNowMs; + if (Time::useTestClock.load(std::memory_order_relaxed)) + return Time::testNowMs.load(std::memory_order_relaxed); #endif return millis(); } -uint64_t Time::getMillis64() +namespace { - static uint32_t lastLow = 0; // last 32-bit sample - static uint32_t highWord = 0; // number of observed wraps +struct PublishedSnapshot { + std::atomic high{0}; + std::atomic low{0}; +}; - uint32_t now = Time::getMillis(); +// The constexpr atomic initializers make both snapshots available before firmware startup. +PublishedSnapshot published[2]; +std::atomic publishedGeneration{0}; #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; - } +std::atomic monotonicPublishHook{nullptr}; #endif - if (now < lastLow) - highWord++; // low word wrapped since last call - lastLow = now; - return (static_cast(highWord) << 32) | now; + +// Extend a published (high, low) snapshot to `now`; unsigned subtraction is exact across the wrap +// for any gap under 49.7 days. One copy, because reader and writer must agree on it exactly. +uint64_t extendPublished(uint32_t high, uint32_t low, uint32_t now) +{ + return ((((uint64_t)high << 32) | low) + (uint32_t)(now - low)); } + +// A generation change means the writer completed a publish while this copy was being read. A +// paused publish leaves the generation unchanged and writes only the inactive snapshot. +void readPublished(uint32_t &high, uint32_t &low) +{ + for (;;) { + const uint32_t before = publishedGeneration.load(std::memory_order_acquire); + PublishedSnapshot &snapshot = published[before & 1u]; + high = snapshot.high.load(std::memory_order_relaxed); + low = snapshot.low.load(std::memory_order_relaxed); + std::atomic_thread_fence(std::memory_order_acquire); + if (publishedGeneration.load(std::memory_order_relaxed) == before) + return; + } +} +} // namespace + +uint64_t Time::getMillisMonotonic() +{ + uint32_t high, low; + readPublished(high, low); + // The reader writes nothing back; it just extends the last published carry to now. + return extendPublished(high, low, getMillis()); +} + +uint32_t Time::getUptimeSecs() +{ + return (uint32_t)(getMillisMonotonic() / 1000); +} + +void Time::serviceMonotonic() +{ + const uint32_t generation = publishedGeneration.load(std::memory_order_relaxed); + PublishedSnapshot &active = published[generation & 1u]; + const uint32_t low = active.low.load(std::memory_order_relaxed); + const uint32_t high = active.high.load(std::memory_order_relaxed); + const uint64_t next = extendPublished(high, low, getMillis()); + + PublishedSnapshot &inactive = published[(generation + 1u) & 1u]; + inactive.high.store((uint32_t)(next >> 32), std::memory_order_relaxed); + inactive.low.store((uint32_t)next, std::memory_order_relaxed); +#ifdef PIO_UNIT_TESTING + if (const auto hook = monotonicPublishHook.load(std::memory_order_relaxed)) + hook(); +#endif + publishedGeneration.store(generation + 1u, std::memory_order_release); +} + +#ifdef PIO_UNIT_TESTING +void Time::resetMonotonicForTests() +{ + publishedGeneration.store(0, std::memory_order_relaxed); + for (auto &snapshot : published) { + snapshot.high.store(0, std::memory_order_relaxed); + snapshot.low.store(0, std::memory_order_relaxed); + } + monotonicPublishHook.store(nullptr, std::memory_order_relaxed); +} + +void Time::setMonotonicPublishHookForTests(MonotonicPublishHook hook) +{ + monotonicPublishHook.store(hook, std::memory_order_relaxed); +} +#endif diff --git a/src/UptimeClock.h b/src/UptimeClock.h index 9329a7bf6..efc04eb89 100644 --- a/src/UptimeClock.h +++ b/src/UptimeClock.h @@ -1,46 +1,69 @@ #pragma once #include +#ifdef PIO_UNIT_TESTING +#include +#endif // 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 +// Test-only virtual clock; OFF by default so suites relying on real time are unaffected. Atomic so +// a suite can step the clock from one thread while others read it - the concurrent-reader cases in +// test_uptime_clock/ do exactly that. +inline std::atomic testNowMs{0}; +inline std::atomic useTestClock{false}; +using MonotonicPublishHook = void (*)(); inline void setTestMillis(uint32_t ms) { - testNowMs = ms; - useTestClock = true; - clockSourceChanged = true; + testNowMs.store(ms, std::memory_order_relaxed); + useTestClock.store(true, std::memory_order_relaxed); } 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; + testNowMs.fetch_add(deltaMs, std::memory_order_relaxed); + useTestClock.store(true, std::memory_order_relaxed); } // 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; + useTestClock.store(false, std::memory_order_relaxed); + testNowMs.store(0, std::memory_order_relaxed); } +// Zero the published wrap carry. Suites that assert absolute uptime values call this in setUp(): +// a previous case that moved the test clock backwards left a counted wrap behind. +void resetMonotonicForTests(); +void setMonotonicPublishHookForTests(MonotonicPublishHook hook); #endif -/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis(). +/// Milliseconds since boot, 32-bit (wraps ~49.7 days). Drop-in for millis(). For "has this interval +/// elapsed / deadline arrived" use Throttle (isWithinTimespanMs / hasElapsed / deadlinePassed), +/// which is wrap-correct with no carry state at all. 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(); +/// Milliseconds since boot as a monotonic 64-bit count. +/// +/// A pure read: it derives its answer from a complete snapshot published by serviceMonotonic() +/// plus the unsigned elapsed time since that snapshot, which is exact across the wrap. A reader +/// that preempts publication uses the previous snapshot. If publication completes during a copy, +/// the reader retries; it never waits for a publish in progress. +/// +/// Not intended for ISR call sites because lock-free std::atomic operations are not guaranteed by +/// every supported toolchain. ISRs use getMillis(); the publication protocol itself never waits. +uint64_t getMillisMonotonic(); + +/// Whole seconds since boot, derived from getMillisMonotonic() (~136 years of range). This is +/// the unit to store when an instant must be dated before the wall clock is trustworthy. +uint32_t getUptimeSecs(); + +/// Advances the published wrap carry. THE ONLY WRITER - call it from the main loop and nowhere +/// else. Two concurrent callers could count one wrap twice, jumping every uptime and wall-clock +/// reading ~49.7 days forward for the rest of the boot. +/// +/// Must run at least once per ~49.7-day wrap window; the main loop calls it every iteration. +void serviceMonotonic(); } // namespace Time diff --git a/src/airtime.cpp b/src/airtime.cpp index 0e0d72e20..a9b4c7dc5 100644 --- a/src/airtime.cpp +++ b/src/airtime.cpp @@ -1,6 +1,8 @@ #include "airtime.h" #include "NodeDB.h" +#include "UptimeClock.h" #include "configuration.h" +#include AirTime *airTime = NULL; @@ -11,6 +13,9 @@ uint32_t air_period_rx[PERIODS_TO_LOG]; void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms) { + // A packet may be logged immediately after waking from light sleep. Sync first so + // the packet is counted in the current wall-time bucket, not a stale awake-time bucket. + syncNow(); if (reportType == TX_LOG) { LOG_DEBUG("Packet TX: %ums", airtime_ms); @@ -33,47 +38,112 @@ void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms) uint8_t AirTime::currentPeriodIndex() { - return ((getSecondsSinceBoot() / SECONDS_PER_PERIOD) % PERIODS_TO_LOG); + return ((secSinceBoot / SECONDS_PER_PERIOD) % PERIODS_TO_LOG); } uint8_t AirTime::getPeriodUtilMinute() { - return (getSecondsSinceBoot() / 10) % CHANNEL_UTILIZATION_PERIODS; + return (secSinceBoot / 10) % CHANNEL_UTILIZATION_PERIODS; } uint8_t AirTime::getPeriodUtilHour() { - return (getSecondsSinceBoot() / 60) % MINUTES_IN_HOUR; + return (secSinceBoot / 60) % MINUTES_IN_HOUR; } void AirTime::airtimeRotatePeriod() { + // Preserve the public helper while keeping all rotation logic in one monotonic-time path. + syncNow(); +} - if (this->airtimes.lastPeriodIndex != this->currentPeriodIndex()) { - LOG_DEBUG("Rotate airtimes to a new period = %u", this->currentPeriodIndex()); +void AirTime::syncNow() +{ + // Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move + // airtime accounting. Pure read; the main loop publishes the wrap carry it derives from. + uint32_t nowSecs = Time::getUptimeSecs(); - for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) { - this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i]; - this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i]; - this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i]; - - air_period_tx[i + 1] = this->airtimes.periodTX[i]; - air_period_rx[i + 1] = this->airtimes.periodRX[i]; - } - - this->airtimes.periodTX[0] = 0; - this->airtimes.periodRX[0] = 0; - this->airtimes.periodRX_ALL[0] = 0; - - air_period_tx[0] = 0; - air_period_rx[0] = 0; + if (firstTime) { + memset(this->utilizationTX, 0, sizeof(this->utilizationTX)); + memset(this->channelUtilization, 0, sizeof(this->channelUtilization)); + memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX)); + memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX)); + memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL)); + memset(air_period_tx, 0, sizeof(air_period_tx)); + memset(air_period_rx, 0, sizeof(air_period_rx)); + this->secSinceBoot = nowSecs; + this->lastUtilPeriod = this->getPeriodUtilMinute(); + this->lastUtilPeriodTX = this->getPeriodUtilHour(); this->airtimes.lastPeriodIndex = this->currentPeriodIndex(); + firstTime = false; + return; } + + if (nowSecs == this->secSinceBoot) { + return; + } + + uint32_t oldSecSinceBoot = this->secSinceBoot; + this->secSinceBoot = nowSecs; + + // Historical airtime reports use 1-hour buckets. If multiple hours elapsed while + // asleep, rotate each crossed bucket or clear the whole report window. + uint32_t elapsedAirtimePeriods = (this->secSinceBoot / SECONDS_PER_PERIOD) - (oldSecSinceBoot / SECONDS_PER_PERIOD); + if (elapsedAirtimePeriods >= PERIODS_TO_LOG) { + memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX)); + memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX)); + memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL)); + memset(air_period_tx, 0, sizeof(air_period_tx)); + memset(air_period_rx, 0, sizeof(air_period_rx)); + } else { + while (elapsedAirtimePeriods-- > 0) { + LOG_DEBUG("Rotate airtimes to a new period = %u", this->currentPeriodIndex()); + for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) { + this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i]; + this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i]; + this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i]; + air_period_tx[i + 1] = this->airtimes.periodTX[i]; + air_period_rx[i + 1] = this->airtimes.periodRX[i]; + } + + this->airtimes.periodTX[0] = 0; + this->airtimes.periodRX[0] = 0; + this->airtimes.periodRX_ALL[0] = 0; + air_period_tx[0] = 0; + air_period_rx[0] = 0; + } + } + this->airtimes.lastPeriodIndex = this->currentPeriodIndex(); + + // Channel utilization is a rolling 60-second view split into six 10-second buckets. + // Clear every bucket crossed while asleep so old airtime decays by real elapsed time. + uint32_t elapsedUtilPeriods = (this->secSinceBoot / 10) - (oldSecSinceBoot / 10); + if (elapsedUtilPeriods >= CHANNEL_UTILIZATION_PERIODS) { + memset(this->channelUtilization, 0, sizeof(this->channelUtilization)); + } else { + for (uint32_t i = 1; i <= elapsedUtilPeriods; i++) { + this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0; + } + } + this->lastUtilPeriod = this->getPeriodUtilMinute(); + + // TX utilization is a rolling 60-minute view used by duty-cycle checks. + uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60); + if (elapsedUtilTXPeriods >= MINUTES_IN_HOUR) { + memset(this->utilizationTX, 0, sizeof(this->utilizationTX)); + } else { + for (uint32_t i = 1; i <= elapsedUtilTXPeriods; i++) { + this->utilizationTX[((oldSecSinceBoot / 60) + i) % MINUTES_IN_HOUR] = 0; + } + } + this->lastUtilPeriodTX = this->getPeriodUtilHour(); } uint32_t *AirTime::airtimeReport(reportTypes reportType) { + // Reports may be requested before runOnce() executes after wake. + syncNow(); if (reportType == TX_LOG) { return this->airtimes.periodTX; @@ -97,11 +167,16 @@ uint32_t AirTime::getSecondsPerPeriod() uint32_t AirTime::getSecondsSinceBoot() { + // Keep HTTP/debug reporting aligned with the same monotonic clock used by the buckets. + syncNow(); return this->secSinceBoot; } float AirTime::channelUtilizationPercent() { + // Gate decisions should see buckets that have decayed across light-sleep time. + syncNow(); + uint32_t sum = 0; for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) { sum += this->channelUtilization[i]; @@ -112,6 +187,9 @@ float AirTime::channelUtilizationPercent() float AirTime::utilizationTXPercent() { + // Duty-cycle checks use this value, so keep it current even outside the periodic thread. + syncNow(); + uint32_t sum = 0; for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) { sum += this->utilizationTX[i]; @@ -162,50 +240,6 @@ AirTime::AirTime() : concurrency::OSThread("AirTime"), airtimes({}) {} int32_t AirTime::runOnce() { - secSinceBoot++; - - uint8_t utilPeriod = this->getPeriodUtilMinute(); - uint8_t utilPeriodTX = this->getPeriodUtilHour(); - - if (firstTime) { - - // Init utilizationTX window to all 0 - for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) { - this->utilizationTX[i] = 0; - } - - // Init channelUtilization window to all 0 - for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) { - this->channelUtilization[i] = 0; - } - - // Init airtime windows to all 0 - for (int i = 0; i < PERIODS_TO_LOG; i++) { - this->airtimes.periodTX[i] = 0; - this->airtimes.periodRX[i] = 0; - this->airtimes.periodRX_ALL[i] = 0; - - // air_period_tx[i] = 0; - // air_period_rx[i] = 0; - } - - firstTime = false; - lastUtilPeriod = utilPeriod; - } else { - this->airtimeRotatePeriod(); - - // Reset the channelUtilization window when we roll over - if (lastUtilPeriod != utilPeriod) { - lastUtilPeriod = utilPeriod; - - this->channelUtilization[utilPeriod] = 0; - } - - if (lastUtilPeriodTX != utilPeriodTX) { - lastUtilPeriodTX = utilPeriodTX; - - this->utilizationTX[utilPeriodTX] = 0; - } - } + syncNow(); return (1000 * 1); } diff --git a/src/airtime.h b/src/airtime.h index 8e3e6c557..39c1d3e03 100644 --- a/src/airtime.h +++ b/src/airtime.h @@ -39,6 +39,12 @@ void logAirtime(reportTypes reportType, uint32_t airtime_ms); uint32_t *airtimeReport(reportTypes reportType); +// Not thread-safe: everything but getPeriodsToLog()/getSecondsPerPeriod() either rotates the +// windows via syncNow() or reads the buckets. Current callers are all on the OSThread scheduler - +// RadioLibInterface/SimRadio, RadioInterface, Router, DeviceTelemetry, ContentHandler, and the +// screen renderers. New callers must be on that thread too, or this needs a lock. +// TODO: airtime lock-guarding - serialise the above behind a lock so the contract is enforced +// rather than documented. Kept out of this PR: it is a separate concern from millis() rollover. class AirTime : private concurrency::OSThread { @@ -66,6 +72,8 @@ class AirTime : private concurrency::OSThread bool firstTime = true; uint8_t lastUtilPeriod = 0; uint8_t lastUtilPeriodTX = 0; + // Time::getUptimeSecs() as of the last syncNow(); the gap since is what the windows rotate by, + // so they stay correct even if the scheduler was paused by light sleep. uint32_t secSinceBoot = 0; uint8_t max_channel_util_percent = 40; uint8_t polite_channel_util_percent = 25; @@ -81,6 +89,8 @@ class AirTime : private concurrency::OSThread uint8_t getPeriodUtilMinute(); uint8_t getPeriodUtilHour(); uint8_t currentPeriodIndex(); + // Advance rolling airtime windows from monotonic uptime, not from runOnce() calls. + void syncNow(); protected: virtual int32_t runOnce() override; diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 2ca1d86d1..69000f2fe 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -10,6 +10,7 @@ #include "NodeDB.h" #include "PowerMon.h" #include "Throttle.h" +#include "UptimeClock.h" #include "buzz.h" #include "concurrency/Periodic.h" #include "gps/RTC.h" @@ -350,11 +351,13 @@ GPS_RESPONSE GPS::getACK(const char *message, uint32_t waitMillis) uint8_t buffer[768] = {0}; uint8_t b; int bytesRead = 0; - uint32_t startTimeout = millis() + waitMillis; + // Start stamp + interval rather than a stored deadline: same wrap-safety, but the full 49.7-day + // range instead of 24.8 days ahead, and Time::getMillis() makes the wait injectable. + const uint32_t waitStartMs = Time::getMillis(); #if GPS_DEBUG std::string debugmsg = ""; #endif - while (millis() < startTimeout) { + while (Throttle::isWithinTimespanMs(waitStartMs, waitMillis)) { if (_serial_gps->available()) { b = _serial_gps->read(); @@ -1422,6 +1425,29 @@ void GPS::publishUpdate() } } +/// Is a post-lock ephemeris hold currently in force? The `!= 0` is the "never armed" sentinel, which +/// deadlinePassed() reads as passed for the first half of each wrap cycle and as ~24.8 days in the +/// future for the second. No header: test_gps_fix_hold declares the prototypes itself. +bool fixHoldInForce(uint32_t fixHoldEnds, uint32_t threadIntervalMs) +{ + return fixHoldEnds != 0 && !Throttle::deadlinePassed(fixHoldEnds + threadIntervalMs); +} + +/// Did an armed hold just expire? `!= 0` guards against negating fixHoldInForce() alone, which would +/// call an unarmed hold "expired" every cycle. No grace interval: the deadline itself is go-down time. +bool holdJustExpired(uint32_t fixHoldEnds) +{ + return fixHoldEnds != 0 && !fixHoldInForce(fixHoldEnds, 0); +} + +/// Should a post-lock ephemeris hold be (re-)armed this cycle? "No hold in force" fires often, since +/// every publish clears the hold, including ones that don't put the receiver back to sleep. +bool shouldArmFixHold(bool hasValidLocation, uint8_t prevFixQual, uint32_t fixHoldEnds, uint32_t threadIntervalMs) +{ + // First lock of a cycle, first lock after the receiver was off, or nothing holding right now. + return !hasValidLocation || prevFixQual == 0 || !fixHoldInForce(fixHoldEnds, threadIntervalMs); +} + int32_t GPS::runOnce() { #if defined(SENSECAP_INDICATOR) @@ -1522,13 +1548,15 @@ int32_t GPS::runOnce() if (updateInterval <= GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS) { hasValidLocation = true; shouldPublish = true; - } else if (!hasValidLocation || prev_fixQual == 0 || (fixHoldEnds + GPS_THREAD_INTERVAL) < millis()) { + } else if (shouldArmFixHold(hasValidLocation, prev_fixQual, fixHoldEnds, GPS_THREAD_INTERVAL)) { hasValidLocation = true; // Hold for up to 20secs after getting a lock to download ephemeris etc uint32_t holdTime = updateInterval - GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS; if (holdTime > GPS_FIX_HOLD_MAX_MS) holdTime = GPS_FIX_HOLD_MAX_MS; - fixHoldEnds = millis() + holdTime; + // Same clock the Throttle evaluation reads, and never the "no hold" sentinel. + const uint32_t holdEnds = Time::getMillis() + holdTime; + fixHoldEnds = holdEnds == 0 ? 1 : holdEnds; LOG_DEBUG_GPS("Holding for %ums after lock", holdTime); } } @@ -1546,7 +1574,7 @@ int32_t GPS::runOnce() } // Hold has expired , Search time has expired, we got a time only, or we never needed to hold. - bool holdExpired = (fixHoldEnds != 0 && millis() > fixHoldEnds); + bool holdExpired = holdJustExpired(fixHoldEnds); if (shouldPublish || tooLong || holdExpired) { if (gotTime && hasValidLocation) { shouldPublish = true; @@ -1563,7 +1591,7 @@ int32_t GPS::runOnce() #if GPS_DEBUG } else if (fixHoldEnds != 0) { - LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - millis(), p.sats_in_view); + LOG_DEBUG("Holding for GPS data download: %d ms (numSats=%d)", fixHoldEnds - Time::getMillis(), p.sats_in_view); #endif } } diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 99153e764..5c18cca62 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -1,10 +1,12 @@ #include "gps/RTC.h" +#include "UptimeClock.h" #include "configuration.h" #include "detect/ScanI2C.h" #include "detect/ScanI2CTwoWire.h" #include "gps/GPSLog.h" #include "main.h" #include "mesh/MeshService.h" +#include "mesh/NodeDB.h" #include "modules/NodeInfoModule.h" #include #include @@ -26,9 +28,12 @@ static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQual LOG_DEBUG("Time source acquired (%s -> %s), recheck NodeInfo", RtcName(oldQuality), RtcName(newQuality)); nodeInfoModule->triggerImmediateNodeInfoCheck(); } - if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet && service) { + if (oldQuality < RTCQualityFromNet && newQuality >= RTCQualityFromNet) { LOG_DEBUG("RTC net quality reached (%s -> %s), reconciling rx_time", RtcName(oldQuality), RtcName(newQuality)); - service->reconcilePendingRxTimes(); + if (service) + service->reconcilePendingRxTimes(); + if (nodeDB) + nodeDB->backfillHeardAt(); } } @@ -38,8 +43,9 @@ RTCQuality getRTCQuality() } // stuff that really should be in in the instance instead... -static uint32_t - timeStartMsec; // Once we have a GPS lock, this is where we hold the initial msec clock that corresponds to that time +// The Time::getMillisMonotonic() instant corresponding to zeroOffsetSecs. 64-bit so getTime()'s +// elapsed term cannot wrap: a 32-bit anchor walks the wall clock back 49.7 days per millis() cycle. +static uint64_t timeStartMs64; static uint64_t zeroOffsetSecs; // GPS based time in secs since 1970 - only updated once on initial lock #ifdef PIO_UNIT_TESTING @@ -71,11 +77,11 @@ static struct timeval mockSystemTime = {}; { struct timeval tv; if (readSystemTime(&tv)) { - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms if (currentQuality == RTCQualityNone) { LOG_DEBUG("Seed time from system clock: %lu", (unsigned long)printableEpoch); - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; } else { LOG_DEBUG("Ignore system clock fallback (%lu); RTC quality is %s", (unsigned long)printableEpoch, @@ -101,7 +107,7 @@ RTCSetResult readFromRTC() [[maybe_unused]] struct timeval tv; /* btw settimeofday() is helpful here too*/ #ifdef RV3028_RTC if (rtc_found.address == RV3028_RTC) { - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); Melopero_RV3028 rtc; #if WIRE_INTERFACES_COUNT == 2 rtc.initI2C(*ScanI2CTwoWire::fetchI2CBus(rtc_found)); @@ -132,7 +138,7 @@ RTCSetResult readFromRTC() t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); @@ -150,7 +156,7 @@ RTCSetResult readFromRTC() SensorPCF85063 rtc; #endif - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); #if WIRE_INTERFACES_COUNT == 2 rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found)); @@ -178,7 +184,7 @@ RTCSetResult readFromRTC() t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch); if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); @@ -189,7 +195,7 @@ RTCSetResult readFromRTC() } #elif defined(RX8130CE_RTC) if (rtc_found.address == RX8130CE_RTC) { - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); #ifdef MUZI_BASE ArtronShop_RX8130CE rtc(&Wire1); #else @@ -214,7 +220,7 @@ RTCSetResult readFromRTC() #endif if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); @@ -224,7 +230,7 @@ RTCSetResult readFromRTC() } #elif HAS_LSE if (stm32wlRtcAvailable()) { - uint32_t now = millis(); + const uint64_t now = Time::getMillisMonotonic(); tv.tv_sec = STM32RTC::getInstance().getEpoch(); tv.tv_usec = 0; uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms @@ -239,7 +245,7 @@ RTCSetResult readFromRTC() #endif if (currentQuality == RTCQualityNone) { RTCQuality oldQuality = currentQuality; - timeStartMsec = now; + timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); @@ -264,7 +270,8 @@ RTCSetResult readFromRTC() RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpdate) { static uint32_t lastSetMsec = 0; - uint32_t now = millis(); + const uint64_t now64 = Time::getMillisMonotonic(); + const uint32_t now = (uint32_t)now64; // low word == getMillis(); fine for the Throttle-checked stamps below uint32_t printableEpoch = tv->tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms #ifdef BUILD_EPOCH if (tv->tv_sec < BUILD_EPOCH) { @@ -314,7 +321,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd } // This delta value works on all platforms - timeStartMsec = now; + timeStartMs64 = now64; zeroOffsetSecs = tv->tv_sec; // If this platform has a settable RTC, set it #ifdef RV3028_RTC @@ -486,10 +493,12 @@ int32_t getTZOffset() */ uint32_t getTime(bool local) { + // Both terms are 64-bit monotonic, so the elapsed time cannot wrap - see timeStartMs64. + const uint64_t elapsedSecs = (Time::getMillisMonotonic() - timeStartMs64) / 1000; if (local) { - return (((uint32_t)millis() - timeStartMsec) / 1000) + zeroOffsetSecs + getTZOffset(); + return elapsedSecs + zeroOffsetSecs + getTZOffset(); } else { - return (((uint32_t)millis() - timeStartMsec) / 1000) + zeroOffsetSecs; + return elapsedSecs + zeroOffsetSecs; } } @@ -509,7 +518,7 @@ void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot) { currentQuality = RTCQualityNone; zeroOffsetSecs = 0; - timeStartMsec = millis() - (secondsSinceBoot * 1000); + timeStartMs64 = Time::getMillisMonotonic() - ((uint64_t)secondsSinceBoot * 1000); lastSetFromPhoneNtpOrGps = 0; lastTimeValidationWarning = 0; } @@ -538,7 +547,7 @@ void setReadFromRTCUseSystemTimeForTests(bool enabled) void resetRTCStateForTests() { currentQuality = RTCQualityNone; - timeStartMsec = 0; + timeStartMs64 = 0; zeroOffsetSecs = 0; lastSetFromPhoneNtpOrGps = 0; lastTimeValidationWarning = 0; diff --git a/src/graphics/EInkDynamicDisplay.cpp b/src/graphics/EInkDynamicDisplay.cpp index be05cd0c3..c51f0a5bb 100644 --- a/src/graphics/EInkDynamicDisplay.cpp +++ b/src/graphics/EInkDynamicDisplay.cpp @@ -232,9 +232,7 @@ void EInkDynamicDisplay::checkForPromotion() // Is it too soon for another frame of this type? void EInkDynamicDisplay::checkRateLimiting() { - // Sanity check: millis() overflow - just let the update run.. - if (previousRunMs > millis()) - return; + // No millis()-overflow guard needed: the Throttle checks below are wrap-correct already. // Skip update: too soon for BACKGROUND if (frameFlags == BACKGROUND) { diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 55c565c3c..c8271ddf1 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1112,7 +1112,7 @@ int32_t Screen::runOnce() // Show boot screen for first logo_timeout seconds, then switch to normal operation. // serialSinceMsec adjusts for additional serial wait time during nRF52 bootup static bool showingBootScreen = true; - if (showingBootScreen && (millis() > (logo_timeout + serialSinceMsec))) { + if (showingBootScreen && Throttle::hasElapsed(serialSinceMsec, logo_timeout)) { LOG_INFO("Done with boot screen"); stopBootScreen(); showingBootScreen = false; @@ -1120,8 +1120,8 @@ int32_t Screen::runOnce() #ifdef USERPREFS_OEM_TEXT static bool showingOEMBootScreen = true; - if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) { - LOG_INFO("Switch to OEM screen"); + if (showingOEMBootScreen && Throttle::hasElapsed(serialSinceMsec, logo_timeout / 2)) { + LOG_INFO("Switch to OEM screen..."); // Change frames. static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen}; static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index d71ca6d08..7abfd210d 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -12,6 +12,7 @@ #include "graphics/images.h" #include "input/RotaryEncoderInterruptImpl1.h" #include "input/UpDownInterruptImpl1.h" +#include "mesh/Throttle.h" #if HAS_BUTTON #include "input/ButtonThread.h" #endif @@ -253,7 +254,7 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU // Handle text_input notifications first - they have their own timeout/banner logic if (current_notification_type == notificationTypeEnum::text_input) { // Check for timeout and reset if needed for text input - if (millis() > alertBannerUntil && alertBannerUntil > 0) { + if (alertBannerUntil > 0 && Throttle::deadlinePassed(alertBannerUntil)) { resetBanner(); return; } @@ -261,7 +262,8 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU return; } - if (millis() > alertBannerUntil && alertBannerUntil > 0) { + // 0 means "no deadline set", and reads as long expired - test it first. + if (alertBannerUntil > 0 && Throttle::deadlinePassed(alertBannerUntil)) { resetBanner(); } @@ -1226,7 +1228,8 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat bool NotificationRenderer::isOverlayBannerShowing() { - return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil); + // Here 0 means "show indefinitely", so it must short-circuit the comparison. + return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || !Throttle::deadlinePassed(alertBannerUntil)); } bool NotificationRenderer::isMenuShowing() diff --git a/src/input/RotaryEncoderImpl.cpp b/src/input/RotaryEncoderImpl.cpp index dcdbf0d36..88075c2f1 100644 --- a/src/input/RotaryEncoderImpl.cpp +++ b/src/input/RotaryEncoderImpl.cpp @@ -3,6 +3,7 @@ #include "RotaryEncoderImpl.h" #include "InputBroker.h" #include "RotaryEncoder.h" +#include "mesh/Throttle.h" #ifdef ARCH_ESP32 #include "sleep.h" #endif @@ -66,7 +67,7 @@ void RotaryEncoderImpl::pollOnce() static uint32_t lastPressed = millis(); if (rotary->readButton() == RotaryEncoder::ButtonState::BUTTON_PRESSED) { - if (lastPressed + 200 < millis()) { + if (Throttle::hasElapsed(lastPressed, 200)) { LOG_DEBUG("Rotary event Press"); lastPressed = millis(); e.inputEvent = this->eventPressed; diff --git a/src/main.cpp b/src/main.cpp index e51dee109..3a4eb5f5f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,6 +16,7 @@ #include "RadioLibInterface.h" #include "ReliableRouter.h" #include "TransmitHistory.h" +#include "UptimeClock.h" #include "airtime.h" #include "buzz.h" #include "power/PowerHAL.h" @@ -1360,6 +1361,9 @@ void loop() { runASAP = false; + // The single writer of the monotonic wrap carry; every other caller only reads it. + Time::serviceMonotonic(); + #if defined(MESHTASTIC_ENCRYPTED_STORAGE) && defined(MESHTASTIC_PHONEAPI_ACCESS_CONTROL) if (lockdownDisablePending) { lockdownDisablePending = false; diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 77540660b..162d15353 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -182,14 +182,14 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id) return nodenum; } -// Back-calculate the real epoch for any queued packet still carrying a millis() rx_time +// Back-calculate the real epoch for any queued packet still carrying an uptime-seconds 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(); + const uint32_t nowUptimeSecs = Time::getUptimeSecs(); // 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. @@ -198,11 +198,13 @@ void MeshService::reconcilePendingRxTimes() 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; + // Both stamps are monotonic uptime seconds, so the elapsed term is exact at any age. + // If it somehow exceeds the epoch, leave the packet un-dated rather than pre-1970. + const uint32_t elapsedSecs = nowUptimeSecs - p->rx_time; + if (elapsedSecs < nowEpoch) { + p->rx_time = nowEpoch - elapsedSecs; + p->has_rx_time = true; + } } if (!toPhoneQueue.enqueue(p, 0)) { // mirrors sendToPhone()'s degrade-on-failure path LOG_CRIT("Requeue to toPhoneQueue failed"); @@ -627,7 +629,7 @@ 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 + // rx_time may be an uptime-seconds 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; diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index bae955969..8ddc6e434 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -137,8 +137,8 @@ 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. + // Rewrite any queued-for-phone packet still carrying an uptime-seconds rx_time placeholder + // into a real epoch, now that the wall clock is trustworthy. void reconcilePendingRxTimes(); // Release QueueStatus packet to pool diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index d7d396f60..3be7a1ba6 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -1,6 +1,8 @@ #include "NextHopRouter.h" #include "Default.h" #include "MeshTypes.h" +#include "Throttle.h" +#include "UptimeClock.h" #include "meshUtils.h" #if !MESHTASTIC_EXCLUDE_TRACEROUTE #include "modules/TraceRouteModule.h" @@ -403,7 +405,9 @@ PendingPacket *NextHopRouter::startRetransmission(meshtastic_MeshPacket *p, uint */ int32_t NextHopRouter::doRetransmissions() { - uint32_t now = millis(); + // Same clock Throttle reads, so setNextTx() deadlines and this test can't diverge under an + // injected test clock. + uint32_t now = Time::getMillis(); int32_t d = INT32_MAX; // FIXME, we should use a better datastructure rather than walking through this map. @@ -414,8 +418,9 @@ int32_t NextHopRouter::doRetransmissions() bool stillValid = true; // assume we'll keep this record around - // FIXME, handle 51 day rolloever here!!! - if (p.nextTxMsec <= now) { + // Judged against the snapshot above, so one pass sees one instant and the 49.7 day wrap + // can't stall retransmission. + if (Throttle::deadlinePassedAt(now, p.nextTxMsec)) { if (p.numRetransmissions == 0) { if (isFromUs(p.packet)) { LOG_DEBUG("Reliable send failed, return nak fr=0x%08x,to=0x%08x,id=0x%08x", p.packet->from, p.packet->to, @@ -511,7 +516,7 @@ void NextHopRouter::setNextTx(PendingPacket *pending) { assert(iface); auto d = iface->getRetransmissionMsec(pending->packet); - pending->nextTxMsec = millis() + d; + pending->nextTxMsec = Time::getMillis() + d; LOG_TRACE("Next retransmission in %u msecs", d); printPacket("", pending->packet); setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e31df4faa..699d2fab5 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -19,6 +19,7 @@ #include "SafeFile.h" #include "TransmitHistory.h" #include "TypeConversions.h" +#include "UptimeClock.h" #include "error.h" #include "gps/RTC.h" #include "main.h" @@ -3269,7 +3270,7 @@ 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 + // rx_time may be an uptime-seconds 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; @@ -3516,16 +3517,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) { // Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it // without the user doing so deliberately. We don't normally expect users to use a CLIENT_BASE to send DMs or to add - // contacts, but we should make sure it doesn't auto-favorite in case they do. Instead, as a workaround, we'll set - // last_heard to now, so that the add_contact node doesn't immediately get evicted. - info->last_heard = getTime(); + // contacts, but we should make sure it doesn't auto-favorite in case they do. Instead, as a workaround, we'll + // stamp the contact as heard now, so that the add_contact node doesn't immediately get evicted. + stampContactHeardNow(info); } else { // Normal case: set is_favorite to prevent expiration. // last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB). - // If the protected cap refuses the favorite, fall back to stamping last_heard so the + // If the protected cap refuses the favorite, fall back to a heard-now stamp so the // contact still isn't the first eviction victim. if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) - info->last_heard = getTime(); + stampContactHeardNow(info); } // As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified @@ -3678,9 +3679,13 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) return; } - // Gate on has_rx_time, not truthiness - rx_time may hold a millis() placeholder. + // Gate on has_rx_time, not truthiness - rx_time may hold an uptime-seconds placeholder. if (mp.has_rx_time) info->last_heard = mp.rx_time; + else + // rx_time is the arrival instant in uptime seconds. It goes to the RAM sidecar, not + // last_heard, which only ever holds a real epoch or 0. + recordHeardWhileClockUntrusted(getFrom(&mp), mp.rx_time); // 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 @@ -4096,6 +4101,84 @@ meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) return meshtastic_Config_DeviceConfig_Role_CLIENT; } +void NodeDB::recordHeardWhileClockUntrusted(NodeNum num, uint32_t heardAtUptime) +{ + // Update in place if the node already has a stamp. + for (auto &h : heardAt) { + if (h.num == num) { + h.heardAtUptimeSecs = heardAtUptime; + return; + } + } + // Otherwise take an empty slot, or reuse the oldest stamp. + NodeHeardAt *victim = &heardAt[0]; + for (auto &h : heardAt) { + if (h.num == 0) { + victim = &h; + break; + } + if (h.heardAtUptimeSecs < victim->heardAtUptimeSecs) + victim = &h; + } + victim->num = num; + victim->heardAtUptimeSecs = heardAtUptime; +} + +bool NodeDB::getHeardAtUptimeSecs(NodeNum num, uint32_t &stamp) const +{ + for (const auto &h : heardAt) { + if (h.num == num) { + stamp = h.heardAtUptimeSecs; + return true; + } + } + return false; +} + +NodeDB::EvictionRecency NodeDB::evictionRecency(const meshtastic_NodeInfoLite *n) const +{ + uint32_t stamp = 0; + const bool heardThisBoot = getHeardAtUptimeSecs(n->num, stamp); + return {heardThisBoot ? stamp : n->last_heard, heardThisBoot}; +} + +bool NodeDB::evictionRecencyOlder(EvictionRecency candidate, EvictionRecency incumbent) +{ + if (candidate.heardThisBoot != incumbent.heardThisBoot) + return !candidate.heardThisBoot; + return candidate.value < incumbent.value; +} + +void NodeDB::stampContactHeardNow(meshtastic_NodeInfoLite *info) +{ + const uint32_t nowEpoch = getValidTime(RTCQualityFromNet); + if (nowEpoch) + info->last_heard = nowEpoch; + else + recordHeardWhileClockUntrusted(info->num, Time::getUptimeSecs()); +} + +void NodeDB::backfillHeardAt() +{ + const uint32_t nowEpoch = getValidTime(RTCQualityFromNet); + if (nowEpoch == 0) // called before the clock was actually valid - nothing to date against + return; + const uint32_t nowUptimeSecs = Time::getUptimeSecs(); + for (auto &h : heardAt) { + if (h.num == 0) + continue; + meshtastic_NodeInfoLite *info = getMeshNode(h.num); + if (info) { + // Both stamps are monotonic uptime seconds, so the elapsed term is exact at any age. + // Never move last_heard backwards: the node may since have been re-heard on a good clock. + const uint32_t elapsedSecs = nowUptimeSecs - h.heardAtUptimeSecs; + if (elapsedSecs < nowEpoch && nowEpoch - elapsedSecs > info->last_heard) + info->last_heard = nowEpoch - elapsedSecs; + } + h = {}; // evicted or converted either way, the stamp's job is done + } +} + /// Find a node in our DB, create an empty NodeInfo if missing meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) { @@ -4105,8 +4188,10 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) if (isFull()) { LOG_INFO("Node database full: %i nodes, %u bytes free. Erase oldest", numMeshNodes, memGet.getFreeHeap()); // look for oldest node and erase it - uint32_t oldest = UINT32_MAX; - uint32_t oldestBoring = UINT32_MAX; + // Newest-possible sentinel: a zeroed init ranks older than every candidate, so nothing + // would ever be selected. Keep it maximal even though the index guards below also cover it. + EvictionRecency oldest = {UINT32_MAX, true}; + EvictionRecency oldestBoring = {UINT32_MAX, true}; int oldestIndex = -1; int oldestBoringIndex = -1; for (int i = 1; i < numMeshNodes; i++) { @@ -4114,14 +4199,19 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) const bool isFavoriteNode = nodeInfoLiteIsFavorite(cand); const bool isIgnored = nodeInfoLiteIsIgnored(cand); const bool isVerified = nodeInfoLiteIsKeyManuallyVerified(cand); + // last_heard, except that nodes heard this boot before the clock became trusted + // rank by their RAM arrival stamp instead of the 0 in the stored field. + const EvictionRecency candRecency = evictionRecency(cand); // Simply the oldest non-favorite, non-ignored, non-verified node - if (!isFavoriteNode && !isIgnored && !isVerified && cand->last_heard < oldest) { - oldest = cand->last_heard; + if (!isFavoriteNode && !isIgnored && !isVerified && + (oldestIndex == -1 || evictionRecencyOlder(candRecency, oldest))) { + oldest = candRecency; oldestIndex = i; } // The oldest "boring" node - if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 && cand->last_heard < oldestBoring) { - oldestBoring = cand->last_heard; + if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 && + (oldestBoringIndex == -1 || evictionRecencyOlder(candRecency, oldestBoring))) { + oldestBoring = candRecency; oldestBoringIndex = i; } } diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 0d45d1e1e..e5b5a67ac 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -248,6 +248,14 @@ enum LoadFileResult { enum UserLicenseStatus { NotKnown, NotLicensed, Licensed }; +// RAM-only arrival stamp (monotonic uptime secs) for nodes heard before the wall clock was trusted, +// backfilled into last_heard as an epoch once it is. last_heard persists, so it cannot hold this. +// Bounded, linear-scan, reuse-oldest, never persisted - dies with the boot, as does its timebase. +struct NodeHeardAt { + NodeNum num = 0; ///< node this stamp describes; 0 == empty slot + uint32_t heardAtUptimeSecs = 0; ///< Time::getUptimeSecs() when last heard +}; + class NodeDB { // NodeNum provisionalNodeNum; // if we are trying to find a node num this is our current attempt @@ -308,6 +316,10 @@ class NodeDB void addFromContact(const meshtastic_SharedContact); + /// On the clock-becoming-trusted transition (see RTC.cpp): convert every RAM arrival stamp into + /// a real last_heard epoch, never backwards, then empty the table. updateFrom() takes over. + void backfillHeardAt(); + /** Update position info for this node based on received position data */ void updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src = RX_SRC_RADIO); @@ -638,6 +650,31 @@ class NodeDB uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually uint32_t lastSort = 0; // When last sorted the nodeDB + /// See NodeHeardAt. Caps how many distinct nodes can be dated once the clock arrives; a node + /// pushed out by reuse-oldest just stays "last heard: unknown", the same as before this table. + static constexpr size_t kMaxHeardAt = 32; + NodeHeardAt heardAt[kMaxHeardAt] = {}; + + /// Stamp (or re-stamp) a node's RAM arrival record; used instead of writing a non-epoch into + /// last_heard whenever the wall clock is untrusted. + void recordHeardWhileClockUntrusted(NodeNum num, uint32_t heardAtUptimeSecs); + + /// addFromContact's anti-eviction stamp: a real epoch when the clock is trusted, otherwise a + /// RAM arrival stamp that evictionRecency() honours - never a boot-relative last_heard. + void stampContactHeardNow(meshtastic_NodeInfoLite *info); + + /// Read the node's RAM arrival stamp. The boolean carries presence because uptime second 0 is valid. + bool getHeardAtUptimeSecs(NodeNum num, uint32_t &stamp) const; + + struct EvictionRecency { + uint32_t value; + bool heardThisBoot; + }; + + /// Eviction ranking with current-boot stamps newer than every persisted epoch. + EvictionRecency evictionRecency(const meshtastic_NodeInfoLite *n) const; + static bool evictionRecencyOlder(EvictionRecency candidate, EvictionRecency incumbent); + /* * Internal boolean to track sorting paused */ diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index e093d5be0..45f9b2477 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -972,6 +972,14 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) } if (infoToSend.num != 0) { + // A record prefetched before the clock became trusted carries last_heard == 0 even + // once the store is backfilled, so re-read it at send time: handshake ordering + // (time-set vs node-list download) must not decide what the phone sees. + if (infoToSend.last_heard == 0 && infoToSend.num != nodeDB->getNodeNum()) { + const meshtastic_NodeInfoLite *fresh = nodeDB->getMeshNode(infoToSend.num); + if (fresh) + infoToSend.last_heard = fresh->last_heard; + } // Just in case we stored a different user.id in the past, but should never happen going forward sprintf(infoToSend.user.id, "!%08x", infoToSend.num); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 3a938e03c..2aa6a6c63 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -318,7 +318,7 @@ PacketId generatePacketId() RxTimeStamp computeRxTimeStamp() { const bool haveTime = getRTCQuality() >= RTCQualityFromNet; - return {haveTime ? getValidTime(RTCQualityFromNet) : Time::getMillis(), haveTime}; + return {haveTime ? getValidTime(RTCQualityFromNet) : Time::getUptimeSecs(), haveTime}; } void stampRxTime(meshtastic_MeshPacket *p) diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 003aebc57..d5ea73cfe 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -21,7 +21,8 @@ bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p); bool willUsePki(const meshtastic_MeshPacket *p); /// rx_time/has_rx_time for "now": a real epoch when the clock is trustworthy, else a -/// Time::getMillis() placeholder with valid=false. +/// Time::getUptimeSecs() placeholder with valid=false. Uptime seconds are monotonic, so +/// reconciliation against a later epoch is exact at any age. struct RxTimeStamp { uint32_t time; bool valid; diff --git a/src/mesh/Throttle.cpp b/src/mesh/Throttle.cpp index a4f8347b2..606ba737e 100644 --- a/src/mesh/Throttle.cpp +++ b/src/mesh/Throttle.cpp @@ -1,4 +1,5 @@ #include "Throttle.h" +#include "UptimeClock.h" #include /// @brief Execute a function throttled to a minimum interval @@ -10,11 +11,11 @@ bool Throttle::execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, void (*throttleFunc)(void), void (*onDefer)(void)) { if (*lastExecutionMs == 0) { - *lastExecutionMs = millis(); + *lastExecutionMs = Time::getMillis(); throttleFunc(); return true; } - uint32_t now = millis(); + uint32_t now = Time::getMillis(); if ((now - *lastExecutionMs) >= minumumIntervalMs) { throttleFunc(); @@ -31,6 +32,14 @@ bool Throttle::execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, vo /// @param timeSpanMs The interval in milliseconds of the timespan bool Throttle::isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t timeSpanMs) { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); return (now - lastExecutionMs) < timeSpanMs; +} + +/// @brief Check whether an absolute deadline has arrived, correctly across the millis() wrap +/// @param deadlineMs The deadline, as a millis() value +/// See the header for the range limit and the sentinel requirement. +bool Throttle::deadlinePassed(uint32_t deadlineMs) +{ + return deadlinePassedAt(Time::getMillis(), deadlineMs); } \ No newline at end of file diff --git a/src/mesh/Throttle.h b/src/mesh/Throttle.h index 8b4bb5d30..f9d68a414 100644 --- a/src/mesh/Throttle.h +++ b/src/mesh/Throttle.h @@ -7,4 +7,48 @@ class Throttle public: static bool execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, void (*func)(void), void (*onDefer)(void) = NULL); static bool isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t intervalMs); + + /// Complement of isWithinTimespanMs(): true once intervalMs has passed since lastExecutionMs. + /// Boundary is inclusive (>=), mirroring isWithinTimespanMs()'s exclusive <. + /// Deliberately does not treat lastExecutionMs == 0 as "never run" - callers that use 0 as a + /// sentinel must test for it separately, so the sentinel never reaches the arithmetic. + static bool hasElapsed(uint32_t lastExecutionMs, uint32_t intervalMs) + { + return !isWithinTimespanMs(lastExecutionMs, intervalMs); + } + + /// True once an absolute deadline has arrived. Use this rather than comparing against millis() + /// directly: that inverts while the deadline sits on the far side of the 32-bit wrap, so the + /// action either fires immediately or blocks for about the interval it should have waited. + /// + /// Use this when the site stores a deadline; use hasElapsed() when it stores the time of the + /// last event, which allows the full ~49.7 day range instead of ~24.8 days ahead. + /// + /// Callers that overload the deadline with an "inactive" sentinel (0, or UINT32_MAX) MUST test + /// for that separately, first: every such value is arithmetically far in the past, so it reads + /// as passed. + /// + /// TODO(deadline-type): mistake-proof that MUST by giving a deadline its own one-field type - + /// Deadline::in(ms) / .armed() / .passed() / .disarm(). A hand-built `now + interval` could then + /// no longer land on the sentinel by accident, and "armed" would stay a question separate from + /// "passed" - the split that has to survive, because which way "inactive" falls is the caller's + /// to decide. Same size and cost as the bare uint32_t. The conversion sites, grouped by the four + /// meanings they give the sentinel today: + /// 0 = unarmed - Power.cpp rebootAtMsec/shutdownAtMsec (the cheapest pair to convert), and + /// GPS.cpp fixHoldEnds, whose arm site remaps a 0 result to 1 by hand. + /// 0 = forever - NotificationRenderer.cpp alertBannerUntil. Every read spells its own `> 0` + /// guard, so this third state wants naming rather than repeating. + /// 0 = due now - ethClient.cpp ntp_renew, forced at link-up. + /// UINT32_MAX - ExternalNotificationModule.cpp nagCycleCutoff, whose armed() also lives in a + /// second variable (isNagging) and whose arm site can land on the sentinel. + static bool deadlinePassed(uint32_t deadlineMs); + + /// deadlinePassed() against a caller-supplied "now", for a loop that snapshots the time once and + /// tests many deadlines against it. Same range limit and sentinel rules as above. + static bool deadlinePassedAt(uint32_t nowMs, uint32_t deadlineMs) + { + // Passed iff now - deadline has not wrapped past 2^31 ms; further-ahead deadlines land in + // the top half. Not an int32_t cast, which is implementation-defined beyond INT32_MAX. + return (uint32_t)(nowMs - deadlineMs) < 0x80000000u; + } }; \ No newline at end of file diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index bf6be0b9c..bf85eef92 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -4,6 +4,7 @@ #include "configuration.h" #include "gps/RTC.h" #include "main.h" +#include "mesh/Throttle.h" #include "mesh/api/ethServerAPI.h" #include "target_specific.h" #if HAS_ETHERNET && defined(HAS_ETHERNET_OTA) @@ -196,7 +197,9 @@ static int32_t reconnectETH() } #ifndef DISABLE_NTP - if (isEthernetAvailable() && (ntp_renew < millis())) { + // 0 here means "renew now" (forced at link-up). deadlinePassed(0) only reads as passed for the + // first half of each wrap cycle, so treat 0 as always-due rather than relying on that. + if (isEthernetAvailable() && (ntp_renew == 0 || Throttle::deadlinePassed(ntp_renew))) { LOG_INFO("Update NTP time from %s", config.network.ntp_server); if (timeClient.update()) { diff --git a/src/modules/DropzoneModule.cpp b/src/modules/DropzoneModule.cpp index 16bd83849..100b87662 100644 --- a/src/modules/DropzoneModule.cpp +++ b/src/modules/DropzoneModule.cpp @@ -12,6 +12,7 @@ #include "modules/Telemetry/Sensor/DFRobotLarkSensor.h" #include "modules/Telemetry/UnitConversions.h" +#include "mesh/Throttle.h" #include DropzoneModule *dropzoneModule; @@ -19,7 +20,7 @@ DropzoneModule *dropzoneModule; int32_t DropzoneModule::runOnce() { // Send on a 5 second delay from receiving the matching request - if (startSendConditions != 0 && (startSendConditions + 5000U) < millis()) { + if (startSendConditions != 0 && Throttle::hasElapsed(startSendConditions, 5000U)) { service->sendToMesh(sendConditions(), RX_SRC_LOCAL); startSendConditions = 0; } diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index 091a95d41..420697689 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -21,6 +21,7 @@ #include "configuration.h" #include "gps/RTC.h" #include "main.h" +#include "mesh/Throttle.h" #include "mesh/generated/meshtastic/rtttl.pb.h" #include @@ -85,7 +86,10 @@ int32_t ExternalNotificationModule::runOnce() #if defined(HAS_I2S_SPEAKER_NRF52) isRtttlPlaying = isRtttlPlaying || nrf52RtttlPlayer.isPlaying(); #endif - if ((nagCycleCutoff < millis()) && !isRtttlPlaying) { + // isNagging is the armed flag; nagCycleCutoff holds a real deadline only while it is set + // (UINT32_MAX once stopped, 1 at boot), so short-circuit before the comparison. + const bool nagWindowExpired = !isNagging || Throttle::deadlinePassed(nagCycleCutoff); + if (nagWindowExpired && !isRtttlPlaying) { // Turn off external notification immediately when timeout is reached, regardless of song state nagCycleCutoff = UINT32_MAX; ExternalNotificationModule::stopNow(); @@ -97,14 +101,15 @@ int32_t ExternalNotificationModule::runOnce() if (isNagging) { delay = (moduleConfig.external_notification.output_ms ? moduleConfig.external_notification.output_ms : EXT_NOTIFICATION_MODULE_OUTPUT_MS); - if (externalTurnedOn[0] + delay < millis()) { + // externalTurnedOn[] is when each output was last toggled, so these are intervals. + if (Throttle::hasElapsed(externalTurnedOn[0], delay)) { setExternalState(0, !getExternal(0)); } - if (externalTurnedOn[1] + delay < millis()) { + if (Throttle::hasElapsed(externalTurnedOn[1], delay)) { setExternalState(1, !getExternal(1)); } // Only toggle buzzer output if not using PWM mode (to avoid conflict with RTTTL) - if (!moduleConfig.external_notification.use_pwm && externalTurnedOn[2] + delay < millis()) { + if (!moduleConfig.external_notification.use_pwm && Throttle::hasElapsed(externalTurnedOn[2], delay)) { LOG_DEBUG("EXTERNAL 2 %d compared to %d", externalTurnedOn[2] + moduleConfig.external_notification.output_ms, millis()); setExternalState(2, !getExternal(2)); @@ -146,7 +151,7 @@ int32_t ExternalNotificationModule::runOnce() if (moduleConfig.external_notification.use_i2s_as_buzzer) { if (audioThread->isPlaying()) { // Continue playing - } else if (isNagging && (nagCycleCutoff >= millis())) { + } else if (isNagging && !Throttle::deadlinePassed(nagCycleCutoff)) { audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); } // we need fast updates to play the RTTTL @@ -158,7 +163,7 @@ int32_t ExternalNotificationModule::runOnce() if (canBuzz() && buzzerShouldAlert) { if (nrf52RtttlPlayer.isPlaying()) { nrf52RtttlPlayer.play(); - } else if (isNagging && (nagCycleCutoff >= millis())) { + } else if (isNagging && !Throttle::deadlinePassed(nagCycleCutoff)) { nrf52RtttlPlayer.begin(rtttlConfig.ringtone); } delay = EXT_NOTIFICATION_FAST_THREAD_MS; @@ -168,7 +173,7 @@ int32_t ExternalNotificationModule::runOnce() if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz() && buzzerShouldAlert) { if (rtttl::isPlaying()) { rtttl::play(); - } else if (isNagging && (nagCycleCutoff >= millis())) { + } else if (isNagging && !Throttle::deadlinePassed(nagCycleCutoff)) { // start the song again if we have time left rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); } diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index f738d2aed..7c4096959 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -34,17 +34,14 @@ 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); - // 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); + // A local dedup window, not a wall-clock reading - uptime avoids RTC jumps and replayed + // packets' stale rx_time perturbing it. Seconds, not millis - this is a wide window. + const uint32_t nowSecs = Time::getUptimeSecs(); auto it = lastNodeInfoSeen.find(sender); - if (it != lastNodeInfoSeen.end()) { - uint32_t sinceLast = now >= it->second ? now - it->second : 0; - if (sinceLast < NodeInfoReplySuppressSeconds) { - suppressReplyForCurrentRequest = true; - } + if (it != lastNodeInfoSeen.end() && (uint32_t)(nowSecs - it->second) < NodeInfoReplySuppressSeconds) { + suppressReplyForCurrentRequest = true; } - lastNodeInfoSeen[sender] = now; + lastNodeInfoSeen[sender] = nowSecs; pruneLastNodeInfoCache(); } @@ -193,19 +190,26 @@ void NodeInfoModule::pruneLastNodeInfoCache() return; const size_t maxEntries = nodeDB->meshNodes->size(); + const uint32_t nowSecs = Time::getUptimeSecs(); + // Drop entries for nodes we no longer know, and any stamp already past the suppression window: + // it can only decide "don't suppress", so keeping it buys nothing. for (auto it = lastNodeInfoSeen.begin(); it != lastNodeInfoSeen.end();) { - if (!nodeDB->getMeshNode(it->first)) { + if (!nodeDB->getMeshNode(it->first) || (uint32_t)(nowSecs - it->second) >= NodeInfoReplySuppressSeconds) { it = lastNodeInfoSeen.erase(it); } else { ++it; } } + // Evict by largest elapsed time rather than smallest stamp, so the victim is still the oldest + // entry if the uptime counter ever wraps underneath us. while (!lastNodeInfoSeen.empty() && lastNodeInfoSeen.size() > maxEntries) { - auto oldestIt = std::min_element(lastNodeInfoSeen.begin(), lastNodeInfoSeen.end(), - [](const std::pair &lhs, - const std::pair &rhs) { return lhs.second < rhs.second; }); + auto oldestIt = std::max_element( + lastNodeInfoSeen.begin(), lastNodeInfoSeen.end(), + [nowSecs](const std::pair &lhs, const std::pair &rhs) { + return (uint32_t)(nowSecs - lhs.second) < (uint32_t)(nowSecs - rhs.second); + }); lastNodeInfoSeen.erase(oldestIt); } } diff --git a/src/modules/NodeInfoModule.h b/src/modules/NodeInfoModule.h index 9b3b66cae..8653c71eb 100644 --- a/src/modules/NodeInfoModule.h +++ b/src/modules/NodeInfoModule.h @@ -50,6 +50,8 @@ class NodeInfoModule : public ProtobufModule, private concurren private: bool shorterTimeout = false; bool suppressReplyForCurrentRequest = false; + /// Sender -> uptime seconds (Time::getUptimeSecs()) at our last reply. Seconds, not millis: + /// the suppression window is hours wide. See handleReceivedProtobuf(). std::map lastNodeInfoSeen; void pruneLastNodeInfoCache(); diff --git a/src/modules/StatusLEDModule.cpp b/src/modules/StatusLEDModule.cpp index 5c6f84942..3a09fb862 100644 --- a/src/modules/StatusLEDModule.cpp +++ b/src/modules/StatusLEDModule.cpp @@ -2,6 +2,7 @@ #include "MeshService.h" #include "configuration.h" #include "mesh/RadioInterface.h" +#include "mesh/Throttle.h" #include /* @@ -118,7 +119,7 @@ int32_t StatusLEDModule::runOnce() } else if (power_state == charged) { CHARGE_LED_state = LED_STATE_ON; } else if (power_state == critical) { - if (POWER_LED_starttime + 30000 < millis() && !doing_fast_blink) { + if (Throttle::hasElapsed(POWER_LED_starttime, 30000) && !doing_fast_blink) { doing_fast_blink = true; POWER_LED_starttime = millis(); } @@ -126,7 +127,7 @@ int32_t StatusLEDModule::runOnce() PAIRING_LED_state = LED_STATE_OFF; CHARGE_LED_state = !CHARGE_LED_state; my_interval = 250; - if (POWER_LED_starttime + 2000 < millis()) { + if (Throttle::hasElapsed(POWER_LED_starttime, 2000)) { doing_fast_blink = false; CHARGE_LED_state = LED_STATE_OFF; } @@ -165,7 +166,7 @@ int32_t StatusLEDModule::runOnce() } #endif #ifdef LED_PAIRING - if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) { + if (!config.bluetooth.enabled || Throttle::hasElapsed(PAIRING_LED_starttime, 30 * 1000) || doing_fast_blink) { PAIRING_LED_state = LED_STATE_OFF; } else if (ble_state == unpaired) { if (slowTrack) { @@ -190,7 +191,7 @@ int32_t StatusLEDModule::runOnce() bool chargeIndicatorLED2 = LED_STATE_OFF; bool chargeIndicatorLED3 = LED_STATE_OFF; bool chargeIndicatorLED4 = LED_STATE_OFF; - if (lastUserbuttonTime + 10 * 1000 > millis() || CHARGE_LED_state == LED_STATE_ON) { + if (Throttle::isWithinTimespanMs(lastUserbuttonTime, 10 * 1000) || CHARGE_LED_state == LED_STATE_ON) { // should this be off at very low percentages? chargeIndicatorLED1 = LED_STATE_ON; if (powerStatus && powerStatus->getBatteryChargePercent() >= 25) diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index 9772463d7..e3ef3f095 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -6,7 +6,9 @@ #include "PowerFSM.h" #include "RadioLibInterface.h" #include "Router.h" +#include "Throttle.h" #include "TransmitHistory.h" +#include "UptimeClock.h" #include "configuration.h" #include "gps/RTC.h" #include "main.h" @@ -21,13 +23,12 @@ static constexpr uint16_t TX_HISTORY_KEY_DEVICE_TELEMETRY = 0x8001; int32_t DeviceTelemetryModule::runOnce() { - refreshUptime(); uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_DEVICE_TELEMETRY) : 0; bool isImpoliteRole = isSensorOrRouterRole(); - if (((lastTelemetry == 0) || - ((uptimeLastMs - lastTelemetry) >= Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.device_update_interval, - default_telemetry_broadcast_interval_secs, - numOnlineNodes, TrafficType::TELEMETRY))) && + if (((lastTelemetry == 0) || Throttle::hasElapsed(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( + moduleConfig.telemetry.device_update_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(!isImpoliteRole) && airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN && moduleConfig.telemetry.device_telemetry_enabled) { @@ -38,9 +39,9 @@ int32_t DeviceTelemetryModule::runOnce() // Just send to phone when it's not our time to send to mesh yet // Only send while queue is empty (phone assumed connected) sendTelemetry(NODENUM_BROADCAST, true); - if (lastSentStatsToPhone == 0 || (uptimeLastMs - lastSentStatsToPhone) >= sendStatsToPhoneIntervalMs) { + if (lastSentStatsToPhone == 0 || Throttle::hasElapsed(lastSentStatsToPhone, sendStatsToPhoneIntervalMs)) { sendLocalStatsToPhone(); - lastSentStatsToPhone = uptimeLastMs; + lastSentStatsToPhone = Time::getMillis(); } } return sendToPhoneIntervalMs; @@ -114,7 +115,7 @@ meshtastic_Telemetry DeviceTelemetryModule::getDeviceTelemetry() t.variant.device_metrics.has_voltage = true; t.variant.device_metrics.voltage = batteryMv / 1000.0f; } - t.variant.device_metrics.uptime_seconds = getUptimeSeconds(); + t.variant.device_metrics.uptime_seconds = Time::getUptimeSecs(); return t; } @@ -124,7 +125,7 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry() telemetry.which_variant = meshtastic_Telemetry_local_stats_tag; telemetry.variant.local_stats = meshtastic_LocalStats_init_zero; telemetry.time = getTime(); - telemetry.variant.local_stats.uptime_seconds = getUptimeSeconds(); + telemetry.variant.local_stats.uptime_seconds = Time::getUptimeSecs(); telemetry.variant.local_stats.channel_utilization = airTime->channelUtilizationPercent(); telemetry.variant.local_stats.air_util_tx = airTime->utilizationTXPercent(); telemetry.variant.local_stats.num_online_nodes = numOnlineNodes; diff --git a/src/modules/Telemetry/DeviceTelemetry.h b/src/modules/Telemetry/DeviceTelemetry.h index f37afee70..c2d2762f3 100644 --- a/src/modules/Telemetry/DeviceTelemetry.h +++ b/src/modules/Telemetry/DeviceTelemetry.h @@ -18,8 +18,6 @@ class DeviceTelemetryModule : private concurrency::OSThread, : concurrency::OSThread("DeviceTelemetry"), ProtobufModule("DeviceTelemetry", meshtastic_PortNum_TELEMETRY_APP, &meshtastic_Telemetry_msg) { - uptimeWrapCount = 0; - uptimeLastMs = millis(); nodeStatusObserver.observe(&nodeStatus->onNewStatus); setIntervalFromNow(setStartDelay()); // Wait until NodeInfo is sent } @@ -37,12 +35,6 @@ class DeviceTelemetryModule : private concurrency::OSThread, */ bool sendTelemetry(NodeNum dest = NODENUM_BROADCAST, bool phoneOnly = false); - /** - * Get the uptime in seconds - * Loses some accuracy after 49 days, but that's fine - */ - uint32_t getUptimeSeconds() { return (0xFFFFFFFF / 1000) * uptimeWrapCount + (uptimeLastMs / 1000); } - private: meshtastic_Telemetry getDeviceTelemetry(); meshtastic_Telemetry getLocalStatsTelemetry(); @@ -51,17 +43,4 @@ class DeviceTelemetryModule : private concurrency::OSThread, uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute uint32_t sendStatsToPhoneIntervalMs = 15 * SECONDS_IN_MINUTE * 1000; // Send stats to phone every 15 minutes uint32_t lastSentStatsToPhone = 0; - - void refreshUptime() - { - auto now = millis(); - // If we wrapped around (~49 days), increment the wrap count - if (now < uptimeLastMs) - uptimeWrapCount++; - - uptimeLastMs = now; - } - - uint32_t uptimeWrapCount; - uint32_t uptimeLastMs; }; \ No newline at end of file diff --git a/src/modules/Telemetry/HostMetrics.h b/src/modules/Telemetry/HostMetrics.h index 99ee631c1..a352a5afa 100644 --- a/src/modules/Telemetry/HostMetrics.h +++ b/src/modules/Telemetry/HostMetrics.h @@ -12,8 +12,6 @@ class HostMetricsModule : private concurrency::OSThread, public ProtobufModuleonNewStatus); setIntervalFromNow(setStartDelay()); // Wait until NodeInfo is sent } @@ -35,6 +33,4 @@ class HostMetricsModule : private concurrency::OSThread, public ProtobufModule) #include @@ -163,7 +165,8 @@ void BME680Sensor::updateState() } } else { /* Update every STATE_SAVE_PERIOD minutes */ - if ((stateUpdateCounter * STATE_SAVE_PERIOD) < millis()) { + // Interval since the last save; counter * period overflows uint32 past ~198 saves. + if (Throttle::hasElapsed(lastStateSaveMs, STATE_SAVE_PERIOD)) { LOG_DEBUG("%s state update every %d minutes", sensorName, STATE_SAVE_PERIOD / 60000); update = true; stateUpdateCounter++; @@ -181,6 +184,8 @@ void BME680Sensor::updateState() file.write((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE); file.flush(); file.close(); + // Checkpoint on success only, so a failed write is retried at the next interval. + lastStateSaveMs = Time::getMillis(); } else { LOG_INFO("Can't write %s state (File: %s)", sensorName, bsecConfigFileName); } diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.h b/src/modules/Telemetry/Sensor/BME680Sensor.h index 1134f04d9..b8c0bd810 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.h +++ b/src/modules/Telemetry/Sensor/BME680Sensor.h @@ -39,6 +39,7 @@ class BME680Sensor : public TelemetrySensor uint8_t bsecState[BSEC_MAX_STATE_BLOB_SIZE] = {0}; uint8_t accuracy = 0; uint16_t stateUpdateCounter = 0; + uint32_t lastStateSaveMs = 0; // when the state blob was last written, for the save interval bsecSensor sensorList[9] = {BSEC_OUTPUT_IAQ, BSEC_OUTPUT_RAW_TEMPERATURE, BSEC_OUTPUT_RAW_PRESSURE, diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp index b1744ad92..6cbe8e21d 100755 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -259,8 +259,11 @@ void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState const uint32_t now = millis(); const uint32_t endCalibrationAt = screen->getEndCalibration(); uint32_t timeRemaining = 0; - if (endCalibrationAt > now) { - timeRemaining = (endCalibrationAt - now + 999) / 1000; + // Signed delta, as in finishCalibrationIfExpired(): this needs the remaining magnitude, not + // just whether the deadline passed, so it cannot use Throttle::deadlinePassed(). + const int32_t remainingMs = (int32_t)(endCalibrationAt - now); + if (remainingMs > 0) { + timeRemaining = ((uint32_t)remainingMs + 999) / 1000; } int16_t compassX = 0, compassY = 0; diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp index 2ef2d2e23..a83b04b0f 100644 --- a/src/platform/extra_variants/t5s3_epaper/variant.cpp +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -10,6 +10,7 @@ #include "input/InputBroker.h" #include "input/TouchScreenImpl1.h" #include "main.h" +#include "mesh/Throttle.h" #include "sleep.h" #include @@ -100,7 +101,10 @@ volatile bool touchControllerReady = false; volatile bool touchLightSleepActive = false; volatile bool touchNeedsWake = false; volatile bool touchIndicatorRefreshPending = false; -volatile uint32_t touchResumeBlockUntilMs = 0; +// When the light-sleep resume happened, not when the block expires: an interval bounds a missed +// 0-check by the settle time, where a stored deadline would block for up to half a wrap cycle. +constexpr uint32_t TOUCH_RESUME_BLOCK_MS = 150; +volatile uint32_t touchResumeAtMs = 0; volatile uint32_t touchStateEpoch = 1; volatile bool homeCapButtonEventsEnabled = false; #if HAS_SCREEN @@ -184,7 +188,8 @@ class SideKeyInterruptThread : public concurrency::OSThread { const uint32_t now = millis(); - if (now < touchResumeBlockUntilMs) { + // 0 means the device has never light-slept, so no block is armed - test it first. + if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) { resetStateAndStop(); return OSThread::disable(); } @@ -279,8 +284,8 @@ class SideKeyInterruptThread : public concurrency::OSThread if (touchLightSleepActive) { return; } - const uint32_t now = millis(); - if (now < touchResumeBlockUntilMs) { + // See the runOnce() guard above for why 0 must be tested separately. + if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) { return; } if (state != State::REST) { @@ -550,7 +555,7 @@ struct TouchLightSleepEndObserver { } touchStateEpoch++; - touchResumeBlockUntilMs = millis() + 150; + touchResumeAtMs = millis(); touchIndicatorRefreshPending = !isTouchInputEnabled(); #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS // Clear sleep-time touch overlay after wake. @@ -569,17 +574,18 @@ struct TouchLightSleepEndObserver { bool readTouch(int16_t *x, int16_t *y) { #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS - static uint32_t suppressUntilMs = 0; + constexpr uint32_t TOUCH_WAKE_SUPPRESS_MS = 60; + static uint32_t suppressFromMs = 0; // 0 = not suppressing, same reading as touchResumeAtMs static uint32_t seenTouchStateEpoch = 0; // Reset transient gesture helpers whenever touch mode changes. if (seenTouchStateEpoch != touchStateEpoch) { seenTouchStateEpoch = touchStateEpoch; - suppressUntilMs = 0; + suppressFromMs = 0; } - // Let buses and peripherals settle briefly after light-sleep wake. - if (millis() < touchResumeBlockUntilMs) { + // Let buses and peripherals settle briefly after light-sleep wake. 0 means no wake yet. + if (touchResumeAtMs != 0 && Throttle::isWithinTimespanMs(touchResumeAtMs, TOUCH_RESUME_BLOCK_MS)) { return false; } @@ -596,12 +602,12 @@ bool readTouch(int16_t *x, int16_t *y) LOG_DEBUG("touchscreen1: wakeup() on deferred resume"); touch.wakeup(); touchNeedsWake = false; - suppressUntilMs = millis() + 60; + suppressFromMs = millis(); return false; } // After a recovery pulse, emit a brief "released" window so gesture state can reset. - if (suppressUntilMs != 0 && millis() < suppressUntilMs) { + if (suppressFromMs != 0 && Throttle::isWithinTimespanMs(suppressFromMs, TOUCH_WAKE_SUPPRESS_MS)) { return false; } #endif diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 8c17435cc..85a29e05a 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -446,7 +446,7 @@ bool NRF52Bluetooth::onPairingPasskey(uint16_t conn_handle, uint8_t const passke if (match_request) { uint32_t start_time = millis(); - while (millis() < start_time + 30000) { + while (Throttle::isWithinTimespanMs(start_time, 30000)) { if (!Bluefruit.connected(conn_handle)) break; } diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index 14138767e..eb2084403 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -1,4 +1,5 @@ #include "configuration.h" +#include "mesh/Throttle.h" #include #include #include @@ -270,12 +271,17 @@ namespace { constexpr uint8_t NRF52_MAGIC_LFS_IS_CORRUPT = 0xF5; constexpr uint32_t MULTIPLE_CORRUPTION_DELAY_MILLIS = 20 * 60 * 1000; -static unsigned long millis_until_formatting_again = 0; +// When the last format happened, not when the next one is due: measuring forward from the event +// bounds the pause below by the constant, where a stored deadline could hand delay() any value. +// Armed separately because preFSBegin() runs in the first millisecond of boot, so a zero timestamp +// is a legitimate value here, not an "unset" marker. +static uint32_t last_format_ms = 0; +static bool formatted_this_boot = false; // Report the critical error from loop(), giving a chance for the screen to be initialized first. inline void reportLittleFSCorruptionOnce() { - static bool report_corruption = !!millis_until_formatting_again; + static bool report_corruption = formatted_this_boot; if (report_corruption) { report_corruption = false; RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE); @@ -290,7 +296,8 @@ void preFSBegin() if (!(NRF_POWER->RESETREAS == 0 && NRF_POWER->GPREGRET == NRF52_MAGIC_LFS_IS_CORRUPT)) return; NRF_POWER->GPREGRET = 0; - millis_until_formatting_again = millis() + MULTIPLE_CORRUPTION_DELAY_MILLIS; + last_format_ms = millis(); + formatted_this_boot = true; InternalFS.format(); LOG_INFO("LittleFS format complete; restoring default settings"); } @@ -298,9 +305,11 @@ void preFSBegin() extern "C" void lfs_assert(const char *reason) { LOG_ERROR("LittleFS corruption detected: %s", reason); - if (millis_until_formatting_again > millis()) { + // Test the armed flag first, since elapsed-since-0 is inside the backoff for the first 20 + // minutes after each wrap. + if (formatted_this_boot && Throttle::isWithinTimespanMs(last_format_ms, MULTIPLE_CORRUPTION_DELAY_MILLIS)) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE); - const long millis_remain = millis_until_formatting_again - millis(); + const long millis_remain = MULTIPLE_CORRUPTION_DELAY_MILLIS - (millis() - last_format_ms); LOG_WARN("Pausing %d seconds to avoid wear on flash storage", millis_remain / 1000); delay(millis_remain); } diff --git a/test/test_airtime/test_main.cpp b/test/test_airtime/test_main.cpp new file mode 100644 index 000000000..97adeac0c --- /dev/null +++ b/test/test_airtime/test_main.cpp @@ -0,0 +1,196 @@ +// Unit tests for src/airtime.{h,cpp} - AirTime::syncNow() and its rolling windows. +// +// syncNow() replaced a per-second runOnce() tick with monotonic-uptime bucket rotation so windows +// stay correct across light sleep. It now takes its seconds from Time::getUptimeSecs(), which is a +// pure read of a carry the main loop publishes via Time::serviceMonotonic(); these tests exercise +// the rotation/decay math on top of that, including across the 32-bit millis() wrap. The wrap cases +// therefore step the clock the way the main loop does - advance, then publish. +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "airtime.h" +#include +#include + +void setUp(void) +{ + // Absolute uptime assertions (e.g. getSecondsSinceBoot()) must not inherit wraps counted by + // an earlier case that moved the test clock backwards via setTestMillis(). + Time::resetMonotonicForTests(); +} +void tearDown(void) +{ + Time::useRealClock(); // don't leak the fake clock into other suites +} + +// --- first sync / immediate writes --- + +void test_logAirtime_writes_into_current_bucket_immediately() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 100); + + TEST_ASSERT_EQUAL_UINT32(100, a.airtimeReport(TX_LOG)[0]); +} + +void test_getSecondsSinceBoot_tracks_elapsed_time() +{ + Time::setTestMillis(0); + AirTime a; + + TEST_ASSERT_EQUAL_UINT32(0, a.getSecondsSinceBoot()); + Time::advanceTestMillis(5000); + TEST_ASSERT_EQUAL_UINT32(5, a.getSecondsSinceBoot()); +} + +// --- hourly period rotation --- + +void test_period_rotates_after_one_hour() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 500); + + Time::advanceTestMillis(3600u * 1000u); // exactly one SECONDS_PER_PERIOD + + uint32_t *report = a.airtimeReport(TX_LOG); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); // new period starts empty + TEST_ASSERT_EQUAL_UINT32(500, report[1]); // old period shifted back one slot +} + +// The property runOnce() alone could never exercise: several hours pass in a single sync (e.g. the +// device was light-sleeping), so the rotation has to walk forward more than one period at once. +void test_period_rotates_once_per_hour_crossed_while_asleep() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 200); + + Time::advanceTestMillis(3u * 3600u * 1000u); // 3 hours in one jump + + uint32_t *report = a.airtimeReport(TX_LOG); + TEST_ASSERT_EQUAL_UINT32(200, report[3]); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_EQUAL_UINT32(0, report[1]); + TEST_ASSERT_EQUAL_UINT32(0, report[2]); +} + +// More periods elapse than there are slots to rotate through: the whole history is stale, not just +// the oldest slot, so it must be wiped rather than rotated PERIODS_TO_LOG times. +void test_period_history_clears_when_asleep_longer_than_the_whole_log() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 999); + + Time::advanceTestMillis(9u * 3600u * 1000u); // 9 hours > PERIODS_TO_LOG (8) + + uint32_t *report = a.airtimeReport(TX_LOG); + for (uint8_t i = 0; i < a.getPeriodsToLog(); i++) { + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, report[i], "stale history must be cleared, not rotated in"); + } +} + +// --- channel utilization: rolling 60s window --- + +void test_channel_utilization_reflects_recent_airtime() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 6000); // 6s of airtime inside the 60s window + + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +void test_channel_utilization_decays_once_the_60s_window_passes() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 6000); + + Time::advanceTestMillis(70u * 1000u); // longer than the 60s rolling window + + TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.channelUtilizationPercent()); +} + +void test_isTxAllowedChannelUtil_blocks_once_over_threshold() +{ + Time::setTestMillis(0); + AirTime a; + + TEST_ASSERT_TRUE(a.isTxAllowedChannelUtil()); // nothing logged yet + + a.logAirtime(RX_LOG, 25000); // 25s / 60s = 41.7%, over the 40% default max + TEST_ASSERT_FALSE(a.isTxAllowedChannelUtil()); +} + +// --- TX utilization: rolling 60-minute window --- + +void test_tx_utilization_decays_once_the_60_minute_window_passes() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 60000); // 1 minute of TX airtime + + TEST_ASSERT_TRUE(a.utilizationTXPercent() > 0.0f); + + Time::advanceTestMillis(61u * 60u * 1000u); // longer than the 60-minute rolling window + + TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.utilizationTXPercent()); +} + +// --- the headline property: syncNow() must survive the 32-bit millis() wrap --- + +void test_syncNow_survives_millis_wrap() +{ + const uint32_t beforeWrap = 4294967000u; // 296ms before the wrap, on a whole-second boundary + Time::setTestMillis(beforeWrap); + Time::serviceMonotonic(); // the main loop's publish, which is what carries the wrap + AirTime a; + + TEST_ASSERT_EQUAL_UINT32(4294967u, a.getSecondsSinceBoot()); + + Time::advanceTestMillis(1000); // crosses the wrap + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT32(4294968u, a.getSecondsSinceBoot()); +} + +// A bucket logged just before the wrap must still be the one that rotates out after it - pinning +// the same property test_period_rotates_after_one_hour checks, but across the wrap boundary. +void test_period_rotation_survives_millis_wrap() +{ + const uint32_t beforeWrap = 0xFFFFFFFFu - (3600u * 1000u) + 1; // one hour minus 1ms before the wrap + Time::setTestMillis(beforeWrap); + Time::serviceMonotonic(); + AirTime a; + a.logAirtime(TX_LOG, 777); + + Time::advanceTestMillis(3600u * 1000u); // wraps partway through + Time::serviceMonotonic(); + + uint32_t *report = a.airtimeReport(TX_LOG); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_EQUAL_UINT32(777, report[1]); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_logAirtime_writes_into_current_bucket_immediately); + RUN_TEST(test_getSecondsSinceBoot_tracks_elapsed_time); + RUN_TEST(test_period_rotates_after_one_hour); + RUN_TEST(test_period_rotates_once_per_hour_crossed_while_asleep); + RUN_TEST(test_period_history_clears_when_asleep_longer_than_the_whole_log); + RUN_TEST(test_channel_utilization_reflects_recent_airtime); + RUN_TEST(test_channel_utilization_decays_once_the_60s_window_passes); + RUN_TEST(test_isTxAllowedChannelUtil_blocks_once_over_threshold); + RUN_TEST(test_tx_utilization_decays_once_the_60_minute_window_passes); + RUN_TEST(test_syncNow_survives_millis_wrap); + RUN_TEST(test_period_rotation_survives_millis_wrap); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_gps_fix_hold/test_main.cpp b/test/test_gps_fix_hold/test_main.cpp new file mode 100644 index 000000000..c6a4aec5e --- /dev/null +++ b/test/test_gps_fix_hold/test_main.cpp @@ -0,0 +1,218 @@ +// Unit tests for shouldArmFixHold() / fixHoldInForce() in src/gps/GPS.cpp - the post-lock +// ephemeris hold. +// +// In power-saving mode (gps_update_interval above GPS_UPDATE_ALWAYS_ON_THRESHOLD_MS) the GPS holds +// for up to 20s after a lock to download ephemeris, then publishes and sleeps. The predicate below +// decides, once per GPS thread cycle that has a location, whether a hold should be armed. +// +// The case that matters is a hold that was consumed by a publish which did not sleep: GPS::runOnce() +// clears fixHoldEnds whenever it publishes, but only calls down() when the search timed out or a +// hold expired. If the predicate treats "not holding" as a reason to skip, nothing re-arms, nothing +// publishes, and the receiver stays powered until searchedTooLong() fires. +#include "Arduino.h" +#include "TestUtil.h" +#include "Throttle.h" +#include "UptimeClock.h" +#include +#include + +// The predicates live beside their only caller in src/gps/GPS.cpp rather than in a header of their +// own; the native test build compiles that file, so declaring the prototypes here is enough. A +// signature change breaks the link rather than silently diverging from the definition. +bool fixHoldInForce(uint32_t fixHoldEnds, uint32_t threadIntervalMs); +bool holdJustExpired(uint32_t fixHoldEnds); +bool shouldArmFixHold(bool hasValidLocation, uint8_t prevFixQual, uint32_t fixHoldEnds, uint32_t threadIntervalMs); + +// GPS_THREAD_INTERVAL, spelled out so the suite does not pull in GPS.h and its hardware deps. +static constexpr uint32_t kThreadInterval = 200; + +// The two hold durations the firmware uses: GPS_FIX_HOLD_MAX_MS, and a short one. +static constexpr uint32_t kHoldMs = 20 * 1000; + +void setUp(void) +{ + Time::setTestMillis(0); +} +void tearDown(void) +{ + Time::useRealClock(); +} + +// Arms a hold at the current test time and returns the resulting fixHoldEnds. +static uint32_t armHoldNow(uint32_t holdMs = kHoldMs) +{ + return Time::getMillis() + holdMs; +} + +// --- the reasons to arm --- + +// First lock of a cycle: hasValidLocation is still false on the rising edge. +void test_arms_on_the_first_lock_of_a_cycle(void) +{ + Time::setTestMillis(50 * 1000); + TEST_ASSERT_TRUE(shouldArmFixHold(false, 3, 0, kThreadInterval)); +} + +// Lock after the receiver was off: down() zeroes fixQual, so prev_fixQual is 0 on the way back up. +void test_arms_on_the_first_lock_after_the_gps_was_off(void) +{ + Time::setTestMillis(50 * 1000); + TEST_ASSERT_TRUE(shouldArmFixHold(true, 0, 0, kThreadInterval)); +} + +// The regression. A publish that did not sleep leaves hasValidLocation set, prev_fixQual non-zero +// and fixHoldEnds cleared to 0. Nothing else in runOnce() re-arms, so if this returns false the +// GPS never holds, never publishes again and never calls down() until the search times out. +void test_arms_after_a_publish_cleared_the_hold_without_sleeping(void) +{ + Time::setTestMillis(50 * 1000); + TEST_ASSERT_TRUE_MESSAGE(shouldArmFixHold(true, 3, 0, kThreadInterval), + "fixHoldEnds == 0 means 'not holding', which is a reason to arm"); +} + +void test_arms_once_the_hold_has_expired(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + Time::advanceTestMillis(kHoldMs + kThreadInterval); + TEST_ASSERT_TRUE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// --- the reason not to arm --- + +void test_does_not_arm_while_a_hold_is_in_force(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + Time::advanceTestMillis(kHoldMs / 2); + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// The GPS_THREAD_INTERVAL grace period: at the exact deadline the hold has not yet expired, because +// the next cycle is one interval away. +void test_does_not_arm_in_the_thread_interval_grace_after_the_deadline(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + Time::advanceTestMillis(kHoldMs); // exactly at the deadline + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(kThreadInterval - 1); + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(1); // deadline + GPS_THREAD_INTERVAL, inclusive boundary + TEST_ASSERT_TRUE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// --- across the 32-bit wrap --- + +// A hold armed just before the wrap must still be held through it. The naive form this replaced +// (`(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`) read as expired for the whole pre-wrap window, +// re-arming the hold on every single cycle. +void test_does_not_arm_while_a_hold_straddling_the_wrap_is_in_force(void) +{ + Time::setTestMillis(0xFFFFFF00u); // 256ms short of the wrap + const uint32_t fixHoldEnds = armHoldNow(); + + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(0x200u); // now past the wrap, still inside the hold + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(kHoldMs); // well past the deadline, still past the wrap + TEST_ASSERT_TRUE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// The deadline itself wrapping (fixHoldEnds numerically below millis()) must not read as expired. +void test_holds_when_the_deadline_wraps_but_now_has_not(void) +{ + Time::setTestMillis(0xFFFFFF00u); + const uint32_t fixHoldEnds = armHoldNow(); // wraps to ~0x4CFF + + TEST_ASSERT_TRUE_MESSAGE(fixHoldEnds < Time::getMillis(), "test setup: the deadline must have wrapped"); + TEST_ASSERT_FALSE(shouldArmFixHold(true, 3, fixHoldEnds, kThreadInterval)); +} + +// --- the two readings of the same sentinel --- + +// runOnce() asks two questions of fixHoldEnds and they take opposite answers when nothing is armed: +// "should I arm one?" (yes) and "did one just expire, so publish and sleep?" (no). Both are derived +// from fixHoldInForce(), which is the only place the sentinel is interpreted. +void test_no_hold_means_arm_but_does_not_mean_expired(void) +{ + Time::setTestMillis(50 * 1000); + + TEST_ASSERT_FALSE_MESSAGE(fixHoldInForce(0, kThreadInterval), "a hold that was never armed is not in force"); + TEST_ASSERT_TRUE_MESSAGE(shouldArmFixHold(true, 3, 0, kThreadInterval), "...so it is a reason to arm one"); + TEST_ASSERT_FALSE_MESSAGE(holdJustExpired(0), "...but not a reason to publish and sleep"); +} + +// holdJustExpired()'s sentinel guard is load-bearing on every cycle, not just past the half-range: +// fixHoldInForce() calls an unarmed hold "not in force", so negating it alone reads as expired. +void test_only_an_armed_hold_can_expire(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + TEST_ASSERT_FALSE_MESSAGE(holdJustExpired(fixHoldEnds), "still inside the hold"); + + Time::advanceTestMillis(kHoldMs); // the deadline itself, no grace interval at this site + TEST_ASSERT_TRUE_MESSAGE(holdJustExpired(fixHoldEnds), "the deadline is the moment to publish and sleep"); + + TEST_ASSERT_TRUE_MESSAGE(!fixHoldInForce(0, 0), "test premise: the negation alone calls an unarmed hold expired"); + TEST_ASSERT_FALSE_MESSAGE(holdJustExpired(0), "so the sentinel test is what keeps it from expiring"); +} + +// The `fixHoldEnds != 0` term inside fixHoldInForce() looks redundant, and for the first half of +// each wrap cycle it is: deadlinePassed(0 + interval) is true once uptime exceeds one interval, so +// "not in force" would fall out of the arithmetic on its own. Past 2^31 ms of uptime it flips. +// deadlinePassed() is an unsigned half-range test, so `now - interval` lands in the top half and +// the sentinel reads as a deadline ~24.8 days in the FUTURE - an unarmed hold would look like one +// in force for the whole second half of every cycle, and nothing would ever re-arm. +void test_the_sentinel_guard_is_load_bearing_past_the_half_range(void) +{ + Time::setTestMillis(0x90000000u); // ~27.8 days of uptime, past the ~24.8-day half-range point + + // The arithmetic alone now says "not yet" for the sentinel... + TEST_ASSERT_FALSE_MESSAGE(Throttle::deadlinePassed(0 + kThreadInterval), + "test premise: past half-range the sentinel reads as a future deadline"); + + // ...so the explicit sentinel test is the only thing keeping the answer right. + TEST_ASSERT_FALSE_MESSAGE(fixHoldInForce(0, kThreadInterval), "an unarmed hold is never in force"); + TEST_ASSERT_TRUE_MESSAGE(shouldArmFixHold(true, 3, 0, kThreadInterval), "...so a hold must still be armed"); +} + +void test_hold_in_force_tracks_the_deadline(void) +{ + Time::setTestMillis(50 * 1000); + const uint32_t fixHoldEnds = armHoldNow(); + + TEST_ASSERT_TRUE(fixHoldInForce(fixHoldEnds, kThreadInterval)); + + Time::advanceTestMillis(kHoldMs + kThreadInterval); + TEST_ASSERT_FALSE(fixHoldInForce(fixHoldEnds, kThreadInterval)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_no_hold_means_arm_but_does_not_mean_expired); + RUN_TEST(test_only_an_armed_hold_can_expire); + RUN_TEST(test_the_sentinel_guard_is_load_bearing_past_the_half_range); + RUN_TEST(test_hold_in_force_tracks_the_deadline); + RUN_TEST(test_arms_on_the_first_lock_of_a_cycle); + RUN_TEST(test_arms_on_the_first_lock_after_the_gps_was_off); + RUN_TEST(test_arms_after_a_publish_cleared_the_hold_without_sleeping); + RUN_TEST(test_arms_once_the_hold_has_expired); + RUN_TEST(test_does_not_arm_while_a_hold_is_in_force); + RUN_TEST(test_does_not_arm_in_the_thread_interval_grace_after_the_deadline); + RUN_TEST(test_does_not_arm_while_a_hold_straddling_the_wrap_is_in_force); + RUN_TEST(test_holds_when_the_deadline_wraps_but_now_has_not); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_meshpacket_serializer/ports/test_timestamp.cpp b/test/test_meshpacket_serializer/ports/test_timestamp.cpp index 333945f80..d6e38bcf1 100644 --- a/test/test_meshpacket_serializer/ports/test_timestamp.cpp +++ b/test/test_meshpacket_serializer/ports/test_timestamp.cpp @@ -21,7 +21,7 @@ void test_timestamp_zeroed_when_rx_time_absent() 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 + TEST_ASSERT_EQUAL_UINT32(0u, root["timestamp"].asUInt()); // must not leak the uptime placeholder } void test_encrypted_timestamp_zeroed_when_rx_time_absent() diff --git a/test/test_meshpacket_serializer/test_helpers.h b/test/test_meshpacket_serializer/test_helpers.h index 2dc06cec7..63447d509 100644 --- a/test/test_meshpacket_serializer/test_helpers.h +++ b/test/test_meshpacket_serializer/test_helpers.h @@ -70,7 +70,7 @@ static meshtastic_MeshPacket create_test_packet_no_rx_time(meshtastic_PortNum po 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.rx_time = 123456; // a plausible uptime-seconds placeholder, not a real epoch packet.has_rx_time = false; return packet; } diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index 88d7f0259..8b35d6534 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -25,6 +25,7 @@ class NodeDBTestShim : public NodeDB public: void runDemote() { demoteOldestHotNodesToWarm(); } void runCleanup() { cleanupMeshDB(); } + void stampUntrusted(NodeNum num, uint32_t uptimeSecs) { recordHeardWhileClockUntrusted(num, uptimeSecs); } // Read back the role + protected category the warm tier cached for a node. bool warmMeta(NodeNum n, uint8_t &role, uint8_t &prot) { return warmStore.lookupMeta(n, role, prot); } @@ -178,6 +179,27 @@ static void test_eviction_preservesFavorite(void) TEST_ASSERT_NOT_NULL(db->getMeshNode(0x99990000)); } +// A node heard during this boot is newer than every persisted epoch, including valid epochs after +// 2038. Ranking both domains in one uint32_t incorrectly evicts the current-boot node first. +static void test_eviction_prefers_current_boot_stamp_over_post2038_epoch(void) +{ + constexpr NodeNum futureDated = 0x70000001; + constexpr NodeNum heardThisBoot = 0x70000002; + + db->seedSelf(); + db->push(futureDated, 0xB5000000u, false, false, /*withUser=*/true, /*withKey=*/true); + db->push(heardThisBoot, 0, false, false, /*withUser=*/true, /*withKey=*/true); + db->stampUntrusted(heardThisBoot, 10); + for (int i = 3; i < MAX_NUM_NODES; i++) + db->push(0x70000000u + i, UINT32_MAX, false, false, /*withUser=*/true, /*withKey=*/true); + + TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); + TEST_ASSERT_NOT_NULL(db->getOrCreateMeshNode(0x79999999)); + + TEST_ASSERT_NULL(db->getMeshNode(futureDated)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(heardThisBoot)); +} + // Ignored handling: an ignored node survives eviction (like a favourite), and is // never purged by cleanupMeshDB even with no user info (a block set by bare ID). static void test_ignored_survivesEvictionAndCleanup(void) @@ -269,6 +291,7 @@ NDB_TEST_ENTRY void setup() RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm); RUN_TEST(test_migration_carriesSignerBitThroughWarm); RUN_TEST(test_eviction_preservesFavorite); + RUN_TEST(test_eviction_prefers_current_boot_stamp_over_post2038_epoch); RUN_TEST(test_ignored_survivesEvictionAndCleanup); RUN_TEST(test_protectedCap_refusesBeyondLimit); RUN_TEST(test_removeNodeByNum_absentNodeOnFullDb); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index c3abb7bc9..d8234290a 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -22,6 +22,7 @@ // compiled out unless both PKI and XEdDSA are enabled (e.g. stm32 sets MESHTASTIC_EXCLUDE_XEDDSA). #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) +#include "UptimeClock.h" #include "mesh/Channels.h" #include "mesh/CryptoEngine.h" #include "mesh/MeshRadio.h" @@ -421,6 +422,16 @@ void tearDown(void) delete mockNodeDB; mockNodeDB = nullptr; nodeDB = nullptr; + + // Restore globals here, not at the end of a test body: an assertion aborts the body, and these + // would otherwise leak into every later case. The injected clock is the one the N8-N11 + // suppression-window cases drive; the region and TX bucket are C14's duty-cycle setup. + Time::useRealClock(); + Time::resetMonotonicForTests(); + if (airTime) + airTime->utilizationTX[0] = 0; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); } // =========================================================================== @@ -1073,6 +1084,7 @@ void test_B13_licensed_port_and_destination_signing_matrix(void) class NodeInfoTestShim : public NodeInfoModule { public: + using MeshModule::currentRequest; // allocReply() only suppresses while a request is in flight using NodeInfoModule::allocReply; using NodeInfoModule::handleReceivedProtobuf; }; @@ -1221,14 +1233,18 @@ void test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state(void) prior.hop_start = 2; prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; pipelineRouter->remember(&prior); - pipelineRouter->addPending(prior, UINT32_MAX); + // "Far future, so no retransmission is due." Must be a representable future time, not + // UINT32_MAX: doRetransmissions() compares with an unsigned half-range test, under which + // UINT32_MAX is ~1ms in the *past* and would fire a retransmit and rewrite nextTxMsec. + const uint32_t notDueTxMsec = Time::getMillis() + 3600000UL; + pipelineRouter->addPending(prior, notDueTxMsec); const uint32_t lastHeard = mockNodeDB->getMeshNode(LOCAL_NODE)->last_heard; meshtastic_MeshPacket invalid = makeSignedWirePacket(LOCAL_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x34, false); runPipelineIngress(invalid); assertNoRejectedPipelineEffects(LOCAL_NODE, lastHeard); TEST_ASSERT_EQUAL(1, pipelineRouter->pendingCount()); - TEST_ASSERT_EQUAL_UINT32(UINT32_MAX, pipelineRouter->pendingNextTx(LOCAL_NODE, id)); + TEST_ASSERT_EQUAL_UINT32(notDueTxMsec, pipelineRouter->pendingNextTx(LOCAL_NODE, id)); } void test_C4_invalid_fallback_packet_cannot_relay(void) @@ -1569,6 +1585,113 @@ void test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name(void) "non-signer identity learning must be unaffected"); } +// --------------------------------------------------------------------------- +// N8-N11: the 12h reply-suppression window. +// +// The stamp is uptime SECONDS, not milliseconds: entries live for as long as the node stays in the +// DB, so a 32-bit millisecond stamp aliased back into the window once uptime passed 49.7 days and +// suppressed a legitimate reply for up to 12h. Driven through Time::setTestMillis() rather than by +// waiting. +// --------------------------------------------------------------------------- + +static constexpr uint32_t kSuppressSecs = 12 * 60 * 60; + +// Deliver a NodeInfo request from `sender` and report whether we would reply to it. +static bool wouldReplyToNodeInfoRequest(NodeInfoTestShim &shim, NodeNum sender) +{ + meshtastic_MeshPacket mp = makeDecoded(sender, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); + mp.decoded.want_response = true; + meshtastic_User user = meshtastic_User_init_zero; + user.is_licensed = owner.is_licensed; + + shim.handleReceivedProtobuf(mp, &user); + + NodeInfoTestShim::currentRequest = ∓ + meshtastic_MeshPacket *reply = shim.allocReply(); + NodeInfoTestShim::currentRequest = nullptr; + + if (reply) { + packetPool.release(reply); + return true; + } + return false; +} + +// Step the injected clock the way the main loop does - advance, then publish the wrap carry. +static void advanceUptime(uint32_t deltaMs) +{ + Time::advanceTestMillis(deltaMs); + Time::serviceMonotonic(); +} + +void test_N8_second_request_inside_the_window_is_suppressed(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + Time::setTestMillis(60 * 1000); + Time::serviceMonotonic(); + + NodeInfoTestShim shim; + TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "first request must be answered"); + + advanceUptime(60 * 60 * 1000); // 1h later, well inside the 12h window + TEST_ASSERT_FALSE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "repeat request inside 12h must be suppressed"); +} + +void test_N9_request_after_the_window_is_answered(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + Time::setTestMillis(60 * 1000); + Time::serviceMonotonic(); + + NodeInfoTestShim shim; + TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE)); + + advanceUptime((kSuppressSecs + 60) * 1000); // 12h + a minute + TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "request after 12h must be answered"); +} + +// The regression. A stamp is only aliased by a counter that wraps underneath it, so the failure +// needs a *full* 2^32 ms of uptime to elapse, not merely a crossing of the boundary: with 32-bit +// millisecond stamps `now - stamp` then computes as 0 and the sender looks like it was answered +// this instant. Uptime seconds do not wrap for 136 years, so the entry reads as ~49.7 days old. +void test_N10_stale_stamp_does_not_alias_after_a_full_wrap(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + Time::setTestMillis(0x80000000u); // ~24.8 days of uptime + Time::serviceMonotonic(); + + NodeInfoTestShim shim; + TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE)); + + // A whole millis() cycle, in two serviced halves - one publish per window is the contract. + advanceUptime(0x80000000u); + advanceUptime(0x80000000u); + + TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), + "a stamp one full wrap old must read as ~49.7 days, not as this instant"); +} + +// Suppression must still behave normally either side of the boundary: still suppressing inside the +// window, and answering again once 12h have passed, with the stamp and the reading on opposite +// sides of the wrap. +void test_N11_window_still_applies_across_the_wrap(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + Time::setTestMillis(0xFFFF0000u); // just short of the wrap + Time::serviceMonotonic(); + + NodeInfoTestShim shim; + TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE)); + + advanceUptime(0x20000u); // ~131s later, and now past the wrap + TEST_ASSERT_FALSE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), + "the window must still bite when the stamp sits the other side of the wrap"); + + advanceUptime((kSuppressSecs + 60) * 1000); + TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), + "and must still release once 12h have passed across the wrap"); +} + void test_L1_licensed_nodeinfo_publishes_public_key(void) { owner.is_licensed = true; @@ -1984,6 +2107,10 @@ void setup() RUN_TEST(test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name); RUN_TEST(test_N6_signed_unicast_nodeinfo_from_signer_changes_name); RUN_TEST(test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name); + RUN_TEST(test_N8_second_request_inside_the_window_is_suppressed); + RUN_TEST(test_N9_request_after_the_window_is_answered); + RUN_TEST(test_N10_stale_stamp_does_not_alias_after_a_full_wrap); + RUN_TEST(test_N11_window_still_applies_across_the_wrap); printf("\n=== Group L: licensed identity and plaintext signing ===\n"); RUN_TEST(test_L1_licensed_nodeinfo_publishes_public_key); diff --git a/test/test_stream_api/test_main.cpp b/test/test_stream_api/test_main.cpp index 5075fe461..994e82c3d 100644 --- a/test/test_stream_api/test_main.cpp +++ b/test/test_stream_api/test_main.cpp @@ -526,14 +526,14 @@ static void test_want_config_includes_status_message_module_config(void) } /// Queue a packet as Router::dispatchReceived would have, before any time source existed. -static void queuePendingTimePlaceholderPacket(NodeNum from, uint32_t placeholderMillis) +static void queuePendingTimePlaceholderPacket(NodeNum from, uint32_t placeholderUptimeSecs) { 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.rx_time = placeholderUptimeSecs; // computeRxTimeStamp() stamps Time::getUptimeSecs() pending.has_rx_time = false; service->sendToPhone(packetPool.allocCopy(pending)); } @@ -574,6 +574,7 @@ class ScopedTimeFixture ScopedTimeFixture(uint32_t startMillis) : previous(nodeDB) { resetRTCStateForTests(); + Time::resetMonotonicForTests(); // uptime-seconds placeholders assume no carried wrap nodeDB = &instance; Time::setTestMillis(startMillis); } @@ -597,7 +598,7 @@ static void test_time_given_at_handshake_start_reconciles_queued_packet(void) ScopedTimeFixture timeFixture(5000); const NodeNum sender = 0x12345678; - queuePendingTimePlaceholderPacket(sender, 2000); // "received" 3s before the test's current millis() + queuePendingTimePlaceholderPacket(sender, 2); // "received" at uptime 2s, 3s before the fixture's 5000ms now PhoneAPITestShim api; startHandshake(api); @@ -624,7 +625,7 @@ static void test_time_given_at_handshake_end_does_not_rewrite_already_sent_packe ScopedTimeFixture timeFixture(5000); const NodeNum sender = 0x12345678; - queuePendingTimePlaceholderPacket(sender, 2000); + queuePendingTimePlaceholderPacket(sender, 2); PhoneAPITestShim api; startHandshake(api); @@ -650,6 +651,68 @@ static void test_time_given_at_handshake_end_does_not_rewrite_already_sent_packe api.close(); } +// The NodeDB half of the same transition: a node heard while the clock was untrusted gets no +// last_heard at all (the arrival instant waits in the RAM sidecar as uptime seconds), and the +// clock-valid hook backfills it to the real epoch of the sighting - so the phone reads +// "last heard: unknown" only until time arrives, never a boot-relative value. +static void test_node_heard_before_time_gets_last_heard_backfilled(void) +{ + ScopedMeshService scopedService; + ScopedTimeFixture timeFixture(5000); + + const NodeNum sender = 0x22334455; + meshtastic_MeshPacket heard = meshtastic_MeshPacket_init_zero; + heard.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + heard.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + heard.from = sender; + heard.to = NODENUM_BROADCAST; + heard.rx_time = 2; // uptime-seconds placeholder: "arrived at uptime 2s" + heard.has_rx_time = false; + nodeDB->updateFrom(heard); + + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(sender); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_EQUAL_UINT32(0u, info->last_heard); // absent, never a boot-relative stamp + + struct timeval networkTime; + networkTime.tv_sec = time(NULL) + SEC_PER_DAY; + networkTime.tv_usec = 0; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime)); + + // Heard at uptime 2s, clock arrived at uptime 5s: the sighting dates to nowEpoch - 3. + TEST_ASSERT_UINT32_WITHIN(2, (uint32_t)networkTime.tv_sec - 3, info->last_heard); +} + +// Uptime zero is a valid arrival instant during the first second of boot. It must not be confused +// with an absent sidecar record when network time arrives. +static void test_node_heard_during_first_uptime_second_gets_last_heard_backfilled(void) +{ + ScopedMeshService scopedService; + ScopedTimeFixture timeFixture(500); + + const NodeNum sender = 0x33445566; + TEST_ASSERT_NOT_NULL(nodeDB->getOrCreateMeshNode(sender)); + meshtastic_MeshPacket heard = meshtastic_MeshPacket_init_zero; + heard.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + heard.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + heard.from = sender; + heard.to = NODENUM_BROADCAST; + heard.rx_time = 0; // received during uptime second zero + heard.has_rx_time = false; + nodeDB->updateFrom(heard); + + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(sender); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_EQUAL_UINT32(0u, info->last_heard); + + struct timeval networkTime; + networkTime.tv_sec = time(NULL) + SEC_PER_DAY; + networkTime.tv_usec = 0; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime)); + + TEST_ASSERT_UINT32_WITHIN(1, (uint32_t)networkTime.tv_sec, info->last_heard); +} + /// Unity per-test setup; fixtures are local to each test. void setUp(void) {} /// Unity per-test teardown; fixtures clean themselves up. @@ -674,6 +737,8 @@ void setup() 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); + RUN_TEST(test_node_heard_before_time_gets_last_heard_backfilled); + RUN_TEST(test_node_heard_during_first_uptime_second_gets_last_heard_backfilled); // 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_throttle/test_main.cpp b/test/test_throttle/test_main.cpp new file mode 100644 index 000000000..e2630ba3d --- /dev/null +++ b/test/test_throttle/test_main.cpp @@ -0,0 +1,237 @@ +// Unit tests for src/mesh/Throttle.{h,cpp} - the firmware's elapsed-time and deadline helpers. +// +// These drive the injected clock across the 32-bit millis() wrap, which is not otherwise reachable +// in a test, and which every caller of these helpers depends on being handled correctly. +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "mesh/Throttle.h" +#include +#include + +void setUp(void) {} +void tearDown(void) +{ + Time::useRealClock(); // don't leak the fake clock into other suites +} + +// --- basic window semantics --- + +void test_isWithinTimespan_true_inside_window() +{ + Time::setTestMillis(10000); + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(9500, 1000)); // 500ms elapsed of a 1000ms window +} + +void test_isWithinTimespan_false_outside_window() +{ + Time::setTestMillis(10000); + TEST_ASSERT_FALSE(Throttle::isWithinTimespanMs(8000, 1000)); // 2000ms elapsed +} + +// The boundary is exclusive: elapsed == interval is NOT "within". +void test_isWithinTimespan_boundary_is_exclusive() +{ + Time::setTestMillis(10000); + TEST_ASSERT_FALSE(Throttle::isWithinTimespanMs(9000, 1000)); // exactly 1000ms elapsed + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(9001, 1000)); // 999ms elapsed +} + +// --- hasElapsed is the exact complement --- + +void test_hasElapsed_is_complement_of_isWithinTimespan() +{ + Time::setTestMillis(10000); + const uint32_t cases[][2] = {{9500, 1000}, {8000, 1000}, {9000, 1000}, {10000, 1}, {0, 5000}}; + for (auto &c : cases) { + TEST_ASSERT_EQUAL(!Throttle::isWithinTimespanMs(c[0], c[1]), Throttle::hasElapsed(c[0], c[1])); + } +} + +void test_hasElapsed_boundary_is_inclusive() +{ + Time::setTestMillis(10000); + TEST_ASSERT_TRUE(Throttle::hasElapsed(9000, 1000)); // exactly 1000ms elapsed + TEST_ASSERT_FALSE(Throttle::hasElapsed(9001, 1000)); // 999ms elapsed +} + +// --- rollover: the headline property --- + +// A window opened just before the 32-bit wrap must still close correctly after it. +void test_isWithinTimespan_survives_millis_wrap() +{ + const uint32_t lastRun = 0xFFFFFF00u; // 256ms before the wrap + Time::setTestMillis(lastRun); + + Time::advanceTestMillis(100); // 0xFFFFFF64 - still before the wrap + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(lastRun, 1000)); + + Time::advanceTestMillis(200); // wraps to 0x0000002C - 300ms elapsed in total + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(lastRun, 1000)); + TEST_ASSERT_FALSE(Throttle::hasElapsed(lastRun, 1000)); + + Time::advanceTestMillis(800); // 1100ms elapsed in total, well past the wrap + TEST_ASSERT_FALSE(Throttle::isWithinTimespanMs(lastRun, 1000)); + TEST_ASSERT_TRUE(Throttle::hasElapsed(lastRun, 1000)); +} + +// The long-interval end of the range: a 24h window (the longest in the tree) across the wrap. +void test_long_interval_survives_wrap() +{ + const uint32_t dayMs = 24u * 60u * 60u * 1000u; // 86,400,000 + const uint32_t lastRun = 0xFFFFFF00u; + Time::setTestMillis(lastRun); + + Time::advanceTestMillis(dayMs - 1); + TEST_ASSERT_TRUE(Throttle::isWithinTimespanMs(lastRun, dayMs)); + + Time::advanceTestMillis(1); // exactly one day elapsed + TEST_ASSERT_TRUE(Throttle::hasElapsed(lastRun, dayMs)); +} + +// --- deadlinePassed() --- + +void test_deadlinePassed_basic() +{ + Time::setTestMillis(10000); + TEST_ASSERT_FALSE(Throttle::deadlinePassed(10001)); // 1ms in the future + TEST_ASSERT_TRUE(Throttle::deadlinePassed(10000)); // exactly now counts as passed + TEST_ASSERT_TRUE(Throttle::deadlinePassed(9999)); // 1ms in the past +} + +// The property the naive `millis() > deadline` compare fails: a deadline set before the wrap must +// fire once, and only once, after the wrap. +void test_deadlinePassed_survives_millis_wrap() +{ + Time::setTestMillis(0xFFFFFF00u); // 256ms before the wrap + const uint32_t deadline = 0xFFFFFF00u + 500; + + TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline)); // not yet + Time::advanceTestMillis(400); // 0x00000090 - wrapped, still not due + TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline)); + Time::advanceTestMillis(100); // exactly due, past the wrap + TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline)); + Time::advanceTestMillis(60000); // stays passed + TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline)); +} + +// The naive compare's actual failure mode, pinned so a regression is unmistakable: before the wrap +// the deadline is numerically smaller than now, so `millis() > deadline` would fire it early. +void test_deadlinePassed_does_not_fire_early_when_deadline_wraps() +{ + Time::setTestMillis(0xFFFFFF00u); + const uint32_t deadline = 0xFFFFFF00u + 1000; // wraps to 0x000002E8 + + TEST_ASSERT_TRUE(deadline < Time::getMillis()); // the naive compare would fire here + TEST_ASSERT_FALSE(Throttle::deadlinePassed(deadline)); +} + +// deadlinePassedAt() judges against a caller-supplied now, so a loop that snapshots the clock once +// gets one instant for every entry - including across the wrap, where the clock has moved on. +void test_deadlinePassedAt_uses_the_supplied_now() +{ + Time::setTestMillis(0xFFFFFF00u); + const uint32_t now = Time::getMillis(); + const uint32_t deadline = 0xFFFFFF00u + 500; // wraps to 0x000000F4 + + TEST_ASSERT_FALSE(Throttle::deadlinePassedAt(now, deadline)); + TEST_ASSERT_TRUE(Throttle::deadlinePassedAt(deadline, deadline)); // inclusive boundary + TEST_ASSERT_TRUE(Throttle::deadlinePassedAt(deadline + 1, deadline)); // past the wrap + Time::advanceTestMillis(60000); // clock moved, snapshot did not + TEST_ASSERT_FALSE(Throttle::deadlinePassedAt(now, deadline)); + TEST_ASSERT_TRUE(Throttle::deadlinePassed(deadline)); +} + +// deadlinePassed() cannot know about sentinels, so it reports them as passed. This pins that +// contract, since callers relying on it must test armed-ness first. +void test_deadlinePassed_reads_disarmed_sentinels_as_passed() +{ + Time::setTestMillis(6247); + + TEST_ASSERT_TRUE(Throttle::deadlinePassed(0)); // "inactive" for rebootAtMsec et al + TEST_ASSERT_TRUE(Throttle::deadlinePassed(UINT32_MAX)); // "inactive" for nagCycleCutoff + + // The guarded form every caller must use. + const uint32_t disarmed = 0; + TEST_ASSERT_FALSE(disarmed && Throttle::deadlinePassed(disarmed)); + + // And it still holds after a wrap. + Time::setTestMillis(0xFFFFFF00u); + Time::advanceTestMillis(1000); + TEST_ASSERT_FALSE(disarmed && Throttle::deadlinePassed(disarmed)); +} + +// --- execute() --- + +static int executeCount = 0; +static int deferCount = 0; +static void countExecute() +{ + executeCount++; +} +static void countDefer() +{ + deferCount++; +} + +void test_execute_runs_first_time_then_throttles() +{ + executeCount = 0; + deferCount = 0; + Time::setTestMillis(5000); + + uint32_t last = 0; // 0 means "never run" to execute() + TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute, countDefer)); + TEST_ASSERT_EQUAL(1, executeCount); + + // Immediately again: deferred. + TEST_ASSERT_FALSE(Throttle::execute(&last, 1000, countExecute, countDefer)); + TEST_ASSERT_EQUAL(1, executeCount); + TEST_ASSERT_EQUAL(1, deferCount); + + // After the interval: runs again. + Time::advanceTestMillis(1000); + TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute, countDefer)); + TEST_ASSERT_EQUAL(2, executeCount); +} + +void test_execute_survives_millis_wrap() +{ + executeCount = 0; + Time::setTestMillis(0xFFFFFF00u); + + uint32_t last = 0; + TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute)); // arms at 0xFFFFFF00 + TEST_ASSERT_EQUAL(1, executeCount); + + Time::advanceTestMillis(500); // wraps past 0 + TEST_ASSERT_FALSE(Throttle::execute(&last, 1000, countExecute)); // not due yet + TEST_ASSERT_EQUAL(1, executeCount); + + Time::advanceTestMillis(600); // 1100ms total + TEST_ASSERT_TRUE(Throttle::execute(&last, 1000, countExecute)); + TEST_ASSERT_EQUAL(2, executeCount); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_isWithinTimespan_true_inside_window); + RUN_TEST(test_isWithinTimespan_false_outside_window); + RUN_TEST(test_isWithinTimespan_boundary_is_exclusive); + RUN_TEST(test_hasElapsed_is_complement_of_isWithinTimespan); + RUN_TEST(test_hasElapsed_boundary_is_inclusive); + RUN_TEST(test_isWithinTimespan_survives_millis_wrap); + RUN_TEST(test_long_interval_survives_wrap); + RUN_TEST(test_deadlinePassed_basic); + RUN_TEST(test_deadlinePassed_survives_millis_wrap); + RUN_TEST(test_deadlinePassed_does_not_fire_early_when_deadline_wraps); + RUN_TEST(test_deadlinePassedAt_uses_the_supplied_now); + RUN_TEST(test_deadlinePassed_reads_disarmed_sentinels_as_passed); + RUN_TEST(test_execute_runs_first_time_then_throttles); + RUN_TEST(test_execute_survives_millis_wrap); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_uptime_clock/test_main.cpp b/test/test_uptime_clock/test_main.cpp new file mode 100644 index 000000000..f950102c2 --- /dev/null +++ b/test/test_uptime_clock/test_main.cpp @@ -0,0 +1,356 @@ +// Unit tests for src/UptimeClock.{h,cpp} - the monotonic uptime seam. +// Covers: test-clock injection, stepping the injected clock, the real-clock fallback, and the +// single-writer wrap carry (readers derive, serviceMonotonic() publishes). getMillis() itself is a +// plain 32-bit read with no wrap handling of its own - its consumers' wrap arithmetic is tested in +// test_throttle/. +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "gps/RTC.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +std::atomic publishPaused{false}; +std::atomic releasePublish{false}; + +void pauseMonotonicPublish() +{ + publishPaused.store(true, std::memory_order_release); + while (!releasePublish.load(std::memory_order_acquire)) + std::this_thread::yield(); +} +} // namespace + +void setUp(void) +{ + Time::resetMonotonicForTests(); // absolute uptime assertions must not depend on case order +} +void tearDown(void) +{ + Time::useRealClock(); // don't leak the fake clock into other suites + resetRTCStateForTests(); +} + +// Step the injected clock the way the firmware does: the main loop calls serviceMonotonic() every +// iteration, so any advance is followed by a publish. +static void advanceAndService(uint32_t deltaMs) +{ + Time::advanceTestMillis(deltaMs); + Time::serviceMonotonic(); +} + +// --- injection --- + +void test_getMillis_returns_injected_value() +{ + Time::setTestMillis(123456); + TEST_ASSERT_EQUAL_UINT32(123456, Time::getMillis()); +} + +void test_advanceTestMillis_steps_clock() +{ + Time::setTestMillis(1000); + Time::advanceTestMillis(500); + TEST_ASSERT_EQUAL_UINT32(1500, Time::getMillis()); +} + +// Advancing past 0xFFFFFFFF wraps like millis() does, rather than saturating. This is the property +// the Throttle wrap tests are built on, so it is worth pinning here too. +void test_advanceTestMillis_wraps_like_millis() +{ + Time::setTestMillis(0xFFFFFF00u); + Time::advanceTestMillis(0x200u); + TEST_ASSERT_EQUAL_UINT32(0x00000100u, Time::getMillis()); +} + +// --- getMillisMonotonic(): the published wrap carry --- + +void test_monotonic_matches_millis_before_any_wrap() +{ + Time::setTestMillis(123456); + TEST_ASSERT_EQUAL_UINT64(123456u, Time::getMillisMonotonic()); +} + +void test_monotonic_counts_a_wrap() +{ + Time::setTestMillis(0xFFFFFF00u); + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT64(0xFFFFFF00u, Time::getMillisMonotonic()); + + advanceAndService(0x200u); // crosses the 32-bit wrap; low word is now 0x00000100 + TEST_ASSERT_EQUAL_UINT64(0x100000100ull, Time::getMillisMonotonic()); +} + +// The property that lets readers stay pure: a reader adds its own unsigned elapsed time to the +// published snapshot, so it is exact across a wrap that no publish has observed yet. Nothing here +// needs to detect the boundary, which is why concurrent readers cannot double-count it. +void test_monotonic_reader_crosses_the_wrap_without_a_publish() +{ + Time::setTestMillis(0xFFFFFF00u); + Time::serviceMonotonic(); // last publish before the wrap + + Time::advanceTestMillis(0x200u); // cross the wrap with no publish at all + TEST_ASSERT_EQUAL_UINT64(0x100000100ull, Time::getMillisMonotonic()); +} + +// Reads must not advance the carry. Under the old read-modify-write accessor each reader bumped +// the wrap counter itself, which is what made two of them able to count one wrap twice. +void test_monotonic_reads_do_not_advance_the_carry() +{ + Time::setTestMillis(0xFFFFFF00u); + Time::serviceMonotonic(); + + Time::advanceTestMillis(0x200u); + for (int i = 0; i < 8; i++) + TEST_ASSERT_EQUAL_UINT64(0x100000100ull, Time::getMillisMonotonic()); + + Time::serviceMonotonic(); // the eight reads must not have left eight wraps behind + TEST_ASSERT_EQUAL_UINT64(0x100000100ull, Time::getMillisMonotonic()); +} + +void test_monotonic_counts_every_wrap_when_serviced_each_window() +{ + Time::setTestMillis(0x80000000u); + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT64(0x80000000ull, Time::getMillisMonotonic()); + + // Three full 2^32 cycles, published once per half-cycle - well inside the required + // one-publish-per-49.7-days window. + for (int wrap = 1; wrap <= 3; wrap++) { + advanceAndService(0x80000000u); // crosses the wrap; low word back to 0 + advanceAndService(0x80000000u); // completes the cycle; low word back to 0x80000000 + TEST_ASSERT_EQUAL_UINT64(0x80000000ull + ((uint64_t)wrap << 32), Time::getMillisMonotonic()); + } +} + +// The documented contract, pinned: a full 2^32 ms elapsing between two publishes is +// indistinguishable from no time passing, so the wrap is lost. This is why the main loop's +// per-iteration serviceMonotonic() matters - and it is now the only obligation, where before every +// reader had to participate. +void test_monotonic_misses_a_wrap_not_serviced_within_the_window() +{ + Time::setTestMillis(1000); + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT64(1000u, Time::getMillisMonotonic()); + + Time::advanceTestMillis(0x80000000u); + advanceAndService(0x80000000u); // full cycle with no publish in between: low word is 1000 again + + TEST_ASSERT_EQUAL_UINT64(1000u, Time::getMillisMonotonic()); // the elapsed 2^32 ms is lost +} + +void test_getUptimeSecs_stays_exact_across_the_wrap() +{ + Time::setTestMillis(4294967000u); // 4294967 whole seconds, 296ms short of the wrap + Time::serviceMonotonic(); + TEST_ASSERT_EQUAL_UINT32(4294967u, Time::getUptimeSecs()); + + advanceAndService(1000); // crosses the wrap + TEST_ASSERT_EQUAL_UINT32(4294968u, Time::getUptimeSecs()); +} + +// --- concurrent readers --- + +// Readers run flat out while the clock is stepped across several wraps. Under the old accessor two +// readers interleaving inside the wrap window could each bump the counter, jumping every later +// reading 2^32 ms forward; here they only ever read, so the final value has to be exact. +// +// A one-instruction race is not something a test can hit on demand, so this is corroboration +// rather than the guarantee - the guarantee is structural, and test_monotonic_reads_do_not_advance +// _the_carry pins it. What this case does catch is any future change that puts a write back on the +// read path. +void test_monotonic_exact_with_concurrent_readers() +{ + constexpr int kReaders = 4; + constexpr int kWraps = 3; + constexpr uint32_t kStep = 0x40000000u; // quarter of a cycle, so each wrap is crossed mid-step + + Time::setTestMillis(0xFFFFF000u); + Time::serviceMonotonic(); + + std::atomic stop{false}; + std::atomic wentBackwards{false}; + std::vector readers; + for (int i = 0; i < kReaders; i++) { + readers.emplace_back([&stop, &wentBackwards]() { + uint64_t previous = 0; + while (!stop.load(std::memory_order_relaxed)) { + const uint64_t now = Time::getMillisMonotonic(); + if (now < previous) + wentBackwards.store(true, std::memory_order_relaxed); + previous = now; + } + }); + } + + uint64_t expected = 0xFFFFF000ull; + for (int i = 0; i < kWraps * 4; i++) { + advanceAndService(kStep); + expected += kStep; + } + + stop.store(true, std::memory_order_relaxed); + for (auto &reader : readers) + reader.join(); + + TEST_ASSERT_FALSE_MESSAGE(wentBackwards.load(std::memory_order_relaxed), "monotonic clock retreated for a reader"); + TEST_ASSERT_EQUAL_UINT64(expected, Time::getMillisMonotonic()); +} + +// nRF BLE callbacks run above the main loop. A reader that preempts publication must be able to +// consume the previous complete snapshot without waiting for the suspended writer. +void test_monotonic_reader_completes_while_publish_is_paused() +{ + Time::setTestMillis(100); + Time::serviceMonotonic(); + Time::advanceTestMillis(1); + + publishPaused.store(false, std::memory_order_relaxed); + releasePublish.store(false, std::memory_order_relaxed); + Time::setMonotonicPublishHookForTests(pauseMonotonicPublish); + + std::thread writer([]() { Time::serviceMonotonic(); }); + while (!publishPaused.load(std::memory_order_acquire)) + std::this_thread::yield(); + + std::atomic readerStarted{false}; + std::atomic readerDone{false}; + uint64_t readerValue = 0; + std::thread reader([&readerStarted, &readerDone, &readerValue]() { + readerStarted.store(true, std::memory_order_release); + readerValue = Time::getMillisMonotonic(); + readerDone.store(true, std::memory_order_release); + }); + while (!readerStarted.load(std::memory_order_acquire)) + std::this_thread::yield(); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); + while (!readerDone.load(std::memory_order_acquire) && std::chrono::steady_clock::now() < deadline) + std::this_thread::yield(); + const bool completedWhilePaused = readerDone.load(std::memory_order_acquire); + + releasePublish.store(true, std::memory_order_release); + writer.join(); + reader.join(); + Time::setMonotonicPublishHookForTests(nullptr); + + TEST_ASSERT_TRUE_MESSAGE(completedWhilePaused, "reader waited for a lower-priority publisher"); + TEST_ASSERT_EQUAL_UINT64(101u, readerValue); +} + +// --- getTime(): the wall clock must not retreat at the millis() wrap --- + +// Epoch used by the wall-clock cases; must sit between BUILD_EPOCH (stamped at build time) and +// BUILD_EPOCH + 40 years or perhapsSetRTC() rejects it as implausible - so derive it. +#ifdef BUILD_EPOCH +static constexpr uint32_t kTestEpoch = (uint32_t)BUILD_EPOCH + 3600; +#else +static constexpr uint32_t kTestEpoch = 1800000000u; +#endif + +void test_getTime_stays_exact_across_the_wrap() +{ + resetRTCStateForTests(); + Time::setTestMillis(0xFFFFFF00u); // 256ms short of the wrap + Time::serviceMonotonic(); + + struct timeval tv = {}; + tv.tv_sec = kTestEpoch; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + TEST_ASSERT_EQUAL_UINT32(kTestEpoch, getTime(false)); + + advanceAndService(400u * 1000u); // crosses the wrap partway through + // With a 32-bit anchor this read came back 49.7 days in the past. + TEST_ASSERT_EQUAL_UINT32(kTestEpoch + 400, getTime(false)); +} + +// The anchor must also be correct when the time-set itself happens after a counted wrap, i.e. +// when the monotonic clock is already past 32-bit range. +void test_getTime_anchored_after_a_wrap_is_exact() +{ + resetRTCStateForTests(); + Time::setTestMillis(0xFFFFFF00u); + Time::serviceMonotonic(); // latch the pre-wrap value + advanceAndService(0x200u); // cross the wrap; monotonic is now > 2^32 + + struct timeval tv = {}; + tv.tv_sec = kTestEpoch; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + + advanceAndService(100u * 1000u); + TEST_ASSERT_EQUAL_UINT32(kTestEpoch + 100, getTime(false)); +} + +// A reader on another thread must not be able to perturb the wall clock. This is the user-visible +// shape of the race: getTime() is reached from the nRF52 BLE task and the portduino web server +// threads, and a double-counted wrap put every rx_time and last_heard ~49.7 days in the future. +void test_getTime_unaffected_by_concurrent_readers_across_the_wrap() +{ + resetRTCStateForTests(); + Time::setTestMillis(0xFFFFF800u); // exactly 0x800 short of the wrap, so the first advance lands on it + Time::serviceMonotonic(); + + struct timeval tv = {}; + tv.tv_sec = kTestEpoch; + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + + std::atomic stop{false}; + std::vector readers; + for (int i = 0; i < 4; i++) { + readers.emplace_back([&stop]() { + while (!stop.load(std::memory_order_relaxed)) + (void)getTime(false); // what the BLE / web-server threads actually call + }); + } + + advanceAndService(0x800u); // cross the wrap while the readers are running + advanceAndService(60u * 1000u); // and some ordinary time after it + + stop.store(true, std::memory_order_relaxed); + for (auto &reader : readers) + reader.join(); + + TEST_ASSERT_EQUAL_UINT32(kTestEpoch + 62, getTime(false)); // 0x800ms + 60s, rounded down +} + +// --- real clock fallback --- + +void test_real_clock_advances_when_not_injected() +{ + Time::useRealClock(); + uint32_t t0 = Time::getMillis(); + testDelay(5); + uint32_t t1 = Time::getMillis(); + TEST_ASSERT_TRUE(t1 >= t0); // real millis() is monotonic over a short delay +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_getMillis_returns_injected_value); + RUN_TEST(test_advanceTestMillis_steps_clock); + RUN_TEST(test_advanceTestMillis_wraps_like_millis); + RUN_TEST(test_monotonic_matches_millis_before_any_wrap); + RUN_TEST(test_monotonic_counts_a_wrap); + RUN_TEST(test_monotonic_reader_crosses_the_wrap_without_a_publish); + RUN_TEST(test_monotonic_reads_do_not_advance_the_carry); + RUN_TEST(test_monotonic_counts_every_wrap_when_serviced_each_window); + RUN_TEST(test_monotonic_misses_a_wrap_not_serviced_within_the_window); + RUN_TEST(test_getUptimeSecs_stays_exact_across_the_wrap); + RUN_TEST(test_monotonic_exact_with_concurrent_readers); + RUN_TEST(test_monotonic_reader_completes_while_publish_is_paused); + RUN_TEST(test_getTime_stays_exact_across_the_wrap); + RUN_TEST(test_getTime_anchored_after_a_wrap_is_exact); + RUN_TEST(test_getTime_unaffected_by_concurrent_readers_across_the_wrap); + RUN_TEST(test_real_clock_advances_when_not_injected); + exit(UNITY_END()); +} + +void loop() {} From f06f808391baa4caf76ff3b96ca26205ce6725cd Mon Sep 17 00:00:00 2001 From: Jason P Date: Wed, 12 Aug 2026 21:59:26 +0000 Subject: [PATCH 033/109] Add Transmit Enabled menu to LoRa frame (#11442) * Add Transmit Enabled menu to LoRa frame * Fix outer variable shadows --- src/graphics/draw/DebugRenderer.cpp | 134 +++++++++++++++------------- src/graphics/draw/MenuHandler.cpp | 40 ++++++++- src/graphics/draw/MenuHandler.h | 2 + 3 files changed, 110 insertions(+), 66 deletions(-) diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index b50c7081c..3227ed604 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -224,75 +224,83 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, #if !defined(OLED_TINY) // === Fifth Row: Channel Utilization === - const char *chUtil = "ChUtil:"; - char chUtilPercentage[10]; - snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent()); - - int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10 - : display->getStringWidth(chUtil) + 5; - int chUtil_y = getTextPositions(display)[line] + 3; - - int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50; - int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border - int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7; - int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3; - int chutil_percent = airTime->channelUtilizationPercent(); - const int raw_chutil_percent = chutil_percent; - - int centerofscreen = SCREEN_WIDTH / 2; - int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; - int starting_position = centerofscreen - total_line_content_width; - - display->drawString(starting_position, getTextPositions(display)[line], chUtil); - - // Force 61% or higher to show a full 100% bar, text would still show related percent. - if (chutil_percent >= 61) { - chutil_percent = 100; - } - - // Weighting for nonlinear segments - float milestone1 = 25; - float milestone2 = 40; - float weight1 = 0.45; // Weight for 0-25% - float weight2 = 0.35; // Weight for 25-40% - float weight3 = 0.20; // Weight for 40-100% - float totalWeight = weight1 + weight2 + weight3; - - int seg1 = chutil_bar_max_fill * (weight1 / totalWeight); - int seg2 = chutil_bar_max_fill * (weight2 / totalWeight); - int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors - - int fillRight = 0; - - if (chutil_percent <= milestone1) { - fillRight = (seg1 * (chutil_percent / milestone1)); - } else if (chutil_percent <= milestone2) { - fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1))); + if (!config.lora.tx_enabled) { + const char *txdisabled = "Transmit Disabled"; + textWidth = display->getStringWidth(txdisabled); + display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line], txdisabled); } else { - fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2))); - } - // Draw outline - display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height); + const char *chUtil = "ChUtil:"; + char chUtilPercentage[10]; + snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent()); - // Fill progress - if (fillRight > 0) { -#if GRAPHICS_TFT_COLORING_ENABLED - uint16_t UtilizationFillColor = TFTPalette::Good; - if (raw_chutil_percent >= 60) { - UtilizationFillColor = TFTPalette::Bad; - } else if (raw_chutil_percent >= 35) { - UtilizationFillColor = TFTPalette::Medium; + int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10 + : display->getStringWidth(chUtil) + 5; + int chUtil_y = getTextPositions(display)[line] + 3; + + int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50; + int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border + int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7; + int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3; + int chutil_percent = airTime->channelUtilizationPercent(); + const int raw_chutil_percent = chutil_percent; + + int centerofscreen = SCREEN_WIDTH / 2; + int total_line_content_width = + (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; + int starting_position = centerofscreen - total_line_content_width; + + display->drawString(starting_position, getTextPositions(display)[line], chUtil); + + // Force 61% or higher to show a full 100% bar, text would still show related percent. + if (chutil_percent >= 61) { + chutil_percent = 100; } - setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, - starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); -#endif - display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); - } - display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++], - chUtilPercentage); + // Weighting for nonlinear segments + float milestone1 = 25; + float milestone2 = 40; + float weight1 = 0.45; // Weight for 0-25% + float weight2 = 0.35; // Weight for 25-40% + float weight3 = 0.20; // Weight for 40-100% + float totalWeight = weight1 + weight2 + weight3; + + int seg1 = chutil_bar_max_fill * (weight1 / totalWeight); + int seg2 = chutil_bar_max_fill * (weight2 / totalWeight); + int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors + + int fillRight = 0; + + if (chutil_percent <= milestone1) { + fillRight = (seg1 * (chutil_percent / milestone1)); + } else if (chutil_percent <= milestone2) { + fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1))); + } else { + fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2))); + } + + // Draw outline + display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height); + + // Fill progress + if (fillRight > 0) { +#if GRAPHICS_TFT_COLORING_ENABLED + uint16_t UtilizationFillColor = TFTPalette::Good; + if (raw_chutil_percent >= 60) { + UtilizationFillColor = TFTPalette::Bad; + } else if (raw_chutil_percent >= 35) { + UtilizationFillColor = TFTPalette::Medium; + } + setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, + starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); #endif + display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); + } + + display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++], + chUtilPercentage); +#endif + } graphics::drawCommonFooter(display, x, y); } diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index f5cd21e1a..6471d70c6 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -148,27 +148,31 @@ void menuHandler::loraMenu() "Radio Preset", "Frequency Slot", "LoRa Region", + "Transmit Enabled", #if HAS_LORA_FEM "FEM LNA", #endif }; + // NOTE: "FEM LNA" must stay last; it is the only entry that can be hidden at runtime by + // trimming optionsCount, which only works for a trailing option. enum optionsNumbers { Back = 0, DeviceRolePicker = 1, RadioPresetPicker = 2, FrequencySlot = 3, LoraPicker = 4, + TxEnabled = 5, #if HAS_LORA_FEM - LoraFemLna = 5 + LoraFemLna = 6 #endif }; BannerOverlayOptions bannerOptions; bannerOptions.message = "LoRa Actions"; bannerOptions.optionsArrayPtr = optionsArray; #if HAS_LORA_FEM - bannerOptions.optionsCount = loraFEMInterface.isLnaCanControl() ? 6 : 5; + bannerOptions.optionsCount = loraFEMInterface.isLnaCanControl() ? 7 : 6; #else - bannerOptions.optionsCount = 5; + bannerOptions.optionsCount = 6; #endif bannerOptions.bannerCallback = [](int selected) -> void { if (selected == Back) { @@ -181,6 +185,8 @@ void menuHandler::loraMenu() menuHandler::menuQueue = menuHandler::FrequencySlot; } else if (selected == LoraPicker) { menuHandler::menuQueue = menuHandler::LoraPicker; + } else if (selected == TxEnabled) { + menuHandler::menuQueue = menuHandler::TXEnabledMenu; } #if HAS_LORA_FEM else if (selected == LoraFemLna) { @@ -571,6 +577,31 @@ void menuHandler::radioPresetPicker() screen->showOverlayBanner(buildRegionPresetBanner()); } +void menuHandler::txEnabledMenu() +{ + static const char *optionsArray[] = {"Back", "Enabled", "Disabled"}; + enum optionsNumbers { Back = 0, Enabled = 1, Disabled = 2 }; + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Transmit Enabled"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = 3; + bannerOptions.InitialSelected = config.lora.tx_enabled ? Enabled : Disabled; + bannerOptions.bannerCallback = [](int selected) -> void { + // -1 is the timeout/dismiss case; treat it like Back so we never write config. + if (selected <= Back) { + menuHandler::menuQueue = menuHandler::LoraMenu; + screen->runNow(); + return; + } + bool wanted = (selected == Enabled); + if (config.lora.tx_enabled == wanted) + return; + config.lora.tx_enabled = wanted; + service->reloadConfig(SEGMENT_CONFIG); + }; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::twelveHourPicker() { static const char *optionsArray[] = {"Back", "12-hour", "24-hour"}; @@ -2943,6 +2974,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case RadioPresetPicker: radioPresetPicker(); break; + case TXEnabledMenu: + txEnabledMenu(); + break; case FrequencySlot: FrequencySlotPicker(); break; diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 311205e0a..965093223 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -13,6 +13,7 @@ class menuHandler LoraPicker, DeviceRolePicker, RadioPresetPicker, + TXEnabledMenu, FrequencySlot, NoTimeoutLoraPicker, TzPicker, @@ -73,6 +74,7 @@ class menuHandler static void loraMenu(); static void deviceRolePicker(); static void radioPresetPicker(); + static void txEnabledMenu(); static void FrequencySlotPicker(); static void handleMenuSwitch(OLEDDisplay *display); static void showConfirmationBanner(const char *message, std::function onConfirm); From 5172e5852696c1808b07e6a44ec065dcc81c4c62 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 12 Aug 2026 18:15:18 -0500 Subject: [PATCH 034/109] fix(nimble): stop leaking BLE2904 descriptor on BLE re-setup (#11453) setupService() re-runs on every Bluetooth re-enable cycle, and every other callback object in it is deliberately function-local static for exactly that reason - the battery level descriptor was the one heap allocation that was missed. The framework never frees descriptors (~BLECharacteristic has an empty body and BLEDevice::deinit only deletes server/advertising/scan), so each cycle leaked one BLE2904. Make it static like its neighbors. --- src/nimble/NimbleBluetooth.cpp | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 36f5f9d0e..e01c32c72 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -1025,13 +1025,16 @@ void NimbleBluetooth::setupService() // Setup the battery service BLEService *batteryService = bleServer->createService(BLEUUID((uint16_t)0x180f)); // 0x180F is the Battery Service - BLE2904 *batteryLevelDescriptor = new BLE2904(); - batteryLevelDescriptor->setFormat(BLE2904::FORMAT_UINT8); - batteryLevelDescriptor->setNamespace(1); - batteryLevelDescriptor->setUnit(0x27ad); + // Static like the callback objects above: setupService() re-runs on every BLE re-enable, and + // the framework never frees descriptors (~BLECharacteristic is empty), so a heap allocation + // here leaks one BLE2904 per cycle. + static BLE2904 batteryLevelDescriptor; + batteryLevelDescriptor.setFormat(BLE2904::FORMAT_UINT8); + batteryLevelDescriptor.setNamespace(1); + batteryLevelDescriptor.setUnit(0x27ad); BatteryCharacteristic = batteryService->createCharacteristic( // 0x2A19 is the Battery Level characteristic) (uint16_t)0x2a19, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); - BatteryCharacteristic->addDescriptor(batteryLevelDescriptor); + BatteryCharacteristic->addDescriptor(&batteryLevelDescriptor); // Seed an initial 0-100 level so an early read of 0x2A19 returns a valid value. uint8_t initialLevel = (powerStatus && powerStatus->getHasBattery()) ? powerStatus->getBatteryChargePercent() : 0; if (initialLevel > 100) From a41ddec1a7368af384e53e85024ae22f5e88f342 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 12 Aug 2026 18:17:33 -0500 Subject: [PATCH 035/109] fix(nrf54l15): don't write past String buffer when a grow fails (#11454) * fix(nrf54l15): don't write past String buffer when a grow fails reserve() correctly keeps the old buffer when realloc returns NULL, but returned void, and assign()/concat() proceeded to memcpy with n >= _cap anyway - a heap overflow of up to n+1-_cap bytes into adjacent allocations. On this Zephyr target allocation failure is a realistic condition, and the result was heap corruption instead of a clean no-op. reserve() now reports success and the callers leave the string unchanged when the grow fails. * fix(nrf54l15): guard String length arithmetic against wraparound Per review: reject size requests whose n+1 / _len+n arithmetic would wrap before they reach the capacity check, and make reserve(0) fail without calling realloc (realloc(p, 0) would free the buffer and return NULL, leaving _buf dangling). --- src/platform/nrf54l15/Arduino.h | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/platform/nrf54l15/Arduino.h b/src/platform/nrf54l15/Arduino.h index c67628afa..0d7449e89 100644 --- a/src/platform/nrf54l15/Arduino.h +++ b/src/platform/nrf54l15/Arduino.h @@ -599,8 +599,12 @@ class String void assign(const char *s, unsigned int n) { - if (n >= _cap) - reserve(n + 1); + // reserve() keeps the old (smaller) buffer on OOM, so a failed grow must abort the + // write: memcpy'ing n >= _cap bytes would overflow into adjacent heap. + if (n + 1 == 0) + return; // n + 1 would wrap + if (n >= _cap && !reserve(n + 1)) + return; if (_buf) { memcpy(_buf, s, n); _buf[n] = 0; @@ -612,21 +616,27 @@ class String if (!s || n == 0) return; unsigned newlen = _len + n; - if (newlen >= _cap) - reserve(newlen + 1); + if (newlen < _len || newlen + 1 == 0) + return; // length arithmetic wrapped + if (newlen >= _cap && !reserve(newlen + 1)) + return; // OOM: keep the existing content intact instead of writing past the buffer if (_buf) { memcpy(_buf + _len, s, n); _len = newlen; _buf[_len] = 0; } } - void reserve(unsigned int n) + bool reserve(unsigned int n) { + if (n == 0) + return false; char *b = (char *)realloc(_buf, n); if (b) { _buf = b; _cap = n; + return true; } + return false; } }; From d27d0c4a7f0aa1ac3778fca721837e328550e5a1 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 12 Aug 2026 18:26:27 -0500 Subject: [PATCH 036/109] fix(detectionsensor): release unsent packets and drop heap scratch buffer (#11449) Both sendDetectionMessage() and sendCurrentStateMessage() allocate a packet from packetPool and then, when the primary channel is the public/default channel, log and return without sending or releasing it. Each refused send permanently leaks one packet from the fixed-size pool; with state_broadcast_secs configured, the heartbeat path repeats this on a timer until the pool is exhausted and the device can no longer allocate packets at all. Release the packet in the refusal branch, matching the pattern used in PositionModule. Also replace the per-message 'new char[40]' scratch buffer with a stack buffer (and sprintf with snprintf), removing the manual delete[] bookkeeping on every exit path. --- src/modules/DetectionSensorModule.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/modules/DetectionSensorModule.cpp b/src/modules/DetectionSensorModule.cpp index 1de1bc184..927fe7b1a 100644 --- a/src/modules/DetectionSensorModule.cpp +++ b/src/modules/DetectionSensorModule.cpp @@ -128,11 +128,10 @@ int32_t DetectionSensorModule::runOnce() void DetectionSensorModule::sendDetectionMessage() { LOG_DEBUG("Detected event observed. Send message"); - char *message = new char[40]; - sprintf(message, "%s detected", moduleConfig.detection_sensor.name); + char message[40]; + snprintf(message, sizeof(message), "%s detected", moduleConfig.detection_sensor.name); meshtastic_MeshPacket *p = allocDataPacket(); if (!p) { - delete[] message; return; } p->want_ack = false; @@ -147,18 +146,18 @@ void DetectionSensorModule::sendDetectionMessage() if (!channels.isDefaultChannel(0)) { LOG_INFO("Send message id=%d, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes); service->sendToMesh(p); - } else + } else { LOG_ERROR("Message not allow on Public channel"); - delete[] message; + packetPool.release(p); + } } void DetectionSensorModule::sendCurrentStateMessage(bool state) { - char *message = new char[40]; - sprintf(message, "%s state: %i", moduleConfig.detection_sensor.name, state); + char message[40]; + snprintf(message, sizeof(message), "%s state: %i", moduleConfig.detection_sensor.name, state); meshtastic_MeshPacket *p = allocDataPacket(); if (!p) { - delete[] message; return; } p->want_ack = false; @@ -168,9 +167,10 @@ void DetectionSensorModule::sendCurrentStateMessage(bool state) if (!channels.isDefaultChannel(0)) { LOG_INFO("Send message id=%d, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes); service->sendToMesh(p); - } else + } else { LOG_ERROR("Message not allow on Public channel"); - delete[] message; + packetPool.release(p); + } } bool DetectionSensorModule::hasDetectionEvent() From 10217172f0faed88d1f2042e7562406ae309c7c2 Mon Sep 17 00:00:00 2001 From: Jason P Date: Wed, 12 Aug 2026 18:51:07 -0500 Subject: [PATCH 037/109] Fixed GPS icon alignment for all OLEDs (#11463) --- src/graphics/draw/UIRenderer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index b82b850f9..a81942aba 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -543,7 +543,7 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht if (currentResolution == ScreenResolution::High) { NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display); } else { - display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS); + display->drawXbm(x + 1, y + 3, imgGPS_width, imgGPS_height, imgGPS); } display->drawString(x + textOffset, y, textString); From 0739d2a68c1e3fa52e836eea0b951336982f4b97 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 12 Aug 2026 19:12:32 -0500 Subject: [PATCH 038/109] fix(raspihttp): memory safety and error-path leaks in PiWebServer (#11451) - handleAPIv1ToRadio: the toRadio handler memcpy'd a fixed 512 bytes out of the request body regardless of its actual size. ulfius allocates binary_body at exactly binary_body_length bytes and leaves it NULL for a body-less PUT, so short bodies caused a heap overread and empty ones dereferenced NULL. The unclamped length (ulfius accepts up to 1024) was also handed to handleToRadio, reading past the 512-byte stack buffer. Clamp both directions, matching the ESP32 ContentHandler behavior. - callback_static_file: the stream-free callback only runs when ulfius_set_stream_response succeeds, so the failure branch leaked the FILE (and its fd) on every failed request. Close it and return 500. - CheckSSLandLoad: free cert_pem before the missing-key return; the constructor retries after regenerating certs and the reload overwrote (leaked) the first buffer. - generate_rsa_key / CreateSSLCertificate: free the EVP_PKEY_CTX, EVP_PKEY, and X509 on their error returns; previously only the success paths released them. --- src/mesh/raspihttp/PiWebServer.cpp | 34 +++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index 9158a3061..5dae68a73 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -206,6 +206,10 @@ int callback_static_file(const struct _u_request *request, struct _u_response *r if (ulfius_set_stream_response(response, 200, callback_static_file_stream, callback_static_file_stream_free, length, STATIC_FILE_CHUNK, f) != U_OK) { LOG_DEBUG("callback_static_file - Error ulfius_set_stream_response"); + // The stream-free callback only runs when the stream was accepted, so the + // file must be closed here or the FILE and its fd leak on every failure. + fclose(f); + ulfius_set_string_body_response(response, 500, "Internal server error"); } } } else { @@ -256,9 +260,13 @@ int handleAPIv1ToRadio(const struct _u_request *req, struct _u_response *res, vo } byte buffer[MAX_TO_FROM_RADIO_SIZE]; - size_t s = req->binary_body_length; - - memcpy(buffer, req->binary_body, MAX_TO_FROM_RADIO_SIZE); + // ulfius allocates binary_body at exactly binary_body_length bytes (NULL for a body-less + // PUT), and the framework accepts bodies larger than our buffer, so clamp both directions. + size_t s = req->binary_body ? req->binary_body_length : 0; + if (s > sizeof(buffer)) + s = sizeof(buffer); + if (s > 0) + memcpy(buffer, req->binary_body, s); // FIXME* Problem with portdunio loosing mountpoint maybe because of running in a real sep. thread @@ -327,12 +335,11 @@ int generate_rsa_key(EVP_PKEY **pkey) EVP_PKEY_CTX *pkey_ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, NULL); if (!pkey_ctx) return -1; - if (EVP_PKEY_keygen_init(pkey_ctx) <= 0) - return -1; - if (EVP_PKEY_CTX_set_rsa_keygen_bits(pkey_ctx, 2048) <= 0) - return -1; - if (EVP_PKEY_keygen(pkey_ctx, pkey) <= 0) + if (EVP_PKEY_keygen_init(pkey_ctx) <= 0 || EVP_PKEY_CTX_set_rsa_keygen_bits(pkey_ctx, 2048) <= 0 || + EVP_PKEY_keygen(pkey_ctx, pkey) <= 0) { + EVP_PKEY_CTX_free(pkey_ctx); return -1; + } EVP_PKEY_CTX_free(pkey_ctx); return 0; // SUCCESS } @@ -413,6 +420,10 @@ int PiWebServerThread::CheckSSLandLoad() key_pem = read_file_into_string(KEY_PATH); if (key_pem == NULL) { LOG_ERROR("File private_key can't be loaded or missing"); + // The constructor retries CheckSSLandLoad() after regenerating, which would overwrite + // (and leak) the cert buffer loaded above. + free(cert_pem); + cert_pem = NULL; return 2; } @@ -432,6 +443,9 @@ int PiWebServerThread::CreateSSLCertificate() if (generate_self_signed_x509(pkey, &x509) != 0) { LOG_ERROR("Error generating X509-Cert"); + // generate_self_signed_x509 can fail after allocating *x509; X509_free(NULL) is a no-op + X509_free(x509); + EVP_PKEY_free(pkey); return 2; } @@ -439,6 +453,8 @@ int PiWebServerThread::CreateSSLCertificate() FILE *pkey_file = fopen(KEY_PATH, "wb"); if (!pkey_file) { LOG_ERROR("Error opening private key file"); + X509_free(x509); + EVP_PKEY_free(pkey); return 3; } // write private key file @@ -449,6 +465,8 @@ int PiWebServerThread::CreateSSLCertificate() FILE *x509_file = fopen(CERT_PATH, "wb"); if (!x509_file) { LOG_ERROR("Error opening cert"); + X509_free(x509); + EVP_PKEY_free(pkey); return 4; } // write certificate From 22079ca863da9519530148418bdaeb99bdc872dd Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 12 Aug 2026 19:13:07 -0500 Subject: [PATCH 039/109] fix(platform): OOM null-write in stm32wl File and exception leak in portduino GPIO init (#11456) - STM32_LittleFS File::_open_dir: the _dir_path allocation was the only unchecked malloc in the file, followed immediately by strcpy - an OOM became a NULL write, and the half-initialized state (open dir, null path) would later feed strlen(NULL) in openNextFile(). Check it and unwind the already-opened dir, matching the sibling failure path. - PortduinoGlue initGPIOPin: if setSilent()/gpioBind() threw after the LinuxGPIOPin was constructed, the pointer was lost in the catch block. Hold it in a unique_ptr and release only after the gpio table takes ownership. --- src/platform/portduino/PortduinoGlue.cpp | 6 +++--- src/platform/stm32wl/STM32_LittleFS_File.cpp | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 3076be764..df977a028 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -845,10 +845,10 @@ int initGPIOPin(int pinNum, const std::string &gpioChipName, int line) std::string gpio_name = "GPIO" + std::to_string(pinNum); std::cout << "Initializing " << gpio_name << " on chip " << gpioChipName << std::endl; try { - GPIOPin *csPin; - csPin = new LinuxGPIOPin(pinNum, gpioChipName.c_str(), line, gpio_name.c_str()); + auto csPin = std::make_unique(pinNum, gpioChipName.c_str(), line, gpio_name.c_str()); csPin->setSilent(); - gpioBind(csPin); + gpioBind(csPin.get()); + csPin.release(); // owned by the gpio table from here on return ERRNO_OK; } catch (...) { const std::type_info *t = abi::__cxa_current_exception_type(); diff --git a/src/platform/stm32wl/STM32_LittleFS_File.cpp b/src/platform/stm32wl/STM32_LittleFS_File.cpp index 1f8ae1dea..dfe04aaf1 100644 --- a/src/platform/stm32wl/STM32_LittleFS_File.cpp +++ b/src/platform/stm32wl/STM32_LittleFS_File.cpp @@ -100,11 +100,18 @@ bool File::_open_dir(char const *filepath) return false; } - _is_dir = true; - _dir_path = (char *)rtos_malloc(strlen(filepath) + 1); + if (!_dir_path) { + // match the _dir failure path above: don't leave a half-open dir behind + lfs_dir_close(_fs->_getFS(), _dir); + rtos_free(_dir); + _dir = NULL; + return false; + } strcpy(_dir_path, filepath); + _is_dir = true; + return true; } From d4de61362b837efd8dd734b1ba423b93aa01c144 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 12 Aug 2026 19:14:18 -0500 Subject: [PATCH 040/109] fix(mesh): plug pooled-object and driver leaks in core paths (#11450) - MeshService::sendQueueStatusToPhone: release the pooled QueueStatus when the toPhone queue enqueue fails, matching what sendMqttMessageToClientProxy and sendClientNotification already do. The full-queue guard makes this failure rare, but the check/enqueue sequence is not atomic and this path is reachable concurrently from the main loop and the nRF52 BLE write callback; each failure permanently lost one of the four pool slots, and after four losses the phone never receives QueueStatus again until reboot. - MessageStore::storeTextInPool: bail out when the boot-time pool allocation failed instead of memcpy'ing through a null pointer. The read side (getTextFromPool) already guards and maps offset 0 to an empty string. - RF95Interface: hold the RadioLibRF95 driver in a unique_ptr. It is constructed in init(), and when init() subsequently fails (e.g. chip probe NOT_FOUND) initLoRa() destroys the interface, leaking the driver; every sibling interface holds its driver by value so nothing else needed a destructor here. --- src/MessageStore.cpp | 4 ++++ src/mesh/MeshService.cpp | 2 ++ src/mesh/RF95Interface.cpp | 3 ++- src/mesh/RF95Interface.h | 8 +++++++- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 4030bdd28..913a40c45 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -42,6 +42,10 @@ static inline void resetMessagePool() // If not enough space remains, wrap around (ring buffer style) static inline uint16_t storeTextInPool(const char *src, size_t len) { + // Pool allocation can fail at boot; getTextFromPool() already maps offset 0 to "" in that case + if (!g_messagePool) + return 0; + if (len >= MAX_MESSAGE_SIZE) len = MAX_MESSAGE_SIZE - 1; diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 162d15353..0d450804c 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -353,6 +353,8 @@ ErrorCode MeshService::sendQueueStatusToPhone(const meshtastic_QueueStatus &qs, lastQueueStatus = *copied; res = toPhoneQueueStatusQueue.enqueue(copied, 0); + if (!res) + releaseQueueStatusToPool(copied); fromNum++; return res ? ERRNO_OK : ERRNO_UNKNOWN; diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 6968b5654..909d47e23 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -129,7 +129,8 @@ bool RF95Interface::init() limitPower(RF95_MAX_POWER); - iface = lora = new RadioLibRF95(&module); + lora.reset(new RadioLibRF95(&module)); + iface = lora.get(); #ifdef RF95_TCXO pinMode(RF95_TCXO, OUTPUT); diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index e01dfe376..2cd483572 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -4,12 +4,18 @@ #include "RadioLibInterface.h" #include "RadioLibRF95.h" +#include + /** * Our new not radiohead adapter for RF95 style radios */ class RF95Interface : public RadioLibInterface { - RadioLibRF95 *lora = NULL; // Either a RFM95 or RFM96 depending on what was stuffed on this board + // Either a RFM95 or RFM96 depending on what was stuffed on this board. + // Owned here; every other radio interface holds its driver by value, but this one is + // constructed in init(), so unique_ptr keeps it from leaking when init() fails and the + // interface is destroyed. + std::unique_ptr lora; public: RF95Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs, RADIOLIB_PIN_TYPE irq, RADIOLIB_PIN_TYPE rst, From 4296d5d584723011ac1c96e698074f865f05eedb Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 12 Aug 2026 19:38:04 -0500 Subject: [PATCH 041/109] fix(eth): free partially initialized TLS contexts on init failure (#11452) * fix(eth): free partially initialized TLS contexts on init failure initTlsContext() inits the four mbedtls contexts and populates them step by step, but every failure return left the already-parsed material (X.509 chain, EC key, ssl config) allocated. Since tlsReady is only set after full success, deInitEthTlsApiServer()'s cleanup - guarded by if (tlsReady) - could never reclaim that partial state, and runOnce() hard-fails with the contexts stranded for the life of the process. Also reachable via the cert-regeneration path when a DHCP lease change bumps the cert generation and the rebuild fails. Factor the four frees into freeTlsContexts() (safe on init-but- unpopulated contexts) and call it from every initTlsContext failure return; deInit now uses the same helper. * docs(eth): shorten freeTlsContexts comment per review --- src/mesh/eth/ethTlsApiServer.cpp | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/mesh/eth/ethTlsApiServer.cpp b/src/mesh/eth/ethTlsApiServer.cpp index b73cafce0..d658f0a2a 100644 --- a/src/mesh/eth/ethTlsApiServer.cpp +++ b/src/mesh/eth/ethTlsApiServer.cpp @@ -61,6 +61,16 @@ static mbedtls_ssl_config sslConf; static mbedtls_ssl_context ssl; static bool tlsReady = false; +// Free all TLS contexts, including partially initialized ones - initTlsContext's failure +// paths must use this, because deInit's cleanup only runs once tlsReady is set. +static void freeTlsContexts() +{ + mbedtls_ssl_free(&ssl); + mbedtls_ssl_config_free(&sslConf); + mbedtls_pk_free(&pkKey); + mbedtls_x509_crt_free(&certChain); +} + // Adapter: route mbedtls_ssl_set_bio() through the EthernetClient instance // that runOnce() is currently servicing. The void* ctx we hand mbedtls is a // pointer to the EthernetClient. @@ -243,12 +253,14 @@ class EthTlsApiServerThread : public concurrency::OSThread ret = mbedtls_x509_crt_parse_der(&certChain, cert.certDer.data(), cert.certDer.size()); if (ret != 0) { LOG_ERROR("ETH TLS: x509_crt_parse_der failed -0x%04x", -ret); + freeTlsContexts(); return false; } ret = mbedtls_pk_parse_key(&pkKey, cert.keyDer.data(), cert.keyDer.size(), nullptr, 0, picoRand, nullptr); if (ret != 0) { LOG_ERROR("ETH TLS: pk_parse_key failed -0x%04x", -ret); + freeTlsContexts(); return false; } @@ -256,6 +268,7 @@ class EthTlsApiServerThread : public concurrency::OSThread MBEDTLS_SSL_PRESET_DEFAULT); if (ret != 0) { LOG_ERROR("ETH TLS: ssl_config_defaults failed -0x%04x", -ret); + freeTlsContexts(); return false; } @@ -272,12 +285,14 @@ class EthTlsApiServerThread : public concurrency::OSThread ret = mbedtls_ssl_conf_own_cert(&sslConf, &certChain, &pkKey); if (ret != 0) { LOG_ERROR("ETH TLS: conf_own_cert failed -0x%04x", -ret); + freeTlsContexts(); return false; } ret = mbedtls_ssl_setup(&ssl, &sslConf); if (ret != 0) { LOG_ERROR("ETH TLS: ssl_setup failed -0x%04x", -ret); + freeTlsContexts(); return false; } @@ -340,10 +355,7 @@ void deInitEthTlsApiServer() tlsServer = nullptr; } if (tlsReady) { - mbedtls_ssl_free(&ssl); - mbedtls_ssl_config_free(&sslConf); - mbedtls_pk_free(&pkKey); - mbedtls_x509_crt_free(&certChain); + freeTlsContexts(); tlsReady = false; } } From b4c2eb0b78ec7924b1db981644e09c8da8e1aa12 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 13 Aug 2026 14:31:39 +0800 Subject: [PATCH 042/109] refactor(led): generalize LED_LORA init from ThinkNode-M7 (#11437) Migrate LED_LORA init to match existing LED init patterns. Signed-off-by: Andrew Yong Co-authored-by: Jonathan Bennett --- src/main.cpp | 5 +++++ variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 3a4eb5f5f..6c13515af 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -393,6 +393,11 @@ void setup() digitalWrite(LED_NOTIFICATION, HIGH ^ LED_STATE_ON); #endif +#ifdef LED_LORA + pinMode(LED_LORA, OUTPUT); + digitalWrite(LED_LORA, HIGH ^ LED_STATE_ON); +#endif + #ifdef WIFI_LED pinMode(WIFI_LED, OUTPUT); digitalWrite(WIFI_LED, HIGH ^ WIFI_STATE_ON); diff --git a/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp index fbb9d37c5..17f767c0b 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp +++ b/variants/esp32s3/ELECROW-ThinkNode-M7/variant.cpp @@ -5,6 +5,4 @@ void initVariant() { pinMode(LED_PAIRING, OUTPUT); digitalWrite(LED_PAIRING, !LED_STATE_ON); // Turn off the LED to start - pinMode(LED_LORA, OUTPUT); - digitalWrite(LED_LORA, !LED_STATE_ON); // Turn off the LED to start } From a65d9aef39b332e43ae5b561474cc5ce7ededf64 Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Thu, 13 Aug 2026 09:26:45 +0200 Subject: [PATCH 043/109] Add ADS1X15 ADC (#9846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add ADS1X15 class * Initialize bus and address in ADS1X15Sensor * Initialize member variables and pass bus to ads1x15 begin object * More register values options for ADS1X15 * Mark constructor as explicit * Move ADS1X15 from PowerTelemetry to EnvironmentTelemetry: * Adds ADS to Environment Telemetry * Adds possibility to use template function with multiple devices of the same type on the same bus with different addresses * Moves moduleConfig dev overrides to i2cScan function * Make logs in env telemetry only show what has been collected * Fix channel naming and logging * Remove scannerToSensorsMap for ADS1X15 * Fix ADS1X15 reclock * Fix merge * Trunk format issue * Set port * Remove status overrride * Return status on boot * Set data rate as per coderabbit request. * Add define for ADS type and use it to set SPS * Add object based on define --------- Co-authored-by: Thomas Göttgens --- platformio.ini | 2 + src/configuration.h | 5 +- src/detect/ScanI2C.h | 5 +- src/detect/ScanI2CTwoWire.cpp | 20 +- .../Telemetry/EnvironmentTelemetry.cpp | 57 ++++-- src/modules/Telemetry/PowerTelemetry.cpp | 13 +- .../Telemetry/Sensor/ADS1X15Sensor.cpp | 175 ++++++++++++++++++ src/modules/Telemetry/Sensor/ADS1X15Sensor.h | 52 ++++++ 8 files changed, 308 insertions(+), 21 deletions(-) create mode 100644 src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp create mode 100644 src/modules/Telemetry/Sensor/ADS1X15Sensor.h diff --git a/platformio.ini b/platformio.ini index 3f562ab33..2c7973ecf 100644 --- a/platformio.ini +++ b/platformio.ini @@ -257,6 +257,8 @@ lib_deps = https://github.com/Sensirion/arduino-i2c-scd30/archive/1.1.1.zip # renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip + # renovate: datasource=custom.pio depName=Adafruit ADS1X15 packageName=adafruit/library/Adafruit ADS1X15 Library + https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip # renovate: datasource=github-tags depName=Adafruit DS248x packageName=adafruit/Adafruit_DS248x https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip diff --git a/src/configuration.h b/src/configuration.h index 0ed4dd893..795856955 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -301,6 +301,10 @@ along with this program. If not, see . #define LTR553ALS_ADDR 0x23 #define SEN5X_ADDR 0x69 #define SCD30_ADDR 0x61 +#define ADS1X15_ADDR 0x48 +#define ADS1X15_ADDR_ALT1 0x49 +#define ADS1X15_ADDR_ALT2 0x4A +#define ADS1X15_ADDR_ALT3 0x4B #define DS248X_ADDR 0x18 // same as MCP9808_ADDR, STK8BXX_ADDR and LIS3DH_ADDR #define DS248X_ADDR_ALT1 0x19 // same as LIS3DH_ADDR_ALT and BMA423_ADDR #define DS248X_ADDR_ALT2 0x1A // same as CST328_ADDR @@ -311,7 +315,6 @@ along with this program. If not, see . #define DS248X_ADDR_ALT7 0x1F // same as BBQ10_KB_ADDR #define HM330X_ADDR 0x40 - // ----------------------------------------------------------------------------- // ACCELEROMETER // ----------------------------------------------------------------------------- diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 70f848b33..46d5395a6 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -99,13 +99,14 @@ class ScanI2C SFA30, CW2015, SCD30, - ADS1115, + ADS1X15, + ADS1X15_ALT, IIS2MDCTR, ISM330DHCX, SPA06, DS248X, HM330X - } DeviceType; + } DeviceType; // typedef uint8_t DeviceAddress; typedef enum I2CPort { diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 21a74b759..af484eb15 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -1040,10 +1040,11 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; } + // ADS1X15 default config register is 8583h registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2); - if (registerValue == 0x8583 || registerValue == 0x8580) { - type = ADS1115; - logFoundDevice("ADS1115 ADC", (uint8_t)addr.address); + if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700) { + type = ADS1X15; + logFoundDevice("ADS1X15 ADC", (uint8_t)addr.address); break; } @@ -1052,6 +1053,19 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; } + case ADS1X15_ADDR_ALT1: + case ADS1X15_ADDR_ALT2: + case ADS1X15_ADDR_ALT3: { + // ADS1X15 default config register is 8583h + registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2); + if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700) { + type = ADS1X15_ALT; + logFoundDevice("ADS1X15_ALT", (uint8_t)addr.address); + break; + } + break; + } + default: LOG_INFO("Device found at address 0x%x was not able to be enumerated", (uint8_t)addr.address); } diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 9a6076b28..aa985c909 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -131,6 +131,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/BH1750Sensor.h" #endif +#if __has_include() +#include "Sensor/ADS1X15Sensor.h" +#endif + #if __has_include() #include "Sensor/DS248XSensor.h" #endif @@ -347,6 +351,10 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::BH1750); #endif +#if __has_include() + addSensor(i2cScanner, ScanI2C::DeviceType::ADS1X15); + addSensor(i2cScanner, ScanI2C::DeviceType::ADS1X15_ALT); +#endif #if __has_include() // TODO Can we scan for multiple sensors connected on the same bus? addSensor(i2cScanner, ScanI2C::DeviceType::SHTXX); @@ -759,21 +767,48 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) m.time = getTime(); bool validTelemetry = getEnvironmentTelemetry(&m); + if (validTelemetry) { - LOG_INFO("Send: barometric_pressure=%f, current=%f, gas_resistance=%f, relative_humidity=%f, temperature=%f", - m.variant.environment_metrics.barometric_pressure, m.variant.environment_metrics.current, - m.variant.environment_metrics.gas_resistance, m.variant.environment_metrics.relative_humidity, - m.variant.environment_metrics.temperature); - LOG_INFO("Send: voltage=%f, IAQ=%d, distance=%f, lux=%f", m.variant.environment_metrics.voltage, - m.variant.environment_metrics.iaq, m.variant.environment_metrics.distance, m.variant.environment_metrics.lux); + if (m.variant.environment_metrics.has_temperature || m.variant.environment_metrics.has_relative_humidity || + m.variant.environment_metrics.has_barometric_pressure) + LOG_INFO("Send: barometric_pressure=%fkPa, relative_humidity=%f%RH, temperature=%fdegC", + m.variant.environment_metrics.barometric_pressure, m.variant.environment_metrics.relative_humidity, + m.variant.environment_metrics.temperature); - LOG_INFO("Send: wind speed=%fm/s, direction=%d degrees, weight=%fkg", m.variant.environment_metrics.wind_speed, - m.variant.environment_metrics.wind_direction, m.variant.environment_metrics.weight); + if (m.variant.environment_metrics.has_voltage || m.variant.environment_metrics.has_current || + m.variant.environment_metrics.has_iaq || m.variant.environment_metrics.has_gas_resistance) + LOG_INFO("Send: voltage=%f, current=%f, IAQ=%d, gas_resistance=%f", m.variant.environment_metrics.voltage, + m.variant.environment_metrics.current, m.variant.environment_metrics.iaq, + m.variant.environment_metrics.gas_resistance); - LOG_INFO("Send: radiation=%fµR/h", m.variant.environment_metrics.radiation); + if (m.variant.environment_metrics.has_distance || m.variant.environment_metrics.has_lux) + LOG_INFO("Send: distance=%f, lux=%f", m.variant.environment_metrics.distance, m.variant.environment_metrics.lux); - LOG_INFO("Send: soil_temperature=%f, soil_moisture=%u", m.variant.environment_metrics.soil_temperature, - m.variant.environment_metrics.soil_moisture); + if (m.variant.environment_metrics.has_wind_speed || m.variant.environment_metrics.has_wind_direction) + LOG_INFO("Send: wind speed=%fm/s, direction=%d degrees", m.variant.environment_metrics.wind_speed, + m.variant.environment_metrics.wind_direction); + + if (m.variant.environment_metrics.has_weight) + LOG_INFO("Send: weight=%fkg", m.variant.environment_metrics.weight); + + if (m.variant.environment_metrics.has_radiation) + LOG_INFO("Send: radiation=%fµR/h", m.variant.environment_metrics.radiation); + + if (m.variant.environment_metrics.has_soil_temperature || m.variant.environment_metrics.has_soil_moisture) + LOG_INFO("Send: soil_temperature=%f, soil_moisture=%u", m.variant.environment_metrics.soil_temperature, + m.variant.environment_metrics.soil_moisture); + + if (m.variant.environment_metrics.has_adc_voltage_ch0 || m.variant.environment_metrics.has_adc_voltage_ch1 || + m.variant.environment_metrics.has_adc_voltage_ch2 || m.variant.environment_metrics.has_adc_voltage_ch3) + LOG_INFO("Send: adc_ch0=%f, adc_ch1=%f, adc_ch2=%f, adc_ch3=%f", m.variant.environment_metrics.adc_voltage_ch0, + m.variant.environment_metrics.adc_voltage_ch1, m.variant.environment_metrics.adc_voltage_ch2, + m.variant.environment_metrics.adc_voltage_ch3); + + if (m.variant.environment_metrics.has_adc_voltage_ch4 || m.variant.environment_metrics.has_adc_voltage_ch5 || + m.variant.environment_metrics.has_adc_voltage_ch6 || m.variant.environment_metrics.has_adc_voltage_ch7) + LOG_INFO("Send: adc_ch4=%f, adc_ch5=%f, adc_ch6=%f, adc_ch7=%f", m.variant.environment_metrics.adc_voltage_ch4, + m.variant.environment_metrics.adc_voltage_ch5, m.variant.environment_metrics.adc_voltage_ch6, + m.variant.environment_metrics.adc_voltage_ch7); meshtastic_MeshPacket *p = allocDataProtobuf(m); if (!p) { diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index b00672d2d..60fe00c38 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -272,10 +272,15 @@ bool PowerTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) m.time = getTime(); bool validTelemetry = getPowerTelemetry(&m); if (validTelemetry) { - LOG_INFO("Send: ch1_voltage=%f, ch1_current=%f, ch2_voltage=%f, ch2_current=%f, " - "ch3_voltage=%f, ch3_current=%f", - m.variant.power_metrics.ch1_voltage, m.variant.power_metrics.ch1_current, m.variant.power_metrics.ch2_voltage, - m.variant.power_metrics.ch2_current, m.variant.power_metrics.ch3_voltage, m.variant.power_metrics.ch3_current); + LOG_INFO("Send: ch1_voltage=%f, ch2_voltage=%f, ch3_voltage=%f", m.variant.power_metrics.ch1_voltage, + m.variant.power_metrics.ch2_voltage, m.variant.power_metrics.ch3_voltage); + + bool hasAnyCurrent = m.variant.power_metrics.has_ch1_current || m.variant.power_metrics.has_ch2_current || + m.variant.power_metrics.has_ch3_current; + if (hasAnyCurrent) { + LOG_INFO("Send: ch1_current=%f, ch2_current=%f, ch3_current=%f", m.variant.power_metrics.ch1_current, + m.variant.power_metrics.ch2_current, m.variant.power_metrics.ch3_current); + } sensor_read_error_count = 0; diff --git a/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp b/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp new file mode 100644 index 000000000..1b22e6358 --- /dev/null +++ b/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp @@ -0,0 +1,175 @@ +#include "configuration.h" + +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "ADS1X15Sensor.h" +#include "TelemetrySensor.h" +#include + +ADS1X15Sensor::ADS1X15Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_ADS1X15, "ADS1X15") {} + +bool ADS1X15Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s (address: 0x%x)", sensorName, dev->address.address); + + _bus = bus; + _port = dev->address.port; + _address = dev->address.address; + _deviceType = dev->type; + +#ifdef ADS1X15_I2C_CLOCK_SPEED + reClockI2C.setup(_bus, _port); + reClockI2C.setClock(ADS1X15_I2C_CLOCK_SPEED); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + status = ads1x15.begin(_address, _bus); + +#ifdef ADS1X15_I2C_CLOCK_SPEED + reClockI2C.restoreClock(); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + initI2CSensor(); + + return status; +} + +struct _ADS1X15Measurement ADS1X15Sensor::getMeasurement(uint8_t ch) +{ + struct _ADS1X15Measurement measurement; + + // Reset gain + ads1x15.setGain(GAIN_TWOTHIRDS); + double voltage_range = 6.144; + + // Get value with full range + uint16_t value = ads1x15.readADC_SingleEnded(ch); + + // Dynamic gain, to increase resolution of low voltage values + // If value is under 4.096v increase the gain depending on voltage + if (value < 21845) { + if (value > 10922) { + + // 1x gain, 4.096V + ads1x15.setGain(GAIN_ONE); + voltage_range = 4.096; + + } else if (value > 5461) { + + // 2x gain, 2.048V + ads1x15.setGain(GAIN_TWO); + voltage_range = 2.048; + + } else if (value > 2730) { + + // 4x gain, 1.024V + ads1x15.setGain(GAIN_FOUR); + voltage_range = 1.024; + + } else if (value > 1365) { + + // 8x gain, 0.25V + ads1x15.setGain(GAIN_EIGHT); + voltage_range = 0.512; + + } else { + + // 16x gain, 0.125V + ads1x15.setGain(GAIN_SIXTEEN); + voltage_range = 0.256; + } + + // Get the value again + value = ads1x15.readADC_SingleEnded(ch); + } + + measurement.voltage = (float)value / 32768 * voltage_range; + + return measurement; +} + +struct _ADS1X15Measurements ADS1X15Sensor::getMeasurements() +{ + struct _ADS1X15Measurements measurements; + + // ADS1X15 has 4 channels starting from 0 + for (int i = 0; i < 4; i++) { + measurements.measurements[i] = getMeasurement(i); + } + + return measurements; +} + +bool ADS1X15Sensor::getMetrics(meshtastic_Telemetry *measurement) +{ + // Done here and not in getMeasurements to avoid the back-and-forth 4-8 times one after the other +#ifdef ADS1X15_I2C_CLOCK_SPEED + reClockI2C.setClock(ADS1X15_I2C_CLOCK_SPEED); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + struct _ADS1X15Measurements m = getMeasurements(); + +#ifdef ADS1X15_I2C_CLOCK_SPEED + reClockI2C.restoreClock(); +#endif /* ADS1X15_I2C_CLOCK_SPEED */ + + switch (_deviceType) { + case ScanI2C::DeviceType::ADS1X15: { + measurement->variant.environment_metrics.has_adc_voltage_ch0 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch1 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch2 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch3 = true; + + measurement->variant.environment_metrics.adc_voltage_ch0 = m.measurements[0].voltage; + measurement->variant.environment_metrics.adc_voltage_ch1 = m.measurements[1].voltage; + measurement->variant.environment_metrics.adc_voltage_ch2 = m.measurements[2].voltage; + measurement->variant.environment_metrics.adc_voltage_ch3 = m.measurements[3].voltage; + + LOG_DEBUG( + "Got %s readings: adc_voltage_ch0=%f, adc_voltage_ch1=%f, adc_voltage_ch2=%f, adc_voltage_ch3=%f", sensorName, + measurement->variant.environment_metrics.adc_voltage_ch0, measurement->variant.environment_metrics.adc_voltage_ch1, + measurement->variant.environment_metrics.adc_voltage_ch2, measurement->variant.environment_metrics.adc_voltage_ch3); + + break; + } + case ScanI2C::DeviceType::ADS1X15_ALT: { + measurement->variant.environment_metrics.has_adc_voltage_ch4 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch5 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch6 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch7 = true; + + measurement->variant.environment_metrics.adc_voltage_ch4 = m.measurements[0].voltage; + measurement->variant.environment_metrics.adc_voltage_ch5 = m.measurements[1].voltage; + measurement->variant.environment_metrics.adc_voltage_ch6 = m.measurements[2].voltage; + measurement->variant.environment_metrics.adc_voltage_ch7 = m.measurements[3].voltage; + + LOG_DEBUG( + "Got %s readings: adc_voltage_ch4=%f, adc_voltage_ch5=%f, adc_voltage_ch6=%f, adc_voltage_ch7=%f", sensorName, + measurement->variant.environment_metrics.adc_voltage_ch4, measurement->variant.environment_metrics.adc_voltage_ch5, + measurement->variant.environment_metrics.adc_voltage_ch6, measurement->variant.environment_metrics.adc_voltage_ch7); + + break; + } + default: { + measurement->variant.environment_metrics.has_adc_voltage_ch0 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch1 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch2 = true; + measurement->variant.environment_metrics.has_adc_voltage_ch3 = true; + + measurement->variant.environment_metrics.adc_voltage_ch0 = m.measurements[0].voltage; + measurement->variant.environment_metrics.adc_voltage_ch1 = m.measurements[1].voltage; + measurement->variant.environment_metrics.adc_voltage_ch2 = m.measurements[2].voltage; + measurement->variant.environment_metrics.adc_voltage_ch3 = m.measurements[3].voltage; + + LOG_DEBUG( + "Got %s readings: adc_voltage_ch0=%f, adc_voltage_ch1=%f, adc_voltage_ch2=%f, adc_voltage_ch3=%f", sensorName, + measurement->variant.environment_metrics.adc_voltage_ch0, measurement->variant.environment_metrics.adc_voltage_ch1, + measurement->variant.environment_metrics.adc_voltage_ch2, measurement->variant.environment_metrics.adc_voltage_ch3); + + break; + } + } + return true; +} + +#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/ADS1X15Sensor.h b/src/modules/Telemetry/Sensor/ADS1X15Sensor.h new file mode 100644 index 000000000..4d56e2db7 --- /dev/null +++ b/src/modules/Telemetry/Sensor/ADS1X15Sensor.h @@ -0,0 +1,52 @@ +#include "configuration.h" + +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../detect/ReClockI2C.h" +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "TelemetrySensor.h" +#include + +#define ADS1X15_I2C_CLOCK_SPEED 100000 +// ADS1X15 has no practical way to be detected. Use this to toggle +// between ADS1015 (0) or ADS1115 (1) +#ifndef MESHTASTIC_ADC_ADS1115 +#define MESHTASTIC_ADC_ADS1115 1 +#endif + +class ADS1X15Sensor : public TelemetrySensor +{ + private: +#if MESHTASTIC_ADC_ADS1115 + Adafruit_ADS1115 ads1x15{}; +#else + Adafruit_ADS1015 ads1x15{}; +#endif + +#ifdef ADS1X15_I2C_CLOCK_SPEED + ReClockI2C reClockI2C; +#endif + ScanI2C::DeviceType _deviceType{}; + + // get a single measurement for a channel + struct _ADS1X15Measurement getMeasurement(uint8_t ch); + + // get all measurements for all channels + struct _ADS1X15Measurements getMeasurements(); + + public: + ADS1X15Sensor(); + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; +}; + +struct _ADS1X15Measurement { + float voltage; +}; + +struct _ADS1X15Measurements { + // ADS1X15 has 4 channels + struct _ADS1X15Measurement measurements[4]; +}; + +#endif From fa031c95dce1f8a26b47c755f19b02d25e2529ed Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 13 Aug 2026 02:27:05 -0500 Subject: [PATCH 044/109] perf(crypto): stop heap-allocating a cipher object per packet (#11462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(crypto): stop heap-allocating a cipher object per packet encryptAESCtr() constructed a fresh CTR on the heap for every call - once per encrypted transmit and once per channel decrypt attempt on every received encrypted packet. On the platforms that use this base implementation (STM32WL, RP2040, nRF54L15, portduino) that is avoidable per-packet malloc/free churn on small heaps. Reuse lazily-created singletons instead. Safe for the same reason the function's static scratch buffer already is: every caller serializes under cryptLock, and setKey/setIV reinitialize the cipher state each call. Lazy heap pointers rather than static objects so ESP32/nRF52 (which override this method) never reserve the RAM. * Improve comments in encryptAESCtr function Refactor comments for clarity and conciseness in AES-CTR encryption implementation. --------- Co-authored-by: Thomas Göttgens --- src/mesh/CryptoEngine.cpp | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index f2c966cc7..bd199e8fd 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -403,11 +403,20 @@ void CryptoEngine::decrypt(uint32_t fromNode, uint64_t packetId, size_t numBytes // Generic implementation of AES-CTR encryption. void CryptoEngine::encryptAESCtr(CryptoKey _key, uint8_t *_nonce, size_t numBytes, uint8_t *bytes) { - std::unique_ptr ctr; - if (_key.length == 16) - ctr = std::unique_ptr(new CTR()); - else - ctr = std::unique_ptr(new CTR()); + // Reused instead of reallocated per packet: safe because all callers hold cryptLock and setKey/setIV reset the + // full cipher state. Lazy so overriding platforms reserve nothing; key material now lives until the next call. + static CTR *ctr128 = nullptr; + static CTR *ctr256 = nullptr; + CTRCommon *ctr; + if (_key.length == 16) { + if (!ctr128) + ctr128 = new CTR(); + ctr = ctr128; + } else { + if (!ctr256) + ctr256 = new CTR(); + ctr = ctr256; + } ctr->setKey(_key.bytes, _key.length); static uint8_t scratch[MAX_BLOCKSIZE]; memcpy(scratch, bytes, numBytes); From 210014e81f7c38521c4ca2a4f573e443edd8080a Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 13 Aug 2026 11:06:37 +0000 Subject: [PATCH 045/109] feat(stm32wl): add ST Nucleo-WL55JC variant (#11384) STM32 Nucleo-64 development board with STM32WL55JC MCU, SMPS, supports Arduino and ST morpho connectivity. https://www.st.com/en/evaluation-tools/nucleo-wl55jc.html Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong --- variants/stm32/nucleo_wl55jc/platformio.ini | 23 ++++++++ variants/stm32/nucleo_wl55jc/rfswitch.h | 9 ++++ variants/stm32/nucleo_wl55jc/variant.h | 59 +++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 variants/stm32/nucleo_wl55jc/platformio.ini create mode 100644 variants/stm32/nucleo_wl55jc/rfswitch.h create mode 100644 variants/stm32/nucleo_wl55jc/variant.h diff --git a/variants/stm32/nucleo_wl55jc/platformio.ini b/variants/stm32/nucleo_wl55jc/platformio.ini new file mode 100644 index 000000000..261f2a9a3 --- /dev/null +++ b/variants/stm32/nucleo_wl55jc/platformio.ini @@ -0,0 +1,23 @@ +; ST Nucleo-WL55JC dev board +; https://www.st.com/en/evaluation-tools/nucleo-wl55jc.html +[env:nucleo_wl55jc] +extends = stm32_base +board = nucleo_wl55jc +board_level = pr +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem +build_flags = + ${stm32_base.build_flags} + -Ivariants/stm32/nucleo_wl55jc + -DPRIVATE_HW + -DENABLE_HWSERIAL2 + -DHAS_GPS=1 + -DGPS_SERIAL_PORT=Serial2 ; Default Serial object is used for onboard ST-Link VCP + -DHAS_SENSOR=1 +lib_deps = + ${stm32_base.lib_deps} + # renovate: datasource=github-tags depName=STM32RTC packageName=stm32duino/STM32RTC + https://github.com/stm32duino/STM32RTC/archive/refs/tags/1.9.0.zip + # renovate: datasource=github-tags depName=STM32LowPower packageName=stm32duino/STM32LowPower + https://github.com/stm32duino/STM32LowPower/archive/refs/tags/1.5.0.zip + +upload_port = stlink diff --git a/variants/stm32/nucleo_wl55jc/rfswitch.h b/variants/stm32/nucleo_wl55jc/rfswitch.h new file mode 100644 index 000000000..04db6192a --- /dev/null +++ b/variants/stm32/nucleo_wl55jc/rfswitch.h @@ -0,0 +1,9 @@ +// Canonical RF switch macros from variant_NUCLEO_WL55JC1.h +// UM2592 S6.6.3: RF overview +static const RADIOLIB_PIN_TYPE rfswitch_pins[5] = {LORAWAN_RFSWITCH_PINS, RADIOLIB_NC, RADIOLIB_NC}; + +static const Module::RfSwitchMode_t rfswitch_table[5] = {{STM32WLx::MODE_IDLE, {LORAWAN_RFSWITCH_OFF_VALUES}}, + {STM32WLx::MODE_RX, {LORAWAN_RFSWITCH_RX_VALUES}}, + {STM32WLx::MODE_TX_LP, {LORAWAN_RFSWITCH_RFO_LP_VALUES}}, + {STM32WLx::MODE_TX_HP, {LORAWAN_RFSWITCH_RFO_HP_VALUES}}, + END_OF_MODE_TABLE}; diff --git a/variants/stm32/nucleo_wl55jc/variant.h b/variants/stm32/nucleo_wl55jc/variant.h new file mode 100644 index 000000000..18af214f2 --- /dev/null +++ b/variants/stm32/nucleo_wl55jc/variant.h @@ -0,0 +1,59 @@ +/* +ST Nucleo-WL55JC (MB1389) +https://www.st.com/en/evaluation-tools/nucleo-wl55jc.html +*/ + +#ifndef _VARIANT_NUCLEO_WL55JC_ +#define _VARIANT_NUCLEO_WL55JC_ + +#define USE_STM32WLx + +// Pin mappings from UM2592: User Manual, STM32WL Nucleo-64 board (MB1389) +// https://www.st.com/resource/en/user_manual/um2592-stm32wl-nucleo64-board-mb1389-stmicroelectronics.pdf + +// Human-readable pin macros from variant_NUCLEO_WL55JC1.h + +// UM2592 S6.6.1: LEDs +#define LED_POWER LED_GREEN +#define LED_STATE_ON 1 +#define LED_LORA LED_RED +#define LED_NOTIFICATION LED_BLUE + +// UM2592 S6.6.2: Push-buttons +#define BUTTON_PIN B1_BTN // WKUP1-capable +#define BUTTON_NEED_PULLUP +#define ALT_BUTTON_PIN B2_BTN +#define CANCEL_BUTTON_PIN B3_BTN +#define CANCEL_BUTTON_ACTIVE_LOW true +#define CANCEL_BUTTON_ACTIVE_PULLUP true + +// UM2592 S7.4: Arduino UNO R3 connectors - SPI +// Arduino UNO R3 header: CS/D10, MOSI/D11, MISO/D12, SCK/D13 +#define PIN_SPI_MOSI PA7 +#define PIN_SPI_MISO PA6 +#define PIN_SPI_SCK PA5 + +// UM2592 S7.4: Arduino UNO R3 connectors - UART (GPS, etc.) +// Arduino UNO R3 header: RX/D0, TX/D1 +#define PIN_SERIAL2_TX PB6 +#define PIN_SERIAL2_RX PB7 + +// UM2592 S7.4: Arduino UNO R3 connectors - I2C +// Arduino UNO R3 header: SDA/D14, SCL/D15 +#define PIN_WIRE_SDA PA11 +#define PIN_WIRE_SCL PA12 + +// RM0453 S18.10: Battery voltage monitoring +// Internal VBAT ADC channel; VBAT bridged to VDD_SYS by SB21 +#define BATTERY_PIN AVBAT +#define ADC_MULTIPLIER (1.01f * 3) + +// UM2592 S6.5.2: LSE clock +#define HAS_LSE 1 +#define STM32WL_LSE_DRIVE RCC_LSEDRIVE_LOW + +// UM2592 S6.5.1: HSE clock (used for sub-GHz radio as well) +// NDK NT2016SF-32M-END5875A +#define SX126X_DIO3_TCXO_VOLTAGE 1.7 + +#endif From c5d3321a4129771ec6062f62e7f302e55cc9aeff Mon Sep 17 00:00:00 2001 From: hackengineer Date: Thu, 13 Aug 2026 11:11:11 +0000 Subject: [PATCH 046/109] Fix unterminated MyNodeInfo.pio_env when APP_ENV is 40+ chars (#11468) strncpy does not null-terminate when the source fills the destination. A PlatformIO environment name of 40 or more characters leaves pio_env unterminated, nanopb aborts the whole MyInfo encode with 'unterminated string', getFromRadio() returns 0 bytes forever, and the client app never receives any config after want_config_id. Clients that receive a redacted MyInfo (pio_env cleared before encode) are unaffected, which makes the failure look client-specific when it is not. Co-authored-by: Claude Fable 5 --- src/mesh/PhoneAPI.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 45f9b2477..813d413de 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -579,6 +579,9 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) // app not to send locations on our behalf. fromRadioScratch.which_payload_variant = meshtastic_FromRadio_my_info_tag; strncpy(myNodeInfo.pio_env, optstr(APP_ENV), sizeof(myNodeInfo.pio_env)); + // strncpy does not terminate when the source fills the buffer; a 40+ char + // APP_ENV would make nanopb reject the MyInfo encode ("unterminated string"). + myNodeInfo.pio_env[sizeof(myNodeInfo.pio_env) - 1] = '\0'; myNodeInfo.nodedb_count = static_cast(nodeDB->getNumMeshNodes()); fromRadioScratch.my_info = myNodeInfo; #ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL From 674599544200ba77d447baee95b1a8418b555fef Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:25:03 +0200 Subject: [PATCH 047/109] docs: move the firmware design docs to the documentation site (#11488) The five documents under docs/ were written in this repo while their features were developed. Four of them describe shipped, upstream behaviour and belong on meshtastic.org, where users and client authors will look for them: traffic_management_module.md -> configuration/module/traffic-management + development/reference/traffic-management-internals node_info_stores.md -> development/reference/node-info-stores mesh_beacon_module.md -> configuration/module/mesh-beacon + development/reference/mesh-beacon-internals + development/device/mesh-beacon-client-interface lora_region_preset_compatibility_client_spec.md -> development/device/region-preset-compatibility Each is split by audience: settings pages carry the config surface in user terms, reference pages carry firmware mechanism, and the device pages carry the protocol a client app speaks. The region-preset spec always said it should graduate out of this repo once its protobuf landed upstream, which it has (FromRadio.region_presets, field 19). nexthop-routing-reliability.md is not documentation - it is a working document with a mitigation plan, a "files to modify" list and commit sequencing. Its mitigations shipped in #10745, so the plan is history and the analysis is superseded; it is dropped rather than published. Comments that cited the deleted files now point at the published pages, and the NextHop test header cites #10745 instead of the deleted plan. --- ...region_preset_compatibility_client_spec.md | 293 ----------- docs/mesh_beacon_module.md | 454 ----------------- docs/nexthop-routing-reliability.md | 456 ------------------ docs/node_info_stores.md | 321 ------------ docs/traffic_management_module.md | 222 --------- src/modules/TrafficManagementModule.cpp | 9 +- src/modules/TrafficManagementModule.h | 15 +- test/test_nexthop_routing/test_main.cpp | 2 +- 8 files changed, 17 insertions(+), 1755 deletions(-) delete mode 100644 docs/lora_region_preset_compatibility_client_spec.md delete mode 100644 docs/mesh_beacon_module.md delete mode 100644 docs/nexthop-routing-reliability.md delete mode 100644 docs/node_info_stores.md delete mode 100644 docs/traffic_management_module.md diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md deleted file mode 100644 index bb1749672..000000000 --- a/docs/lora_region_preset_compatibility_client_spec.md +++ /dev/null @@ -1,293 +0,0 @@ -# LoRa Region → Preset Compatibility - Client Implementation Spec - -**Status:** Draft for 2.8 · **Audience:** Meshtastic client app developers (Android first, -Apple second, then web/python) · **Firmware side:** implemented in `firmware` -(`FromRadio.region_presets`, see below). - -> This document lives in the firmware repo while the feature is developed. It is meant to -> graduate to `meshtastic/protobufs` (and/or the docs site) alongside the upstream protobuf -> PR that reserves `FromRadio` field **19**. - ---- - -## 1. Why this exists - -For 2.8 the LoRa regions and modem presets were reworked. **Not every modem preset is legal -in every region** - narrow EU SRD bands, the EU 868 "narrow" band, amateur/ham bands, and -the 2.4 GHz band each accept only a specific subset of presets. The firmware already -enforces this internally (it clamps or rejects illegal combinations), but until now a client -had no way to _know_ the rules, so a user could pick an illegal region+preset pair in the UI -and only discover the problem after the device silently corrected it. - -This feature has the firmware **declare the legal region→preset combinations** to the client -during the `want_config` handshake, so the client UI can constrain the preset picker to the -valid set for the currently selected region (and warn about licensed-only bands). It is -purely advisory metadata - the firmware remains the source of truth and still -validates/clamps on its own. - ---- - -## 2. Protocol additions - -Three new messages in `meshtastic/mesh.proto`, plus one new `FromRadio` oneof variant. - -### 2.1 `FromRadio.region_presets` (field 19) - -```proto -message FromRadio { - uint32 id = 1; - oneof payload_variant { - // ... fields 2..18 unchanged ... - LoRaRegionPresetMap region_presets = 19; - } -} -``` - -### 2.2 Messages - -```proto -// A distinct set of legal modem presets shared by one or more LoRa regions. -message LoRaPresetGroup { - repeated Config.LoRaConfig.ModemPreset presets = 1; // legal presets for this group - Config.LoRaConfig.ModemPreset default_preset = 2; // always one of `presets` - bool licensed_only = 3; // ham/amateur band → warn/gate -} - -// Associates a single LoRa region with its preset group (by index). -message LoRaRegionPresets { - Config.LoRaConfig.RegionCode region = 1; - uint32 group_index = 2; // index into LoRaRegionPresetMap.groups -} - -// The full map, delivered grouped to fit one FromRadio packet. -message LoRaRegionPresetMap { - repeated LoRaPresetGroup groups = 1; // each distinct preset list - repeated LoRaRegionPresets region_groups = 2; // every known region → a group index -} -``` - -### 2.3 Why grouped (and the size envelope clients should respect) - -A `FromRadio` packet is capped at **512 bytes** (`MAX_TO_FROM_RADIO_SIZE`). Most regions -share one identical preset list (the "standard" 10-preset list), so the map is delivered -**grouped**: `groups` holds each _distinct_ preset list once, and `region_groups` maps every -known region to one of those groups by index. This keeps the encoded size additive -(`groups` + `region_groups`) rather than multiplicative, well under the cap. - -nanopb (firmware) array bounds - clients do **not** need to enforce these, but they bound -what you can receive: - -| field | max_count | -| ----------------------------------- | ------------------------------------ | -| `LoRaRegionPresetMap.groups` | 8 | -| `LoRaRegionPresetMap.region_groups` | 38 (= number of `RegionCode` values) | -| `LoRaPresetGroup.presets` | 11 | - ---- - -## 3. When it is delivered - -`region_presets` is sent **once** during the `want_config` handshake, as a single -`FromRadio` message, in this position: - -```text -my_info → (deviceuiConfig) → node_info(self) → metadata → region_presets → channel… → config… → moduleConfig… → node_info(others)… → fileInfo… → config_complete_id → (live packets) -``` - -i.e. **immediately after `metadata` and before the first `channel`**. - -- It is included for a normal full `want_config` and for the **config-only** nonce. -- It is **omitted** for the **nodes-only** nonce (that path skips metadata/config entirely). -- A client must **not** assume it always arrives (see §5). - ---- - -## 4. Decoding into a usable lookup - -Flatten the grouped wire form into `Map`: - -```text -struct RegionPresetInfo { Set presets; ModemPreset default; bool licensedOnly } - -fun decode(map: LoRaRegionPresetMap): Map { - result = {} - for (rg in map.region_groups) { - if (rg.group_index >= map.groups.size) continue // defensive: malformed/forward data - g = map.groups[rg.group_index] - result[rg.region] = RegionPresetInfo( - presets = g.presets.toSet(), - default = g.default_preset, - licensedOnly = g.licensed_only) - } - return result -} -``` - -Persist this map alongside the rest of the downloaded config so the LoRa config screen can -read it synchronously. - ---- - -## 5. Semantics & rules (the load-bearing part) - -These rules are what keep the UX correct across firmware versions. Implement all of them. - -1. **Absent region ⇒ no constraint.** If a `RegionCode` does not appear in `region_groups`, - the client has _no_ compatibility info for it and **must not restrict** its preset - choices (fall back to allowing the full `ModemPreset` list). This happens for a handful - of `RegionCode` enum values that have no firmware band table entry (today: `EU_874`, - `EU_917`, `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`). - -2. **Absent message ⇒ no constraint.** Firmware older than 2.8 never sends `region_presets`. - New clients **must** tolerate the message being absent entirely and keep their existing - (unconstrained) behavior. Do not block the config screen waiting for it. - -3. **`default_preset`** is always a member of that group's `presets`. Use it to pre-select a - preset when the user switches to a region whose valid set does not include the currently - selected preset (instead of leaving an illegal selection or guessing). - -4. **`licensed_only`** marks ham/amateur bands. Surface a warning or gate (the firmware also - requires the operator's `is_licensed` flag for these regions; coordinate the two so the - user isn't allowed to pick a licensed band without acknowledging licensing). - -5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions - (`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a - preset that belongs to a sibling's list, the firmware **swaps the region** rather than - rejecting the preset. To make this visible in the picker, the firmware advertises the - **same superset** (the union of the trio's presets) for all three sibling regions, so a - client filtering per §6 will offer every EU 86x preset regardless of which sibling is - currently selected. Consequence for clients: **do not assume the region is immutable - across a preset change** - after an admin config write, re-read the resulting - `LoRaConfig` and reflect the (possibly changed) region back into the UI. - -6. **Use it as a UI guard, not a validator of truth.** The firmware still validates/clamps - on its own. The map exists to prevent the user from _selecting_ an illegal combo; it is - not a security or correctness boundary. - ---- - -## 6. UI/UX recommendations - -- In the LoRa config screen, when a region is selected, **filter/enable the modem-preset - picker to that region's `presets`** (when `use_preset`/`use_modem_preset` is on). -- If the current preset is not in the newly selected region's set, switch the selection to - that region's `default_preset`. -- Show a **licensed badge / confirmation** for regions where `licensed_only == true`. -- If a region is absent from the map (rule §5.1) or the whole message is absent (§5.2), - render the full preset list as before - never show an empty picker. - ---- - -## 7. Forward / backward compatibility - -- **Old clients, new firmware:** an unknown `FromRadio` oneof variant (field 19) is ignored - by protobuf/nanopb decoders; the relative ordering of the known messages is unchanged, so - existing apps are unaffected. -- **New clients, old firmware:** message simply never arrives → treat as "no constraints" - (§5.2). -- **Enum growth:** new `RegionCode`/`ModemPreset` values may appear over time. Decoders - should pass through unknown enum values rather than crashing; an unknown region in - `region_groups` is harmless (the client just won't have a localized name for it). - ---- - -## 8. Platform notes - -> Verified against the `main` branch of each repo. Both have been refactored away from -> older layouts; re-pin file paths against a specific commit if you need them durable. - -### 8.1 Android - `meshtastic/Meshtastic-Android` (Kotlin / Compose, KMP) - -- **Protobufs are a published Maven artifact, _not_ a submodule.** Declared in - `gradle/libs.versions.toml` (`org.meshtastic:protobufs`, currently `2.7.25`); generated - package is **`org.meshtastic.proto`**. **A `region_presets`-aware build requires a new - published `org.meshtastic:protobufs` release**, then bumping that one version string. -- **The protobufs are Wire-generated**, so the `FromRadio` oneof is **not** a - `payloadVariantCase` enum - each arm is a **nullable field**. Handle the new variant in - `FromRadioPacketHandlerImpl.handleFromRadio(...)` - (`core/data/.../manager/FromRadioPacketHandlerImpl.kt`) by adding a - `regionPresets != null -> …` arm to the existing `when { … }`, delegating to a handler - (mirror `handleLocalMetadata` / `handleConfigComplete`). -- **State holder:** expose the decoded map from `RadioConfigRepository` / - `RadioConfigRepositoryImpl` as a `Flow` (mirroring `localConfigFlow`/`channelSetFlow`), - consumed by `feature/settings/.../radio/RadioConfigViewModel.kt`. -- **UI:** the region & preset dropdowns are `DropDownPreference`s in - `feature/settings/.../radio/component/LoRaConfigItemList.kt` (public composable - `LoRaConfigScreen`). Gate/filter the `ChannelOption` (preset) dropdown by the selected - `RegionInfo`'s entry in the map. - -### 8.2 Apple - `meshtastic/Meshtastic-Apple` (Swift / SwiftUI) - -- **Protobufs are vendored** into a local Swift package `MeshtasticProtobufs` - (`MeshtasticProtobufs/Sources/meshtastic/*.pb.swift`), generated from the `protobufs` git - submodule via `scripts/gen_protos.sh`. **To get field 19:** advance the `protobufs` - submodule, run `scripts/gen_protos.sh`, commit the regenerated `.pb.swift` + submodule - pointer. (No published-artifact dependency - Apple can regenerate from any commit.) -- **Dispatch:** `AccessoryManager.processFromRadio(_:)` - (`Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift`) is a real - `switch decodedInfo.payloadVariant { … }` - add a `.regionPresets` case, with the handler - in `AccessoryManager+FromRadio.swift` (mirror `handleConfig` / `handleMetadata`). -- **Persistence:** config is **SwiftData** (`@Model` entities), upserted via - `MeshPackets`/`UpdateSwiftData.swift`. Store the decoded map (e.g. on a settings/connection - model) so the LoRa view can read it. -- **UI:** `Meshtastic/Views/Settings/Config/LoRaConfig.swift` (`struct LoRaConfig: View`) - has the `Picker("Region", …)` (`RegionCodes.userSelectable`) and `Picker("Presets", …)` - (`ModemPresets.userSelectable`, gated on `usePreset`). Filter the presets picker by the - selected region's entry. Enums live in `Meshtastic/Enums/LoraConfigEnums.swift`. - -### 8.3 Other clients - -- **python (`meshtastic` / Meshtastic-python)** and **web** consume the published protobufs; - they will see `region_presets` once their protobuf dependency includes field 19, and can - ignore it until then (it decodes as an unknown field). - ---- - -## 9. Reference payload (current firmware table) - -For decoder unit tests. With the 2.8 region table, the firmware emits **6 groups**. Group -indices are assigned in region-table order (first region to use a profile creates its group), -so they are stable as listed here: - -| group_index | default_preset | licensed_only | presets | -| ----------------------- | -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO | -| 1 (EU 868) | `LONG_FAST` | false | _EU 86x superset_ (see below) | -| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | _EU 86x superset_ (see below) | -| 3 (EU 868 narrow) | `NARROW_SLOW` | false | _EU 86x superset_ (see below) | -| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW | -| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW | - -The **EU 86x superset** advertised by groups 1, 2 and 3 is the union of the trio's own -band presets, because the firmware auto-swaps region within the trio on preset selection -(§5), so any of these is a legal pick from any of the three regions: - -```text -LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, LITE_FAST, LITE_SLOW, NARROW_FAST, NARROW_SLOW -``` - -The three groups still differ by `default_preset` (`LONG_FAST` / `LITE_FAST` / `NARROW_SLOW`), -which is why they remain distinct groups despite sharing this preset list. - -`region_groups` (region → group_index): - -| group | regions | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 | -| 1 | EU_868 | -| 2 | EU_866 | -| 3 | EU_N_868 | -| 4 | ITU1_2M, ITU2_2M, ITU3_2M | -| 5 | ITU2_125CM | - -> Note that several groups can carry overlapping preset lists but remain distinct: groups 1, -> 2 and 3 share the EU 86x superset yet differ in `default_preset`, and group **5** (ham -> 100 kHz) shares the `NARROW_*` presets with group 3 but differs in `licensed_only`. -> Decoders must key on the group, not on the preset list, to preserve `default_preset` and -> the licensing flag. -> -> Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`, -> `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`. - -This table is generated from the firmware's region table at runtime; treat the firmware as -authoritative and these values as the expected snapshot for the 2.8 table. diff --git a/docs/mesh_beacon_module.md b/docs/mesh_beacon_module.md deleted file mode 100644 index 67a391fe0..000000000 --- a/docs/mesh_beacon_module.md +++ /dev/null @@ -1,454 +0,0 @@ -# Mesh Beacon Module - Function, Settings, and Client Interface Spec - -Status: draft, tracks firmware branch `feat/mesh-beacon`. -Audience: firmware reviewers (Part 1) and client-app developers - Android / Apple / Web / Python (Part 2). - -The Mesh Beacon module lets a node periodically **advertise the existence of a mesh** to -nodes that are not yet on it - broadcasting a short human-readable message plus an optional -"join offer" (a channel, region, and modem preset). It is the mechanism behind invitations -like _"Join us on NarrowSlow"_: a node sitting on one preset/region can shout an invitation -that listeners on other presets/regions can hear and surface to their user. - -The module is deliberately **advisory**. The firmware never auto-joins an advertised -channel or auto-switches preset/region in response to a received beacon - it delivers the -information to the client app and stops there. All "should I act on this?" decisions belong -to the client and, ultimately, the user. - ---- - -## Part 1 - Function and settings choices - -### 1.1 Two roles in one module - -| Role | Class | Active when | What it does | -| --------------- | --------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| **Broadcaster** | `MeshBeaconBroadcastModule` | `FLAG_BROADCAST_ENABLED` set | Periodically transmits `MESH_BEACON_APP` packets on the configured radio settings. | -| **Listener** | `MeshBeaconListenerModule` | `FLAG_LISTEN_ENABLED` set | Receives `MESH_BEACON_APP` packets and caches the offer for the client (the packet itself flows to the client unchanged). | - -The boolean toggles live in a single `flags` bitfield (see [§1.8](#18-settings-reference-moduleconfigmeshbeaconconfig-tag-17)) - broadcasting and -listening can be enabled independently on the same node. The whole module compiles out under the -`MESHTASTIC_EXCLUDE_BEACON` build flag. - -### 1.2 Wire message - -Beacons travel on a dedicated port number: - -```protobuf -MESH_BEACON_APP = 37 // meshtastic/portnums.proto -ENCODING: protobuf (meshtastic.MeshBeacon) -``` - -```protobuf -message MeshBeacon { - string message = 1; // human-readable text, max 100 bytes (buffer 101) - ChannelSettings offer_channel = 2; // optional advertised channel (name + PSK + slot) - Config.LoRaConfig.RegionCode offer_region = 3; // optional advertised region (UNSET = none) - optional Config.LoRaConfig.ModemPreset offer_preset = 4; // optional advertised preset -} -``` - -`.options` size caps (enforced at generation and on send): -`message ≤ 100`, `offer_channel.name ≤ 12`, `offer_channel.psk ≤ 32`. - -The three `offer_*` fields together describe _"there is a reachable mesh on this -region+preset, here is the channel to use."_ Any subset may be present; an empty message with -a populated offer (or vice-versa) is valid. - -### 1.3 Transmission behaviour - -Every outgoing beacon packet is stamped uniformly (`sendBeacon` → `stampPacket`): - -- `to = NODENUM_BROADCAST` -- `from = local node` (see [§1.6](#16-broadcast_send_as_node-currently-disabled) for the disabled spoof path) -- **`hop_limit = 0`** - beacons are **zero-hop**. They are never rebroadcast by the mesh; only - direct RF neighbours hear them. This is the primary spam-control mechanism. (`hop_start` is - normally `0` too, but `FLAG_LEGACY_SPLIT` raises it to `1` for old-firmware compatibility - see - [§1.5](#15-legacy-split-flag_legacy_split).) -- `priority = BACKGROUND`, `want_ack = false`. - -Broadcasting is additionally gated at runtime by: - -- airtime utilisation (`isTxAllowedAirUtil()`), and -- device role - **`CLIENT_HIDDEN` never broadcasts**. - -#### Interval - -`broadcast_interval_secs` controls cadence. The floor is **3600 s (1 hour)** -(`default_mesh_beacon_min_broadcast_interval_secs`); `0` means "use default". Values below the -floor are silently raised, both at config-set time (AdminModule) and at runtime. - -The cadence is **reboot-safe**. Each broadcast's time is persisted to flash via `TransmitHistory` -(keyed by `MESH_BEACON_APP`), and the broadcaster reads it back on boot - so a node that reboots -(or crash-loops) won't re-broadcast until a full interval has elapsed since its last real send, -rather than firing ~30 s after every boot. The timestamp is written **before** the transmit, so a -brown-out during the high-current LoRa TX still counts as "sent." This mirrors `NodeInfoModule` / -`PositionModule`. - -#### Radio switching for TX - -A beacon's whole point is often to reach a mesh on a _different_ preset/region/channel than the -broadcaster currently runs. Before transmitting a beacon tagged with target radio settings, the -module temporarily reconfigures the radio (`reconfigureForBeaconTX`), sends, then restores the -prior config. Per-packet target settings are held in an 8-entry **sidecar table** keyed by packet -ID - chosen so the `MeshPacket` proto carries no extra per-packet radio fields, and normal -(non-beacon) traffic is never touched. - -Two safety guards run before any radio switch (`beaconTxConfigInvalid`): - -1. **An unlicensed node never keys up on a licensed-only (ham) region.** (The reverse - a licensed - node operating in a non-ham region - is allowed. The switch only touches preset/region/channel, - never `owner.is_licensed`.) -2. **The preset must be valid for the target region** (`validateConfigLora`). - - If either fails, the radio is **not** switched and the radio driver **drops** the packet rather - than letting it fall through onto the current config. - -#### Channel encryption on an override channel - -Encryption keys off the **primary** channel slot, and the radio-thread channel switch happens -_after_ encryption. So when a beacon goes out on an override channel (different name/PSK), the -module installs the beacon channel into the primary slot for the synchronous duration of -`send()`, then restores it (`sendBeaconPacket`). This guarantees the packet is encrypted with the -beacon channel's key and stamped with its hash - not the primary's. Meshtastic threading is -cooperative, so there is no preemption between swap and restore. - -### 1.4 Where beacons are sent: single-target and multi-target - -The broadcaster can send to one set of radio settings or to several. **Single- and multi-target -are equal options - neither is preferred and neither is legacy.** Pick whichever matches the -deployment. - -- **Single-target:** the scalar `broadcast_on_preset` / `broadcast_on_region` / - `broadcast_on_channel` fields describe one destination. Used when `broadcast_targets` is empty. -- **Multi-target:** `broadcast_targets` (repeated `BroadcastTarget`) describes several. When - non-empty it takes over from the scalar `broadcast_on_*` fields, and the broadcaster sends **one - beacon copy per entry**. Each `BroadcastTarget` is `{ optional preset, region, optional channel_index }`, - where `channel_index` references a slot in the node's own channel table (the channel must already be - configured locally - its key is needed to encrypt the beacon). Within one cycle, targets that - resolve to the **same** effective preset/region/channel are de-duplicated - only the first is - transmitted - so an accidentally repeated entry costs no extra airtime. - -#### Same-settings vs. other-settings - -Independent of single/multi, each destination can either reuse the node's **own current radio -settings** or specify **different** ones: - -- **Same-settings ("message of the day"):** leave the preset / region / channel unset. They fall - back to the running config, so the beacon goes out on the node's current mesh with **no radio - switch** - a plain periodic broadcast to whoever is already on this preset/region. -- **Other-settings (cross-mesh invite):** set a preset / region / channel that differs from the - running config. The radio is temporarily switched for that copy's TX, then restored (see - [§1.3](#radio-switching-for-tx)). - -Both modes support both styles: a single-target beacon with no `broadcast_on_*` overrides is a -message-of-the-day on the current mesh; a multi-target list can mix one entry on the current -settings with others on different presets/regions. - -### 1.5 Legacy split (`FLAG_LEGACY_SPLIT`) - -This one flag controls **two** independent legacy-compatibility behaviours. Both are about making -beacons usable by firmware that predates this module. - -**(a) Text/offer packet split.** A combined `MESH_BEACON_APP` packet carries both the text and the -offer, but old firmware only decodes `TEXT_MESSAGE_APP` and would never show the text. When -`FLAG_LEGACY_SPLIT` is set **and both text and offer content are present**, the broadcaster -emits **two** packets on the same beacon radio settings instead of one: - -- **Packet A** - `MESH_BEACON_APP` carrying the **offer only** (no text). -- **Packet B** - `TEXT_MESSAGE_APP` carrying the **text only**. - -This is an independent two-packet decision, not an either/or: offer-only and text-only payloads -still go out as a single packet in their respective cases; only the both-present case splits. - -**(b) `hop_start = 1` override.** When `FLAG_LEGACY_SPLIT` is set, **every** beacon packet it sends -(combined, split-A, or split-B; even same-settings ones) is stamped with `hop_start = 1` while -`hop_limit` stays `0`. Pre-2.7.20 firmware drops `hop_start == 0` packets in a pre-decryption check -before it can read the bitfield, so `hop_start = 1` lets those nodes accept the beacon - and it -remains genuinely zero-hop (`hop_limit = 0` still prevents any rebroadcast). - -> **Side effect for clients:** with `hop_start = 1, hop_limit = 0`, receivers compute -> `hops_away = hop_start − hop_limit = 1`, so a legacy-split beacon reads as **1 hop away** even -> though it arrived over direct RF. Without legacy-split it reads as direct (0). Don't treat a -> beacon's `hops_away` as a reliable distance signal. - -### 1.6 `broadcast_send_as_node` (currently disabled) - -The schema reserves `broadcast_send_as_node` (field 3) to send beacons _as_ another node ID. **The -firmware application of this field is currently commented out pending review**, so beacons always -go out as the local node today. The access-control rule is, however, already enforced in -AdminModule and should be treated as canonical: - -> A remote admin may only set `broadcast_send_as_node` to **their own** node ID -> (`mp.from`). Any other value is rejected and reset to the stored value. - -Design note for when it is re-enabled: it is a _node-ID_ spoof only - it rewrites `from` but forges -no signature. Once `from` is not us, the packet is no longer `isFromUs()`, so the router skips -XEdDSA signing and receivers get an unsigned packet attributed to another node. - -### 1.7 Reception behaviour (listener) - -When `FLAG_LISTEN_ENABLED` is **off**, the router drops incoming `MESH_BEACON_APP` packets up front -(`Router::handleReceived`, same pattern as a disabled NeighborInfo module) - so they reach neither -the modules nor the phone. When it is **on**, the packet flows normally and the listener's -`wantPacket` accepts it (`has_mesh_beacon` + `FLAG_LISTEN_ENABLED` + `portnum == MESH_BEACON_APP`). -On a valid beacon (`handleReceivedProtobuf`): - -1. **Offer → cache.** Any offer (`offer_channel` / `offer_region` / `offer_preset`) is stored in - the static `lastReceivedOffer` (sender, channel, region, preset, `received_at`). `received_at` - is `0` if the node has no RTC fix yet - **consumers must not treat `0` as a valid timestamp.** -2. **Never auto-applied.** The firmware does not switch channel/preset/region from a received - offer. Acting on it is the client app's job. -3. The handler returns `CONTINUE` (not `STOP`), so the original `MESH_BEACON_APP` packet **flows to - the client unchanged** through the normal FromRadio path (see Part 2). The client reads the - `message` field directly from that packet - there is no separate copy. - -The firmware deliberately does **not** unwrap a combined beacon's text into a synthesized -`TEXT_MESSAGE_APP`, and does **not** fire `EVENT_RECEIVED_MSG`: a beacon is an advisory broadcast, -not a personal message, so it must not duplicate the text or wake the device from sleep. If a -broadcaster needs non-beacon-aware clients to see the text, it uses `FLAG_LEGACY_SPLIT`, which sends -a real `TEXT_MESSAGE_APP` over RF (see [§1.5](#15-legacy-split-flag_legacy_split)). - -### 1.8 Settings reference (`ModuleConfig.MeshBeaconConfig`, tag 17) - -| # | Field | Type | Meaning / constraints | -| --- | ------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------ | -| 1 | `flags` | uint32 (bitfield) | Bitwise-OR of `Flags` values (listen / broadcast / legacy-split toggles). See enum below. | -| 3 | `broadcast_send_as_node` | uint32 | Send-as node ID. **Application disabled in firmware.** Remote admin may only set to own node ID. | -| 4 | `broadcast_message` | string | Text in each broadcast. **Hard-capped at 100 bytes.** | -| 5 | `broadcast_offer_channel` | ChannelSettings | Channel advertised in `offer_channel`. | -| 6 | `broadcast_offer_region` | RegionCode | Region advertised in `offer_region`. Must be a known region or it is cleared. | -| 7 | `broadcast_offer_preset` | optional ModemPreset | Preset advertised in `offer_preset`. Validated against offer region (else cleared). | -| 8 | `broadcast_on_channel` | ChannelSettings | Channel to transmit on (single-target). Empty name → preset display name. | -| 9 | `broadcast_on_region` | RegionCode | Region to transmit on (single-target). | -| 10 | `broadcast_on_preset` | optional ModemPreset | Preset to transmit on (single-target). Validated against on-region (else this + `on_channel` cleared). | -| 11 | `broadcast_interval_secs` | uint32 | Cadence. **Min 3600**, default 3600; `0` = default. | -| 13 | `broadcast_targets` | repeated BroadcastTarget | Multi-target list; when non-empty overrides the single-target `broadcast_on_*` fields. | - -> The three boolean toggles were folded into the `flags` bitfield; field tags 2 and 12 are now -> unused (the branch is unreleased, so the old tags are left as gaps rather than reserved). - -**`Flags` enum** (nested in `MeshBeaconConfig`; OR the values into `flags`): - -| Bit value | Name | Meaning | -| --------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 0 | `FLAG_NONE` | No options enabled. | -| 1 | `FLAG_LISTEN_ENABLED` | Receive beacons; cache the offer. The packet flows to the client, which reads `message` directly. | -| 2 | `FLAG_BROADCAST_ENABLED` | Periodically broadcast beacons from this node. | -| 4 | `FLAG_LEGACY_SPLIT` | Legacy compatibility: (a) split text+offer into separate `TEXT_MESSAGE_APP` + `MESH_BEACON_APP` packets, and (b) stamp `hop_start = 1` on every beacon so pre-2.7.20 firmware accepts it (see [§1.5](#15-legacy-split-flag_legacy_split)). | - -`BroadcastTarget`: `1 preset` (optional, falls back to running config), `2 region` (`UNSET` = running config), `4 channel_index` (optional `uint32`, index into the node's channel table; if unset, the default channel for the preset is used). Tag `3` is an unused gap - it previously held an embedded `ChannelSettings`, dropped to keep `ModuleConfig` within the BLE `FromRadio` size budget. - ---- - -## Part 2 - Client interface specification - -This section is what a client app needs to integrate with the beacon module. Everything goes -through the **standard admin / ToRadio / FromRadio protocol** - there is no bespoke transport. - -### 2.1 Capability detection - -The module is build-flag optional. Treat it as present when the node's `LocalModuleConfig` -contains a `mesh_beacon` sub-message (`LocalModuleConfig.mesh_beacon`, tag 18). If absent, the -firmware was built with `MESHTASTIC_EXCLUDE_BEACON` - hide the beacon UI. - -### 2.2 Reading and writing configuration - -Standard module-config flow - no new admin messages: - -- **Read:** `AdminMessage.get_module_config_request = ModuleConfig.MeshBeaconConfig` (variant 17). - Reply is `get_module_config_response` with the `mesh_beacon` payload. -- **Write:** `AdminMessage.set_module_config { mesh_beacon = … }`. - -The on/off toggles (listen, broadcast, legacy-split) are bits in the `flags` field, not separate -booleans - read/write them with the `MeshBeaconConfig.Flags` values -(`FLAG_LISTEN_ENABLED = 1`, `FLAG_BROADCAST_ENABLED = 2`, `FLAG_LEGACY_SPLIT = 4`). To toggle one -bit, read the current `flags`, set/clear the bit, and write the whole config back. - -The firmware **sanitises on write** - your value may be silently adjusted. Mirror these rules -client-side so the UI doesn't disagree with the device: - -| Rule | Firmware behaviour | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `broadcast_message` length | Truncated to 100 bytes. | -| `broadcast_interval_secs` | If non-zero and `< 3600`, raised to 3600. | -| `broadcast_on_preset` invalid for `broadcast_on_region` (or current region) | Cleared, **and `broadcast_on_channel` cleared too.** | -| `broadcast_offer_preset` invalid for offer/current region | Cleared. | -| `broadcast_offer_region` not a known region | Cleared to `UNSET`. | -| `broadcast_targets[i].region` not a known region | That entry's region cleared to `UNSET` (TX falls back to running config). | -| `broadcast_targets[i].preset` invalid for that entry's region | That entry's `preset` and `channel_index` cleared. | -| `broadcast_targets[i].channel_index` ≥ `MAX_NUM_CHANNELS` (8) | That entry's `channel_index` cleared (existence is **not** checked - see §2.5). | -| `broadcast_send_as_node` ≠ sender's node ID (remote admin) | Rejected, reset to stored value. | - -Setting beacon config does **not** trigger a reboot (`shouldReboot = false`); changes take effect -on the next broadcast cycle. After a successful write, **re-read** the config to display the -effective (sanitised) values. - -### 2.3 Receiving beacons - -A received beacon reaches the client as a normal `FromRadio.packet` (`MeshPacket`) - the listener -returns `CONTINUE`, so the packet is **not** consumed on-device. The client must: - -1. Subscribe to the FromRadio packet stream as usual. -2. For packets with `decoded.portnum == MESH_BEACON_APP (37)`, decode `decoded.payload` as a - `meshtastic.MeshBeacon`. -3. Read `message`, `offer_channel`, `offer_region`, `offer_preset` (presence-checked). -4. `packet.from` is the **originating beaconer** (the firmware preserves it). - -> **Requires `FLAG_LISTEN_ENABLED` set in `flags`.** With listening disabled the firmware drops -> received `MESH_BEACON_APP` packets in the router - before they reach the phone or any on-device -> handler - the same way it drops a disabled module's packets (e.g. NeighborInfo). The node still -> physically receives the RF, but the client will not see beacons over the FromRadio stream until -> listening is enabled. - -#### Reading the text - no duplication - -For a beacon-aware client the text is **simply the `message` field of the `MESH_BEACON_APP` -packet** you already decode for the offer (step 3 above). One packet, one field - the firmware does -**not** inject a separate `TEXT_MESSAGE_APP` copy, so there is nothing to deduplicate. - -The only time a beacon's text arrives as a separate `TEXT_MESSAGE_APP` is when the broadcaster set -`FLAG_LEGACY_SPLIT`: in that mode the `MESH_BEACON_APP` carries the **offer only** (empty `message`) -and the text is sent as a normal `TEXT_MESSAGE_APP` over RF, so legacy/non-beacon-aware clients can -display it. These two cases are mutually exclusive - a given beacon's text appears exactly once, -either in `MESH_BEACON_APP.message` (combined) or as a `TEXT_MESSAGE_APP` (legacy-split) - so a -client never needs to dedup. Render whichever it receives. - -### 2.4 Acting on an offer (the core client responsibility) - -When a `MESH_BEACON_APP` carries offer content, present it to the user as an **invitation** - -e.g. _"Node ⟨from⟩ invites you to join '⟨offer_channel.name⟩' on ⟨preset⟩/⟨region⟩."_ Then, only on -explicit user confirmation, apply it by writing normal config: - -- `offer_channel` → add/replace a `Channel` (`set_channel`), typically as a secondary channel. -- `offer_region` / `offer_preset` → `set_config { lora = … }` (`use_preset = true`, set - `modem_preset` and `region`). **Note this changes the node's own radio and will drop it off its - current mesh** - make that consequence explicit in the UI. - -**The firmware will never do any of this for the user. No silent auto-apply.** The on-device -`lastReceivedOffer` cache is a firmware-internal convenience and is **not** currently exposed via -an admin message - clients should source offers from the live `MESH_BEACON_APP` packet stream -(§2.3), not expect a "get last offer" RPC. - -#### Offer trust model - read before applying - -- **The advertised PSK is not a secret.** `offer_channel.psk` is a public join token sent in the - clear inside a broadcast; it is a convenience, not a security boundary. An operator who wants a - genuinely private channel must distribute the PSK out-of-band and leave `offer_channel` unset. - Surface offered channels as **public/open** to the user. -- **Validate before applying.** Reject or warn if `offer_preset` is not valid for `offer_region`, - and **never** apply a licensed-only (ham) region for a user who is not a licensed operator - - mirror the firmware's own guard. -- Beacons are **unsigned** when sent as another node (the disabled send-as path), and even normal - beacons assert nothing about the sender's authority. Treat `from` as informational. - -### 2.5 Configuring this node as a broadcaster - -To make a node advertise a mesh, write `MeshBeaconConfig` with `FLAG_BROADCAST_ENABLED` set in -`flags` and at least one of: a non-empty `broadcast_message`, or offer content -(`broadcast_offer_*`). With neither, the broadcaster has nothing to send and stays silent. - -Typical multi-region invite beacon: - -```text -flags = FLAG_BROADCAST_ENABLED | FLAG_LEGACY_SPLIT // broadcast on; split so legacy nodes still see the text -broadcast_message = "Join us on NarrowSlow!" -broadcast_offer_preset = NARROW_SLOW -broadcast_offer_region = EU_N_868 -broadcast_offer_channel = { name: "MyChannel", psk: <32-byte key> } -broadcast_interval_secs = 3600 -// channel_index points at slots in THIS node's channel table - configure those channels first. -broadcast_targets = [ - { preset: LONG_FAST, region: EU_868, channel_index: 0 }, - { preset: NARROW_SLOW, region: EU_N_868, channel_index: 1 }, -] -``` - -The same fields can be baked in at build time via `userPrefs.jsonc` -(`USERPREFS_MESH_BEACON_*`) - see that file for the full list, including -`USERPREFS_MESH_BEACON_TARGET__*` for multi-target entries. - -#### Single-target vs. multi-target - equal options, different channel representation - -Single-target and multi-target are **equal, first-class options**. Neither is preferred, -deprecated, or a "legacy" fallback - pick whichever matches the deployment (a single-target -beacon with no overrides is a plain message-of-the-day; a multi-target list reaches several -preset/region/channel combinations). The broadcaster uses `broadcast_targets` when it is -non-empty and the scalar `broadcast_on_*` fields when it is empty. - -The one **subtle implementation difference** is how each names its TX channel: - -| Path | TX channel is specified by | Channel name/PSK live… | -| ------------- | ------------------------------------------------------- | ----------------------------------------- | -| Single-target | `broadcast_on_channel` - an embedded `ChannelSettings` | …inline in the beacon config | -| Multi-target | `broadcast_targets[i].channel_index` - a `uint32` index | …in the node's channel table (referenced) | - -This asymmetry is deliberate: embedding a full `ChannelSettings` in every one of the (up to -four) targets would push `ModuleConfig` past the BLE `FromRadio` size limit, so a target -references an already-configured channel-table slot instead. `broadcast_offer_channel` (the -advertised join token) is **always** inline regardless of path - it is the advertisement payload -and must carry the actual name/PSK. - -#### Configuring a multi-target broadcaster (two-step) - -Because a target's channel is a reference, configuring a multi-target broadcaster takes **two -admin writes**, in order: - -1. **Create/define each channel in the node's channel table** with the normal channel admin flow - (the same `set_channel` your app already uses for adding channels): - - ```text - AdminMessage.set_channel { index: 1, role: SECONDARY, - settings: { name: "NarrowSlow", psk: , channel_num: 0 } } - ``` - -2. **Write the beacon config**, pointing each target at the slot index from step 1: - - ```text - AdminMessage.set_module_config { mesh_beacon: { - flags = FLAG_BROADCAST_ENABLED - broadcast_targets = [ { preset: NARROW_SLOW, region: EU_N_868, channel_index: 1 } ] - } } - ``` - -Notes: - -- A target may **only** reference a channel that already exists locally - the node needs that - channel's key to encrypt the beacon. A `channel_index` that is out of range, or points at a - blank/unconfigured slot, is not an error: the beacon falls back to the node's **current/primary - channel** (its name, PSK, and slot) on the target preset/region. The channel name only defaults - to the preset's display name (e.g. `LongFast`) when the primary channel itself is unnamed - so - the fallback is "broadcast on my home channel," **not** a freshly-synthesised default-PSK channel - for that preset. -- `channel_index` must be `< MAX_NUM_CHANNELS` (8); the firmware clears it on write otherwise (see - §2.2 sanitise rules). This is the **only** check on write - the firmware does **not** verify that - the referenced slot is actually populated, because you may legitimately write the beacon config - before creating the channel. **Validating that a referenced channel exists is the client app's - responsibility.** A dangling reference doesn't error; it silently falls back to the preset's - default channel - so without a client-side check, the user can believe they're advertising - channel _X_ while the node is really transmitting on the preset default. Before writing, confirm - each `channel_index` maps to a configured `Channel`, and warn the user otherwise. -- **No automatic deduplication of channels.** Neither the beacon config nor the channel table - dedups by content: two `broadcast_targets` may carry the same `channel_index`, or different - indices whose slots hold identical settings, and `set_channel` will happily store two slots with - the same name/PSK. The broadcaster _does_ skip transmitting a target whose effective - preset/region/channel duplicates an earlier one in the same cycle (so a duplicated entry wastes - no airtime), but it does not rewrite or reject your config - keeping the target list free of - redundant entries is up to the client. -- The single-target path needs no separate `set_channel` step - its `broadcast_on_channel` is - written inline in the same beacon-config message. - -### 2.6 Quick reference - -| Concern | Value | -| ---------------------- | ---------------------------------------------------------------------------------------- | -| Port number | `MESH_BEACON_APP = 37` | -| Wire message | `meshtastic.MeshBeacon` | -| Config message | `ModuleConfig.MeshBeaconConfig` (variant tag 17) | -| On/off toggles | `flags` bitfield (`MeshBeaconConfig.Flags`) | -| Local config presence | `LocalModuleConfig.mesh_beacon` (tag 18) | -| Min broadcast interval | 3600 s (1 h) | -| Message max length | 100 bytes | -| Hop behaviour | Zero-hop (`hop_limit = 0`), never rebroadcast; `hop_start = 1` under `FLAG_LEGACY_SPLIT` | -| Auto-apply offers? | **Never** - client + user decide | -| Offer PSK | Public join token, not a secret | -| Disabled today | `broadcast_send_as_node` application | diff --git a/docs/nexthop-routing-reliability.md b/docs/nexthop-routing-reliability.md deleted file mode 100644 index 42a08d077..000000000 --- a/docs/nexthop-routing-reliability.md +++ /dev/null @@ -1,456 +0,0 @@ -# NextHop direct-message reliability on dense meshes - findings & plan - -**Status:** Implemented - mitigations and tests in `PR3-tmm-nexthop` -**Date:** 2026-06-13 -**Area:** `src/mesh` router stack (`NextHopRouter`, `ReliableRouter`, `FloodingRouter`, `Router`, `NodeDB`, `PacketHistory`) -**Constraint:** No over-the-air / wire-format changes - `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only. - -This document captures the analysis and the proposed mitigations so the work can be -continued on this branch by anyone. It is intentionally code-grounded (file:line -references throughout) and standalone - you should not need the original investigation -context to pick it up. - ---- - -## TL;DR - -NextHop routing for direct messages (DMs) is unreliable on dense meshes. The headline -cause is the **birthday problem**: `next_hop` and `relay_node` are each a single byte -(the last byte of a 32-bit node number), so on a mesh of N nodes the probability that -two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50-100. But -there are **other, equally important issues**: that single byte is trusted blindly at -five different code sites, learned routes **never decay**, routes are learned from the -**reverse (ACK) path** (asymmetric-link hazard), and collision-driven spurious -rebroadcasts **amplify congestion** exactly when the mesh is busy. - -Because we can't widen the on-wire field, the fix is **interpretation-side** ("don't -trust a byte that doesn't map to a unique reachable neighbor - flood instead") plus -**recovery-side** ("decay stale/failing routes so they get re-discovered"). Four -mitigations, M1-M4, all RAM-only. The net behavioral change: on dense/mobile meshes a -DM that today silently misroutes or black-holes instead falls back to managed flooding -(which still delivers) and re-learns a fresh route quickly. Sparse-mesh happy paths are -unchanged. - ---- - -## How NextHop routing works today (mechanics) - -Inheritance chain: `Router` → `FloodingRouter` → `NextHopRouter` → `ReliableRouter`. - -**The single-byte identifiers.** Both routing bytes come from one helper: - -```cpp -// src/mesh/NodeDB.h:255 -uint8_t getLastByteOfNodeNum(NodeNum num) { return (uint8_t)((num & 0xFF) ? (num & 0xFF) : 0xFF); } -``` - -It projects a 32-bit node number onto 255 values (`0x00` is remapped to `0xFF` so it -never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELAY_NODE`, -`src/mesh/MeshTypes.h:44-46`). `next_hop` and `relay_node` in the packet header are -`uint8_t` (`src/mesh/mesh.pb.h`, comments "Last byte of the node number…"). The learned -route stored per destination, `meshtastic_NodeInfoLite::next_hop`, is also a single byte -(`src/mesh/generated/meshtastic/deviceonly.pb.h:83`). - -**Sending a DM** - `NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`): - -1. `p->relay_node = getLastByteOfNodeNum(getNodeNum())` (mark ourselves as relayer). -2. `p->next_hop = getNextHop(p->to, p->relay_node)` (`src/mesh/NextHopRouter.cpp:192`): - look up `nodeDB->getMeshNode(to)->next_hop`; return it unless it equals the relayer - byte; otherwise `NO_NEXT_HOP_PREFERENCE` (→ flood). - -**Relaying** - `NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`): -rebroadcast iff `next_hop == NO_NEXT_HOP_PREFERENCE` (flood) **or** -`next_hop == getLastByteOfNodeNum(getNodeNum())` (we are the addressed next hop) -(`:147`). Each node only ever compares against **its own** byte. - -**Learning** - `NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an -ACK/reply (`request_id`/`reply_id` set), if the relayer of the ACK was also a relayer of -the original packet (validated via `PacketHistory::checkRelayers`), set -`origTx->next_hop = p->relay_node` (`:114`). I.e. the **forward** next-hop is learned -from the **reverse** path's relayer. - -**Retransmission / fallback** - `NextHopRouter::doRetransmissions` -(`src/mesh/NextHopRouter.cpp:284`). Budgets: `NUM_RELIABLE_RETX=3` (originator: initial - -- 2 retries), `NUM_INTERMEDIATE_RETX=2` (relayer: 1 retry). On the **last** retry - (`numRetransmissions==1`) it resets `next_hop` to `NO_NEXT_HOP_PREFERENCE` on the packet - **and** clears `sentTo->next_hop` in NodeDB, then floods (`:313-321`). Retransmit timing - comes from `iface->getRetransmissionMsec`, whose contention window **grows with channel - utilization** (`src/mesh/RadioInterface.cpp` `getTxDelayMsec`/`getTxDelayMsecWeighted`). - -**Dedup / relayer history** - `PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded -ring (`PACKETHISTORY_MAX = max(MAX_NUM_NODES*2, 100)`, 20 B/record) keyed by -`(sender,id)`, tracking up to `NUM_RELAYERS=6` relayer **bytes** per packet in -`relayed_by[]`. `wasRelayer` (`:490`) and `checkRelayers` (`:517`) match bytes against -that array. - ---- - -## Root-cause analysis - -### 1. The single byte is trusted blindly at five sites (the birthday problem) - -| # | Site | File:line | Failure on collision | -| --- | -------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| 1 | Rebroadcast self-check | `NextHopRouter.cpp:147` | A remote "impostor" node sharing the intended next-hop's byte also rebroadcasts → wasted airtime / congestion. | -| 2 | Route learning | `NextHopRouter.cpp:111-114` | Stores an ambiguous byte as the route; later resolves to the wrong physical node. | -| 3 | Relayer validation | `PacketHistory.cpp:490-538` | `wasRelayer(byte)` returns true for the wrong node → mis-validated ACK / mis-learn. | -| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins - non-deterministic; can preserve hops for the wrong relay (hop leak). | -| 5 | Send-path lookup | `NextHopRouter.cpp:192-207` | Emits a byte that may address the wrong node; no check it still maps to a reachable neighbor. | - -Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19 nodes, - -> 99% by ~75 nodes. Dense meshes are squarely in the "always colliding" regime. - -### 2. Stale routes never decay - -The learned `next_hop` byte is cleared only on the **current DM's** last retry -(`NextHopRouter.cpp:313-321`). A route learned hours ago that has since gone dead is -still trusted on the **next** DM's first attempt - which on a congested mesh is also the -slowest attempt. Result: silent black-hole at a dead hop until the retransmission budget -drains, then a late flood. Intermediate nodes hold stale routes indefinitely. - -### 3. Reverse-path (asymmetric-link) learning - -`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) - the -**reverse** direction. RF links are frequently asymmetric, so the best reverse relay can -be a poor forward relay. Worse, the next reverse ACK immediately re-learns the same bad -hop, so the route **flaps** back to the bad value even after a failure reset. - -### 4. Congestion amplification - -Collision-driven impostor rebroadcasts (issue 1) add airtime; the contention window -grows with channel utilization, so retransmit intervals **lengthen** exactly when the -mesh is busy. The 3-try reliable budget can then expire before delivery. On dense -meshes, efficiency _is_ reliability. - -### Note: pubkey-derived node numbers (develop / 2.8) - does not change the plan - -develop derives the node number from the public key: -`my_node_num = crc32Buffer(public_key)` (`src/mesh/NodeDB.cpp:481`), re-derived on key -change in `createNewIdentity()` (`src/mesh/NodeDB.cpp:3113`). This **reinforces** the -plan rather than changing it: - -- **Birthday problem unchanged and now textbook-exact.** CRC32 mixes well → the last - byte is uniformly distributed over 256 values. Derivation adds no wire bits. -- **Node numbers are now immutable / identity-bound.** Pre-2.8 `pickNewNodeNum()` could - renumber a node to dodge a conflict; now the number is fixed by the key, so a last-byte - collision **cannot be resolved operationally by renumbering** → M1/M2/M3 become _more_ - necessary. -- **Resolver gets cleaner inputs.** Stable node numbers keep a learned byte bound to one - identity (good for M3 freshness). `createNewIdentity()` retires the old entry by marking - it **ignored** and clearing its pubkey (`src/mesh/NodeDB.cpp:3123-3125`), which M1's - candidate gate already skips - so key rotation can't pollute resolution. -- **No wire-free disambiguation unlocked.** A receiver still gets only 1 byte and cannot - recover which full node number a colliding value meant - so "detect ambiguity → flood" - remains the correct strategy. - ---- - -## Proposed mitigations - -Key insight for all of M1/M2: **a 1-byte ID only needs to be unique among a node's -direct neighbors / plausible relays, not the whole mesh.** That candidate set is small -(typically 5-15), so a byte usually resolves unambiguously there; when it doesn't, fall -back to the _safe_ behavior (flood / decrement / don't-learn). - -### M1 - Ambiguity-aware last-byte resolution (new NodeDB primitive) - -New types + methods in `src/mesh/NodeDB.h` (near line 255) / `src/mesh/NodeDB.cpp` -(near `getMeshNode`, ~2936): - -```cpp -enum class LastByteResolution : uint8_t { None, Unique, Ambiguous }; -struct ResolvedNode { LastByteResolution status = LastByteResolution::None; NodeNum num = 0; }; - -// Resolve a single on-wire last-byte to a unique full NodeNum among relevant candidates. -ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor); -// Convenience: true iff exactly one relevant candidate (Ambiguous and None both -> false = SAFE). -bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr); -``` - -- **One linear pass** over `meshNodes`, reusing `getNumMeshNodes()`/`getMeshNodeByIndex()`, - the bitfield helpers (`nodeInfoLiteIsFavorite/HasUser/IsIgnored`), `sinceLastSeen()`, - and `getLastByteOfNodeNum()`. **Early-exit** on the 2nd match (return `Ambiguous`). -- **Guard:** `if (lastByte == 0) return {None, 0};` (covers `NO_RELAY_NODE` / MQTT-invalid). -- **Candidate gate** (skip): `num == getNodeNum()` (never resolve to ourselves), `num == 0`, - `num == NODENUM_BROADCAST`, `nodeInfoLiteIsIgnored`. Then match - `getLastByteOfNodeNum(node->num) == lastByte` (cheapest test last, mirroring `Router.cpp:119`). -- **Relevance gate:** - - `requireDirectNeighbor == true` (strict, for SEND): `has_hops_away && hops_away == 0` - **and** `sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS`. - - `requireDirectNeighbor == false` (lenient, for learn / hop-preserve): accept if direct - neighbor **or** `nodeInfoLiteIsFavorite` **or** role ∈ {ROUTER, ROUTER_LATE, CLIENT_BASE}. -- **No tie-break.** A collision must return `Ambiguous` - picking "best SNR" would - resurrect the silent-misroute bug. (Deliberate non-goal; document in code.) - -New constant in `src/mesh/MeshTypes.h` (near line 44): -`#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2)` (mirrors `NUM_ONLINE_SECS`). - -### M2 - Only route on bytes that resolve to a unique, reachable neighbor - -In `getNextHop` (`src/mesh/NextHopRouter.cpp:192-207`), after the existing split-horizon -check (`node->next_hop != relay_node`), require the stored byte to resolve to a **unique, -currently-fresh direct neighbor**; else flood: - -```cpp -if (node->next_hop != relay_node) { - ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true); - if (r.status == LastByteResolution::Unique) return node->next_hop; - LOG_WARN("Next hop 0x%x for 0x%x %s -> flood", node->next_hop, to, - r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "no longer a neighbor"); - return std::nullopt; -} -``` - -This self-heals when a neighbor goes away (unicast-into-a-void becomes a flood). It -applies to originating, relaying, and retrying, since all route through `getNextHop`. - -Apply M1's safe fallback at the other sites: - -- **Learning** (`NextHopRouter.cpp:111-114`): gate `origTx->next_hop = p->relay_node` on - `resolveUniqueLastByte(p->relay_node, /*direct=*/false)`. Ambiguous/unknown → don't - learn (leave route unset → flood). -- **Favorite-router preservation** (`Router.cpp:120-145`): replace the "first match wins" - loop with `resolveUniqueLastByte(p->relay_node, /*direct=*/false)` + a re-check that the - resolved node is favorite/has_user/router. Ambiguous/none/not-favorite → **decrement** - (safe). Net: removes one full DB scan, adds one resolver scan (wash). - -**Left unchanged, by design (document why in code):** - -- **Site 1** rebroadcast self-check (`NextHopRouter.cpp:147`) and self-identity checks - (`ReliableRouter.cpp:127`): a node matches its **own** byte - no DB resolution helps. A - remote impostor sharing the intended next-hop's byte will still rebroadcast. M1/M2 - shrink the blast radius by reducing how often an ambiguous byte is ever stored or - originated; a true fix needs a wider field (out of scope). **This is the one residual - the plan cannot fully close.** -- **Site 3** `wasRelayer`/`checkRelayers` (`PacketHistory.cpp:490-538`): intentionally - byte-domain (both sides are on-wire bytes); the consumer (learning) is now hardened. - Add a one-line comment; do not change. - -### M3 - Route freshness / failure memory (RAM table on NextHopRouter) - -A bounded, LRU-evicted table keyed by destination, mirroring `PacketHistory`'s -reuse-oldest discipline (not an unbounded map) to cap RAM. - -`src/mesh/NextHopRouter.h` (near `pending`, line 99): - -```cpp -struct RouteHealth { - NodeNum dest = 0; // 0 == empty slot - uint32_t learnedAtMsec = 0; // millis() at last (re)learn; rollover-aware - uint8_t consecutiveFailures = 0; - uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; // byte this health refers to -}; -static constexpr uint8_t ROUTE_HEALTH_MAX = 32; // ~384B; drop to 16 if RAM-tight -RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {}; -// Helpers take `now` (pure/testable): findRouteHealth, getOrAllocRouteHealth, -// noteRouteLearned, noteRouteSuccess, noteRouteFailure, isRouteStale, clearRouteHealth -``` - -Policy: - -| Constant | Value | Rationale | -| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `ROUTE_TTL_MSEC` | 30 min | Survives a normal conversation; re-discovers a moved node within a telemetry interval. | -| `ROUTE_FAILURE_THRESHOLD` | 3 | 1-2 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). | - -`isRouteStale(h, now)` = `(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC || h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD`. -All age math uses **unsigned subtraction** (rollover-safe, matching -`PacketHistory.cpp:364`); treat `learnedAtMsec == 0` as "set now". - -Wiring (as built - `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`): - -- `getNextHop`: if a health record matches the stored byte and `isRouteStale`, clear - `node->next_hop` (NodeDB) **and** `clearRouteHealth`, return `nullopt` (flood). No - record yet (cold path, first DM after boot) → trust NodeDB, but the M2 strict-neighbor - gate still applies. -- `sniffReceived` learn: gate the write through `resolveUniqueLastByte` (M2), then - `noteRouteLearned(p->from, p->relay_node, millis())` - resets `consecutiveFailures` - **only if the hop changed** (anti-flap for asymmetric re-learn); otherwise just refreshes - `learnedAtMsec`. (No success signal is taken on the intermediate reverse-pass: an ACK - merely passing through us is not proof that _we_ delivered, and resetting failures there - would reintroduce the asymmetric flap.) -- `doRetransmissions`: on the last-retransmission branch (`numRetransmissions == 1`, the - point a directed delivery has gone un-ACKed for both originator and intermediate) → - `noteRouteFailure(to)`, then the existing NodeDB `next_hop` reset + flood. We deliberately - do **not** `clearRouteHealth` here: keeping the record is what lets the failure count - accumulate across DMs so a flapping reverse-path-relearned dead hop eventually ages out. -- `ReliableRouter::sniffReceived` ACK path → `noteRouteSuccess(getFrom(p), millis())` - (an end-to-end ACK addressed to us is genuine forward-delivery proof; clears failures and - refreshes freshness). `noteRouteSuccess`/`noteRouteFailure` are no-ops when no record - exists, so flood-only destinations never pollute the table. - -**Reconciliation (no double-handling):** `doRetransmissions` owns _in-flight_ failure of -the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure counter); -`getNextHop` owns _between-DM_ staleness (TTL or failure-threshold → flood + clear). The -only place that erases a health record is the `getNextHop` decay path; the retransmission -path leaves it intact so the counter survives a reverse-path re-learn. - -### M4 - Earlier flood for unverified routes (gated, off by default) - -Compile-gated so healthy sparse meshes are untouched. **Default is off** - the define -lives in `NextHopRouter.h` and must be flipped to measure: -`#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 1`. - -In `doRetransmissions`, the directed-retry `else` branch: if the route is **not verified** -(`!findRouteHealth(to) || consecutiveFailures > 0 || isRouteStale`), reset `next_hop` and -flood on this attempt instead of spending another directed try. A **verified** route -(record present, `consecutiveFailures == 0`, within TTL - i.e. recently ACKed) takes the -unchanged directed-retry path, so the sparse-mesh happy path is untouched. Trade-off: -airtime ↔ latency; the gate ensures we never pay the flood cost on a proven route, only on -one we already distrust. Off by default precisely so it can be A/B-measured on the -simulator before broad enable. - ---- - -## Files to modify - -| File | Change | -| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/mesh/MeshTypes.h` | `NEXTHOP_NEIGHBOR_FRESH_SECS`, `ROUTE_TTL_MSEC`, `ROUTE_FAILURE_THRESHOLD`, `NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED` | -| `src/mesh/NodeDB.h` / `src/mesh/NodeDB.cpp` | `LastByteResolution`, `ResolvedNode`, `resolveLastByte`, `resolveUniqueLastByte` | -| `src/mesh/NextHopRouter.h` | `RouteHealth` + array + helpers; `#ifdef PIO_UNIT_TESTING public:` for helpers and `getNextHop` | -| `src/mesh/NextHopRouter.cpp` | `getNextHop` (M2 gate + M3 decay); `sniffReceived` (learn gate + health seed + success); `doRetransmissions` (failure counting + M4); comment site 1 | -| `src/mesh/Router.cpp` | `shouldDecrementHopLimit` → resolver + favorite/router re-check | -| `src/mesh/ReliableRouter.cpp` | ACK path → `noteRouteSuccess` | -| `test/test_nexthop_routing/test_main.cpp` | **new** unit suite (auto-built under `[env:native]`) | - -**Reuse, don't reinvent:** `getLastByteOfNodeNum`, `sinceLastSeen`, the bitfield helpers, -`getMeshNodeByIndex`/`getNumMeshNodes`, PacketHistory's reuse-oldest eviction shape, and -`MockNodeDB::addTestNode` (from `test/test_hop_scaling/test_main.cpp`). - ---- - -## Edge cases - -- **`0x00`↔`0xFF` projection:** the resolver compares via `getLastByteOfNodeNum` on both - sides, so a `…00` node and a `…FF` node correctly collide on `0xFF` → `Ambiguous`. Test - explicitly. -- **MQTT packets:** `relay_node`/`next_hop` are forced invalid when `hop_start == 0` - (`src/mesh/RadioLibInterface.cpp:603-605`) → byte 0 → resolver `None` → don't learn - (correct). -- **`has_hops_away == false`** nodes are excluded from the strict gate (never fabricate a - Unique neighbor for M2); admitted to the lenient gate only via favorite/router role. - Safe; self-corrects once `hops_away` is learned. -- **Self / broadcast:** the resolver skips `getNodeNum()` and `NODENUM_BROADCAST`; - `getNextHop` already early-returns for broadcast. -- **Perf:** M2 adds one O(N) resolver scan per directed send/relay (early-exit on the 2nd - match), cheaper than the crypto already on that path; site-4 is a wash. If ever hot, a - future 256-entry last-byte index is the optimization (not now - RAM). - ---- - -## Verification (all tiers) - -### 1. Native unit tests - new `test/test_nexthop_routing/test_main.cpp` - -`pio test -e native -f test_nexthop_routing`; on macOS `./bin/test-native-docker.sh -f test_nexthop_routing`. -Design the RouteHealth helpers to take `now` as a parameter so the 30-min TTL logic is -testable without a clock mock. - -- **Resolver:** None / Unique / **Ambiguous (birthday collision)** / strict-excludes-stale / - strict-excludes-far / lenient-includes-favorite-router / lenient-collision / skips-self / - skips-ignored / **`0x00`↔`0xFF` collision** / early-exit. -- **`getNextHop`:** unique→byte, **ambiguous→nullopt**, stale-neighbor→nullopt, - split-horizon (relay==next_hop)→nullopt, broadcast→nullopt. -- **RouteHealth:** TTL boundary, **rollover** (learn near `0xFFFFFFFF`, check after wrap), - failure threshold, success-resets, **re-learn-same-hop keeps fails (anti-flap)**, - re-learn-new-hop resets, LRU eviction bound, clear. -- **Site-4:** preserve on unique favorite router; **decrement on two colliding favorites**; - decrement when the resolved node is not a favorite. -- **Sparse-mesh regression:** all-distinct last bytes → every resolve Unique, `getNextHop` - returns the stored byte unchanged (proves no happy-path change). -- Re-run `test_packet_history` and `test_hop_scaling` for no regression. - -### 2. portduino SimRadio simulator - -`pio run -e native && ./bin/test-simulator.sh`. Best vehicle for the **intermediate-node** -path the 2-device bench can't reach. Line topology A - B - C: establish A→C (B learns a -directed route), stop B relaying that dest, confirm A re-discovers via flood within -`ROUTE_FAILURE_THRESHOLD` and that B's `noteRouteFailure`/`clearRouteHealth` fires (visible -via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this to A/B M4 -(attempts-to-delivery, total airtime). - -### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop) - -- `meshtastic-mcp/tests/mesh/test_nexthop_multihop_recovery.py` - **the multi-hop validator - for this work** (added on this branch). Self-discovers an A - relay - C line, asserts a - directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts - delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true - multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range). -- `meshtastic-mcp/tests/mesh/test_direct_with_ack.py` - happy-path regression: a fresh/unique - route still delivers a want_ack DM on the first/second try (M4's gate must keep this - green). -- `meshtastic-mcp/tests/mesh/test_peer_offline_recovery.py` - 2-device recovery validator: peer - off mid-conversation then back. Must stay green and ideally recover in fewer attempts. - -### 4. Build / format sanity - -native-macos **and** Docker both ways; trunk clang-format@16.0.3; a release `pio run` to -confirm the `#ifdef PIO_UNIT_TESTING` visibility widening does **not** leak into -production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B table. - ---- - -## Verification status (as built on `nexthop-redux`) - -| Tier | What ran | Result | -| -------------------------------- | ----------------------------------------------------------------------------------- | ------------------- | -| Unit (native-macos) | `test_nexthop_routing` (31 cases) | ✅ 31/31 | -| Unit (Docker / Linux, CI parity) | `test_nexthop_routing` | ✅ 31/31 | -| Regression | `test_packet_history`, `test_hop_scaling`, `test_mqtt`, `test_traffic_management` | ✅ 105/105 | -| Build | `pio run -e native-macos` (M4 off) and with `-DNEXTHOP_EARLY_FLOOD_ON_UNVERIFIED=1` | ✅ both link | -| Format | trunk `clang-format@16.0.3` | ✅ no issues | -| Simulator (CI `simulator-tests`) | `meshtasticd -s` + `meshtastic.test.testSimulator()` on native-macos | ✅ exit 0, no crash | - -**Pending (environment-blocked, not yet run):** - -- **Multi-hop A-B-C recovery sim** - the `simulator/` broker hub is **not git-tracked** - (only stale local `.pyc`), and two `meshtasticd -s` instances can't hear each other - without it. The intermediate-node failure-count path and the M4 A/B therefore have unit - coverage of their logic but no end-to-end multi-node run yet. -- **Hardware / multi-hop tier** - a committable bench test now exists: - `meshtastic-mcp/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real - multi-hop pair (A - relay - C), asserts a directed DM is delivered across the relay, and - asserts delivery recovers after the relay is power-cycled (the M3 path). It - `pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF - range (≥3 roles via `--hub-profile`), so it's safe to commit and only asserts when the - NextHop path is genuinely exercised. Collected + verified to skip without hardware; - not yet run on a bench. `test_direct_with_ack.py` / `test_peer_offline_recovery.py` - remain the 2-device happy-path/recovery regressions. - ---- - -## Risks & limitations - -- **Site-1 impostor rebroadcast** is unfixable without a wider field - documented; M1/M2 - only shrink its frequency. -- **Dense meshes flood DMs more often** - intended (a flooded DM arrives; a mis-unicast one - black-holes). Call out in the PR so reviewers expect a slightly higher DM flood rate on - very dense meshes. -- **M4 airtime** if the gate is too loose → default conservative + compile-gated + - simulator A/B before broad enable. -- **RAM** ~384 B (32 slots); 16 slots (~192 B) with graceful LRU degradation if tight. -- **Asymmetric flap** not fully closed (a _new_ bad hop resets the counter); the TTL - backstop bounds it. Per-hop failure history is future work (more RAM). - ---- - -## How to continue this work (commit sequencing) - -Each step is independently testable; land them as separate commits. - -1. **M1 resolver + unit tests** - `NodeDB` only; no behavior change until wired. Lands the - `resolveLastByte`/`resolveUniqueLastByte` primitive and its full unit-test matrix. -2. **M2 + wiring + tests** - `getNextHop` strict gate, learning gate, favorite-router - preservation rewrite. Adds the `getNextHop` and site-4 tests. -3. **M3 health table + decay + tests** - RAM `RouteHealth` table, decay-on-read, failure/ - success accounting, reconciliation with the existing last-retry reset. Adds the - route-health unit tests and the simulator recovery check. -4. **M4 gated tuning** - early-flood-on-unverified behind the compile flag; simulator A/B - and hardware regression. - -Reference plan (with the same content) was developed at -`~/.claude/plans/nexthop-routing-for-direct-lexical-shell.md` on the author's machine; this -in-repo doc is the canonical handoff copy. diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md deleted file mode 100644 index 7908f9f90..000000000 --- a/docs/node_info_stores.md +++ /dev/null @@ -1,321 +0,0 @@ -# NodeInfo stores: the base and extended databases - -This document is an overview of the node-identity and traffic-state databases that the -TrafficManagementModule (TMM) either owns or leans on. There are four stores in play, but -only three form the identity lookup chain: - -1. **NodeDB hot store** - the authoritative `NodeInfoLite` array (identity tier 1). -2. **Warm tier** (`WarmNodeStore`) - minimal persisted records for hot-store evictees - (identity tier 2). -3. **TMM NodeInfo payload cache** (extended) - the ephemeral **third identity tier**: full - `User` payloads plus direct-response metadata; PSRAM-backed on hardware, plain heap in - native tests. - -The fourth store, the **TMM unified cache** (base - flat 10-byte-per-node traffic-shaping -state), is not part of that chain: it sits beside it, keyed by the same NodeNum, and only -its 4-bit cached role acts as a final fallback when all three identity tiers miss. - -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) - -- **What:** the classic `meshNodes` array of `meshtastic_NodeInfoLite` - full identity as - 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. -- **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 XEdDSA-signed bit. -- **Persistence:** the node database file in LittleFS, saved on the usual NodeDB cadence. -- **Authority:** key pinning (`updateUser`'s "Public Key mismatch" drop), signer - provenance, and identity content all originate here. The lookup helpers that other - stores mirror: - - `copyPublicKeyAuthoritative(n, out)` - hot store, then warm tier. The pin reference - for caches; never consults opportunistic caches. - - `copyPublicKey(n, out)` - the above, then **TMM's NodeInfo cache as last resort** - (extends the encrypt-to pool for nodes both tiers have forgotten). - - `isVerifiedSignerForKey(n, key32)` - key-matched signer verdict across hot + warm. - - `isKnownXeddsaSigner(n)` - key-agnostic "should this node's signable traffic arrive - signed", across hot + warm. Gates that check only the hot store would let a - 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`: - -| ESP32-S3 | Native (portduino) | nRF52840, generic ESP32 | STM32WL | -| --------------- | ------------------ | ----------------------- | ------- | -| 250 / 200 / 100 | 200, configurable | 120 | 10 | - -This one is flash-shaped rather than heap-shaped, so it is unclassed: `nodes.proto` has to fit the -filesystem. The fixed-cap platforms get their value from `mesh-pb-constants.h`; the 120 covers -nRF52840 plus generic ESP32 including C3, and is what keeps `nodes.proto` inside the stock 28 KB -LittleFS. - -**Two platforms do not take their cap from that header, and neither is a compile-time constant:** - -- **ESP32-S3** picks a tier at boot from the flash chip size (>=15 MB / >=7 MB / smaller). -- **Native/portduino** resolves it from _runtime_ config: - `variants/native/portduino{,-buildroot}/variant.h` define `MAX_NUM_NODES portduino_config.MaxNodes`, - default **200** (`PortduinoGlue.h`), overridable per-host with `General: MaxNodes` in the YAML. - Because `variant.h` is reached first, the `ARCH_PORTDUINO` branch of `mesh-pb-constants.h` never - fires - it is `#error`-guarded so it can no longer be misread as the native cap. - -Do not grep `mesh-pb-constants.h` for the native number: the protected-node cap derives from -`MAX_NUM_NODES` (`numProtectedNodes() < MAX_NUM_NODES - 2`), so a wrong reading gives a wrong cap -(248 instead of 198) and makes a genuinely saturated database look impossible. - -The separate `250` in `NodeDB::getMaxNodesAllocatedSize()` is `NODEDB_MIGRATION_LOAD_CEILING`, a -decode allowance for files written by larger-cap firmware. It is not a cap on this build. - -## 2. Warm tier - `WarmNodeStore` (NodeDB-owned) - -- **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal - record survives so DMs keep encrypting: the key is expensive to re-learn; everything - else rebuilds from traffic in seconds. -- **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, XEdDSA-signed 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 - (append/replay/compact); everywhere else `/prefs/warm.dat` (LittleFS). -- **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/XEdDSA-signed 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 - per-node state behind position dedup, rate limiting, unknown-packet filtering, plus two - piggybacked caches: - - `next_hop` - last-byte relay hint, written only from ACK-confirmed NextHopRouter - decisions (no TTL; keeps the slot alive across sweeps). - - a **4-bit device role** (split across the top bits of two count bytes) - the _third_ - fallback for role-aware policy after the hot store and warm tier, surviving even total - NodeDB eviction. Read through `resolveSenderRole()`, refreshed by - `updateCachedRoleFromNodeInfo()` on observed NodeInfo. -- **Entry layout:** - `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. -- **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 - PSRAM (or heap) 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 - Availability) - the full cached `User` payload (names, role, key) plus the metadata that - backs TMM's **spoofed direct NodeInfo replies** on a target's behalf, independent of - NodeDB (the serve/throttle behaviour is documented in - [traffic_management_module.md](traffic_management_module.md)). Also the last-resort key - source for `NodeDB::copyPublicKey()`. -- **Availability:** `TMM_HAS_NODEINFO_CACHE` - ESP32 with PSRAM (production home; 2000 - entries is too large for MCU internal RAM), plus native unit-test builds on the plain - heap so the trust/retention paths run in CI. -- **Entry:** `node`, `user` (full nanopb `User`), the `obsTick` recency stamp (3 min/tick), - `sourceChannel`, `decodedBitfield`, and packed 1-bit flags: `hasDecodedBitfield`, - `keyXeddsaSigned`, `keyManuallyVerified`, `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.) -- **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 - `copyPublicKeyAuthoritative()` (hot then warm - the same coverage as `updateUser`'s own - pin), and, failing NodeDB knowledge, against the cache's **own previously cached key** - (TOFU pin). Mismatches are dropped, never overwritten. A frame advertising _our own_ key - is dropped outright (impersonation). -- **Key provenance (`keyXeddsaSigned` + `keyManuallyVerified`, combined via `keyProven()`):** - `keyXeddsaSigned` is set when a frame's XEdDSA signature was router-verified - (`mp.xeddsa_signed`) or when NodeDB already knew the node as a signer **for the same key** - (`isVerifiedSignerForKey`). `keyManuallyVerified` is set when the user confirmed possession - out-of-band (QR / fingerprint), routed via `onNodeKeyCommitted(proven)` and re-seeded from the - hot store's `is_key_manually_verified` bit at reconcile. Either bit makes `keyProven()` true - - the predicate the replay gate, eviction tiering, and pubkey-pool callers use. Both are monotonic - per slot; a changed key resets both. -- **Unsigned-identity gate:** a NodeInfo arriving _unsigned_ from a node we have ever - verified as a signer - per `NodeDB::isKnownXeddsaSigner()`, which covers hot **and - warm** tiers - drives no cache, role, or `updateUser()` write. (Warm coverage matters: a - signer evicted to the warm tier would otherwise be forgeable with its own public key - until re-heard. The same rule guards `Router::checkXeddsaReceivePolicy`'s - unsigned-broadcast drop.) -- **Serve gate honesty:** only a genuinely _heard_ NODEINFO frame stamps - `obsTick`/`hasObserved` - seeding and write-through don't, so a silent node never looks alive - to the replay path. The sweep clears `hasObserved` to enforce the 6 h serve window. The - spoofed-reply throttle this gate feeds lives in the module (see - [traffic_management_module.md](traffic_management_module.md)). - -### Consistency with NodeDB (anti-entropy) - -Four mechanisms keep this tier a superset of NodeDB's identities. All **merge rather than -overwrite**, so a keyless commit never costs the cache a learned TOFU key. - -| Mechanism | When | Role | -| --------------------------------------------------------------------- | --------------------------- | -------------------------------- | -| Write-through hooks (`onNodeIdentityCommitted`, `onNodeKeyCommitted`) | every identity/key commit | immediate upsert | -| Reconcile sweep (`reconcileNodeInfoFromNodeDBLocked`) | boot seed, then hourly | re-seed from hot + warm tiers | -| Membership refresh | inside the hourly reconcile | re-mark which nodes NodeDB holds | -| Purge hooks (`purgeNode`, `purgeAll`) | node removal / reset | drop the node from both caches | - -Two details that bite: the reconcile sweep transfers signer verdicts only when **key-matched**; -and membership refresh clears-then-re-marks from both tiers rather than a per-entry NodeDB lookup -each sweep (which would be O(entries x members) under the lock). A keyless warm-tier record still -marks membership (`isMember`) even though it has no `User` to seed - `isMember` is a keep-alive, -independent of `hasFullUser`. Because the re-mark is only hourly, hook-driven additions and -`purgeNode()` removals are immediate, but a **passive** NodeDB eviction may lag membership by up to -an hour. - -**Retention:** no timed eviction. Slots die only by LRU displacement on insert, ranked by -trust tiers - members and key-proven keys are stickiest; the seeding pass additionally -refuses to churn one member out for another (`spareMembers`). - -**Key-commit funnel:** every path that writes a remote key into the hot store must route -the write-through. Full-identity commits funnel through `NodeDB::updateUser()`; bare-key -commits (admin-channel learn in `Router::perhapsDecode`, manual verification in -`KeyVerificationModule`) funnel through `NodeDB::commitRemoteKey()`, which carries an -explicit `KeyCommitTrust` provenance (`ManuallyVerified` sets the `keyManuallyVerified` bit in this -cache). Never assign `info->public_key` directly when **learning or rotating a remote -key** - the cache would silently diverge until the next reconcile. (The lone direct write -in `getOrCreateMeshNode()`'s warm-tier re-admission is exempt: it restores a key the warm -tier already holds, which this cache already tracks as a member, so nothing new is learned -and the hourly reconcile re-seeds it even if the packet path had LRU-evicted that slot.) - -**Enable gate:** the write-through hooks, the sweep, the packet path, **and the -`copyPublicKey()`/`copyUser()` accessors** all no-op while `moduleConfig.has_traffic_management` -is off, so cache content, maintenance, and reads are keyed to the same condition. This enforces -(not just documents) the corollary that the pubkey-pool superset property holds only while the -module is enabled: a disabled module's frozen cache never feeds PKI resolution or name -rehydration. - -### Tick clocks and wrap safety - -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 -chose 1-byte ticks instead to stay at 10 B/entry across up to 2048 entries. - -### Direct-response behavior - -How this cache's identities are served as spoofed direct NodeInfo replies - the serve gates, -the per-requester/per-target/global throttle, and the "throttled forwards, not dropped" -behaviour - is documented with the module in -[traffic_management_module.md](traffic_management_module.md). - ---- - -## Property matrix - -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 | 2. Warm tier | 3. NodeInfo cache | 4. Unified cache | -| -------------------------- | ---------------------------------- | ------------------------------ | ---------------------------------- | ------------------------------- | -| Struct | `NodeInfoLite` | `WarmNodeEntry` | `NodeInfoPayloadEntry` | `UnifiedCacheEntry` | -| Node number | yes | yes | yes (0 = free) | yes (0 = free) | -| Names + user id | yes (flattened) | - | yes (full `User`) | - | -| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU/proven; pinned) | - | -| Key source - XEdDSA signed | `HAS_XEDDSA_SIGNED` bit | 1 bit (in `last_heard`) | `keyXeddsaSigned` | - | -| Key source - manual scan | `IS_KEY_MANUALLY_VERIFIED` bit | - (not carried) | `keyManuallyVerified` | - | -| Device role | `role` field | 4-bit role (metadata steal) | in cached `User` | 4-bit role (final fallback) | -| Recency | `last_heard` (unix s) | `last_heard` (128 s quant.) | `obsTick` (3 min) + `hasObserved` | modular ticks | -| Position / telemetry | satellite accessors | - | - | 8-bit pos fingerprint (dedup) | -| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` instead) | - | -| Routing hint (`next_hop`) | yes (persisted) | - | - | ACK-confirmed relay byte | -| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` | - | -| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fp | -| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8 (padded) | 10 B exact | -| Capacity (symbol) | `MAX_NUM_NODES` | `WARM_NODE_COUNT` | `kNodeInfoCacheEntries` | `TRAFFIC_MANAGEMENT_CACHE_SIZE` | -| Capacity (entries) | 250/200/120/100/10 (native: 200\*) | ~100 | 2000 | 2048/500/400/250/0 | -| Persistence (durable) | LittleFS (node DB) | flash ring (nRF52840)/LittleFS | none (rebuilt) | none | -| Storage (runtime) | heap | heap / PSRAM (ESP32) | PSRAM (hw) / heap (test) | PSRAM / heap | - -\* Native/portduino is not a compile-time value: it is `portduino_config.MaxNodes`; the host default -is 200, settable per-host via `General: MaxNodes`, and the WASM build overrides it to 80 -(`wasm_config_apply()`). See the hot-store capacity section above. - -## How a lookup falls through the tiers - -```text -identity/role/key consumer - │ - ▼ - 1. hot store (NodeInfoLite) full identity, authoritative - │ miss - ▼ - 2. warm tier (WarmNodeStore) key + role/protected/XEdDSA-signed bits, persisted - │ miss - ▼ - 3. TMM NodeInfo cache (extended) full User payloads + TOFU/proven keys, ephemeral - │ miss (role-only: 4-bit role in the unified cache) - ▼ - defaults (no key; role = CLIENT) -``` - -The unified cache (§3) sits beside this chain rather than in it: it is traffic-shaping -state keyed by the same NodeNum, whose role bits act as the final role fallback when all -three identity tiers miss. diff --git a/docs/traffic_management_module.md b/docs/traffic_management_module.md deleted file mode 100644 index cf4f9538e..000000000 --- a/docs/traffic_management_module.md +++ /dev/null @@ -1,222 +0,0 @@ -# The Traffic Management Module (TMM) - -TMM is an optional module that shapes **transit** traffic on busy meshes. Large networks get -noisy fast - repeated position packets, bursty senders, and unknown/undecryptable frames all -burn limited airtime and power - and TMM filters or answers that traffic before it is -rebroadcast. On supported targets it **ships enabled** (`has_traffic_management` defaults to -true) with position dedup running at its 11 h default; the other features each default off, so -the module is on out of the box but opt-in per feature. It was introduced in -[meshtastic/firmware#9358](https://github.com/meshtastic/firmware/pull/9358). - -This document covers the module's behaviour, with a deep dive on the two TMM-specific -NodeInfo features - **direct-serve** (answering NodeInfo requests on another node's behalf) -and the **throttling** that bounds it. The identity/traffic-state stores those features read -from are documented separately in [node_info_stores.md](node_info_stores.md); this file owns -the direct-serve and throttle behaviour, that file owns the stores. - -Sources of truth: `src/modules/TrafficManagementModule.{h,cpp}`, defaults in -`src/mesh/Default.h`. - ---- - -## How it runs - -- **Enablement is three-gated.** Compile-time `HAS_TRAFFIC_MANAGEMENT` (with the - `MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT` build exclusion), then the runtime - `moduleConfig.has_traffic_management` presence flag. While the runtime gate is off, the - packet path, the maintenance sweep, the NodeDB write-through hooks, and the cache accessors - all no-op - content, maintenance, and reads are keyed to the same condition. -- **It runs before `RoutingModule`** in `callModules()`. Returning `STOP` from - `handleReceived()` fully consumes a packet, so it is never rebroadcast; `CONTINUE` lets it - proceed through normal relay handling. -- **State is cheap.** Per-node traffic-shaping counters live in a flat 10-byte - `UnifiedCacheEntry` array (position fingerprint, rate/unknown counters, modular tick - stamps, a next-hop hint, and a 4-bit role fallback) - see - [node_info_stores.md §3](node_info_stores.md). Direct-serve additionally reads the PSRAM - NodeInfo payload cache (or the NodeDB fallback when that cache is absent). - -## What it does - -| Feature | Default | In one line | -| ------------------------ | -------------- | -------------------------------------------------------------- | -| Position dedup | on, 11 h | Suppresses a stationary sender's repeated position broadcasts. | -| Per-sender rate limit | off | Caps how many transit packets one sender may spend per window. | -| Unknown-packet filter | off | Drops a sender's undecryptable traffic past a threshold. | -| NodeInfo direct response | off | Answers a NodeInfo request on the target's behalf (see below). | -| Position precision clamp | channel-driven | Truncates relayed position to the channel's precision. | - -Config lives under `moduleConfig.traffic_management`; the per-feature sections below give the -exact fields, defaults, and behaviour. NodeInfo direct response has its own deep-dive sections -after these. - -### Position dedup - -`position_min_interval_secs` (default 11 h; `0` disables). Drops a duplicate position from the -same sender inside the interval, where "duplicate" means the same fingerprint on the channel's -`position_precision` grid (firmware default 19-bit, ~90 m cells). Role caps only ever _shorten_ -the interval: **tracker / TAK tracker → 1 h**, **lost-and-found → 15 min**. - -### Per-sender rate limit - -`rate_limit_window_secs` + `rate_limit_max_packets` (default off; either `0` disables). Drops a -sender's transit packets once it exceeds the budget within the window. - -### Unknown-packet filter - -`unknown_packet_threshold` (default `0` = off). Drops undecryptable traffic from a sender once it -passes the threshold within a ~5 min window. - -### NodeInfo direct response - -`nodeinfo_direct_response_max_hops` (default `0` = off). When set, a neighbour that already -holds the target's identity answers a unicast NodeInfo request on its behalf, saving the full -round trip. This is TMM's most security-sensitive feature; the serve gates and the throttle -that bounds it are covered in the two dedicated sections below. - -### Position precision clamp - -Driven by the channel's `position_precision` ceiling (else the 19-bit firmware default). -`alterReceived()` truncates relayed position coordinates to that precision. - -### Shelved - -Present in the config surface but currently no-ops in the module, deferred until the right -heuristics are settled: hop exhaustion for position/telemetry (`exhaust_hop_position` / -`exhaust_hop_telemetry`) and `router_preserve_hops`. `alterReceived()` leaves rebroadcast hop -handling untouched. - ---- - -## NodeInfo direct response (direct-serve) - -Normally a unicast NodeInfo request travels all the way to the target and the reply travels -all the way back. On a large mesh that is several hops of airtime per lookup. When -`nodeinfo_direct_response_max_hops > 0`, a neighbour that already holds the target's identity -answers **on the target's behalf** with a spoofed reply, cutting the round trip to one hop. - -**Data source.** The reply payload comes from the TMM NodeInfo payload cache (PSRAM-backed; -full cached `User` plus provenance metadata) or, on builds without that cache, from the -NodeDB fallback. Both are described in [node_info_stores.md §4](node_info_stores.md); this -feature is a _consumer_ of them. - -**Decision pipeline** (`shouldRespondToNodeInfo()`), in order - any failure returns `false` -and the request is left to propagate normally: - -1. **Eligibility** (checked by the caller): `nodeinfo_direct_response_max_hops > 0`, - `NODEINFO_APP` portnum, `want_response`, and the packet is unicast, not to us, not from us. -2. **Hop clamp** (`isMinHopsFromRequestor()`): respond only when the requester is within the - role-clamped hop ceiling - **routers up to 3 hops** (`kRouterDefaultMaxHops`, may be - lowered by config), **clients direct-only, 0 hops** (`kClientDefaultMaxHops`). -3. **Identity lookup**: NodeInfo cache hit (cache path) or NodeDB fallback (fallback path). -4. **Staleness gate (6 h)**: never vouch for a node not genuinely _heard_ within the serve - window. Only a real observed frame stamps the recency bit - seeding and write-through are - knowledge, not observation, so a silent node can never look alive to this path. -5. **Key-provenance gate** (`TMM_NODEINFO_REPLAY_SIGNED_GATE`, default on): vouch only for - an identity whose key is proven - XEdDSA-verified (directly or inherited from NodeDB) **or** - manually verified out-of-band. Both paths honour both channels: the cache path via - `keyProven()`, the NodeDB fallback path via `HAS_XEDDSA_SIGNED | IS_KEY_MANUALLY_VERIFIED`. A - trust-on-first-use identity is left for the genuine node - or another cache-holder that _has_ - proof - to answer. Bypassed when PKI is compiled out. -6. **Throttle** (`directResponseAllowed()`): see the next section. - -**The spoofed reply.** On success TMM emits a NodeInfo reply with `from` set to the _target_ -(so the requester sees a valid answer), `to` the requester, `hop_limit = 0` (one hop only), -`request_id` the original packet id, and the OK_TO_MQTT bit set from local -`config.lora.config_ok_to_mqtt` policy. The requester's own identity claim in the request is -**not** written back to NodeDB - a unicast NodeInfo is unsigned, so treating it as an -identity update would be unauthenticated. `nodeinfo_cache_hits` counts only replies actually -sent. - ---- - -## Throttling direct responses - -A direct reply is addressed to the requesting packet's `from` and spoofs the requested -target - and **both fields are unauthenticated header data**. Without a bound, an attacker -crafts requests carrying a victim's address as `from`, and every neighbour holding the target -transmits at the victim: a reflector-amplification primitive. The throttle is the security -core of this feature, checked immediately before a reply would go out so requests declined for -other reasons never consume the budget. - -**Three bounds**, all keyed off `clockMs()` and evaluated under `cacheLock`: - -| Bound | Window | Bounds | -| ------------------------------------------------ | ------ | ------------------------------------------------ | -| Per requester (`kDirectResponsePerRequesterMs`) | 60 s | how much any single node can be made to receive | -| Per target (`kDirectResponsePerTargetMs`) | 60 s | how often we vouch for the same identity | -| Global airtime floor (`kDirectResponseGlobalMs`) | 1 s | total spoofed TX, regardless of key distribution | - -**Mechanism.** The two per-key bounds are fixed **8-slot LRU tables in internal RAM** -(`directRequesterSeen`, `directTargetSeen`) - _not_ the PSRAM NodeInfo cache - so they behave -identically with and without PSRAM, on the cache path and the NodeDB-fallback path alike. -Timestamps are full `uint32` milliseconds compared by wrap-safe subtraction, so there is no -tick clock and no maintenance sweep to keep them honest. `directResponseAllowed(requester, -target, now)` resolves a slot in _both_ tables before stamping either - so a reply one axis -throttles never consumes the other axis's budget - then records the send on all three bounds. -The global floor is a single stamp, checked first as the cheap common case. - -**When a table fills.** For an unseen key with no free slot, `directResponseSlot()` evicts the -**least-recently-used** entry (smallest last-reply time) and admits the new key. The LRU -victim is by construction the entry closest to expiring anyway, so eviction is the -lowest-cost choice. An attacker who cycles more than 8 distinct requesters or targets - easy, -since both are unauthenticated - evicts entries and defeats _per-key_ throttling for the -cycled keys; that is expected, and why the **global 1 s floor is the hard backstop**. It is a -single stamp, cannot fill, and caps total spoofed replies at ~1/s no matter what. Per-key -throttling degrades gracefully to the floor under pressure. - -**Throttled is not dropped.** A throttled request returns `false`, which lets -`handleReceived()` `CONTINUE`: the request forwards toward the genuine target (which can -answer itself) rather than being black-holed. A requester whose first reply was lost on a -noisy link would otherwise get silence for the whole window; repeats of the same packet id -are already absorbed by the router's duplicate detection. - -**Evolution.** The original design split throttling by path: a per-entry `respTick` stamp in -each NodeInfo cache slot (cache path, 30 s, swept for wrap-safety) plus a single module-global -stamp for the NodeDB fallback (30 s, neither per-requester nor per-target). Those two routes -were unified into the symmetric per-requester + per-target RAM tables above, aligned to a -single 60 s window, so both axes hold with and without PSRAM and the cache entry no longer -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 -`has_traffic_management` presence flag, and each per-feature section above lists its own -field(s) and default. Two related sets of knobs are **firmware constants, not config**: the -role-based position caps `default_traffic_mgmt_tracker_position_min_interval_secs` (1 h) and -`default_traffic_mgmt_lost_and_found_position_min_interval_secs` (15 min), and the direct-serve -throttle windows (the `kDirectResponse*Ms` constants). - -## See also - -- [node_info_stores.md](node_info_stores.md) - the NodeDB hot store, warm tier, TMM NodeInfo - payload cache, and unified cache that the direct-serve path reads from, plus their trust, - provenance, and anti-entropy model. diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index b4c5fae98..0fdd8c722 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -586,7 +586,8 @@ void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() // Membership refresh (this hourly pass owns it): clear every isMember bit, then re-mark from // both NodeDB tiers. Runs AFTER seeding so the upsert still sees last pass's bits (spareMembers). - // Cost/lag rationale in docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)". + // Cost/lag rationale in https://meshtastic.org/docs/development/reference/node-info-stores "Consistency with NodeDB + // (anti-entropy)". for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) nodeInfoPayload[i].isMember = false; for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { @@ -729,7 +730,8 @@ bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool { // Same enable gate as the write-through hooks and maintenance: a disabled module stops // updating and sweeping the cache, so its frozen contents must not keep feeding PKI key - // resolution either. Enforces the "superset only while enabled" corollary (node_info_stores.md). + // resolution either. Enforces the "superset only while enabled" corollary + // (https://meshtastic.org/docs/development/reference/node-info-stores). if (!moduleConfig.has_traffic_management) return false; if (!nodeInfoPayload || node == 0 || !out) @@ -1514,7 +1516,8 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // Throttle the spoofed reply (per requester + per target + 1 s global floor; checked here so a // request declined above never spends the budget). false forwards the request instead of consuming - // it. Rationale in docs/traffic_management_module.md "Throttling direct responses". + // it. Rationale in https://meshtastic.org/docs/development/reference/traffic-management-internals "Throttling direct + // responses". if (!directResponseAllowed(getFrom(p), p->to, clockMs())) { TM_LOG_DEBUG("NodeInfo direct response throttled for 0x%08x; forwarding request", getFrom(p)); return false; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index e01cdefdb..631673d75 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -33,7 +33,8 @@ /// Packet inspection and traffic shaping: position dedup, per-node rate limiting, unknown-packet /// filtering, NodeInfo direct response, and the next-hop/role overflow caches. One flat 10-byte -/// unified cache backs all per-node features; see docs/node_info_stores.md for the store overview. +/// unified cache backs all per-node features; see https://meshtastic.org/docs/development/reference/node-info-stores for the +/// store overview. class TrafficManagementModule : public MeshModule, private concurrency::OSThread { public: @@ -144,7 +145,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread private: // 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with // non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count - // bytes (tier-3 role fallback). Full layout and rationale: docs/node_info_stores.md. + // bytes (tier-3 role fallback). Full layout and rationale: + // https://meshtastic.org/docs/development/reference/node-info-stores. #if _meshtastic_Config_DeviceConfig_Role_MAX > 15 #warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values" #endif @@ -347,12 +349,14 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread /// 60 s NodeInfo-cache maintenance under cacheLock: saturate the expired obsTick stamp (wrap-safety /// for the modular clock) and run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone - /// (never the unified cache size); see docs/node_info_stores.md "Tick clocks and wrap safety". + /// (never the unified cache size); see https://meshtastic.org/docs/development/reference/node-info-stores "Tick clocks and + /// wrap safety". void maintainNodeInfoCacheLocked(); /// Anti-entropy under cacheLock: upsert hot-store + warm-tier records this cache lacks (never sets /// hasObserved - seeding is knowledge, not observation), and refresh isMember from both NodeDB - /// tiers. Cost/lag: docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)". + /// tiers. Cost/lag: https://meshtastic.org/docs/development/reference/node-info-stores "Consistency with NodeDB + /// (anti-entropy)". void reconcileNodeInfoFromNodeDBLocked(); /// Learn an observed NODEINFO frame into the cache (key hygiene + provenance rules apply). void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); @@ -368,7 +372,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // Direct-response throttles bounding the reflector risk of spoofed replies: three fixed bounds // (per requester, per target, 1 s global airtime floor) via 8-slot LRU RAM tables, wrap-safe and - // PSRAM-agnostic. Design & rationale: docs/traffic_management_module.md "Throttling direct responses". + // PSRAM-agnostic. Design & rationale: https://meshtastic.org/docs/development/reference/traffic-management-internals + // "Throttling direct responses". static constexpr uint32_t kDirectResponsePerRequesterMs = 60'000UL; static constexpr uint32_t kDirectResponsePerTargetMs = 60'000UL; static constexpr uint32_t kDirectResponseGlobalMs = 1'000UL; diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index 45dfa8c4a..60afed5ce 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -1,4 +1,4 @@ -// Unit tests for NextHop direct-message reliability mitigations (see docs/nexthop-routing-reliability.md): +// Unit tests for NextHop direct-message reliability mitigations (landed in meshtastic/firmware#10745): // M1 - NodeDB::resolveLastByte / resolveUniqueLastByte (ambiguity-aware last-byte resolution) // M2 - NextHopRouter::getNextHop strict-neighbor gate + Router::shouldDecrementHopLimit favorite check // M3 - NextHopRouter route-health freshness / failure decay From f5314148c2f6dfef154902630fa665f7cc48b080 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:12:14 +0200 Subject: [PATCH 048/109] Serialise AirTime behind a lock, and stop handing out its buckets (#11362) * Copy airtime reports into a caller buffer instead of exposing the array airtimeReport() returned a pointer into the rotating bucket arrays, so the caller held a handle to state that logAirtime() and every accessor mutate underneath it. Copy into a caller-supplied buffer instead, and report failure for a null buffer, a count past the log depth, or an unknown report type. ContentHandler owns its buffer and hoists getPeriodsToLog() out of the three calls that repeated it. * Cover the AirTime report API and log-dispatch contract Half of AirTime's surface had no tests: which store each report type feeds, what airtimeReport() does when misused, how the first sync seeds itself, and whether calling several entry points in one interval compounds the rotation. Eighteen tests, asserted through the public API rather than the public bucket arrays - those arrays are meant to become private, and a test that reads them would have to be rewritten rather than pinning a contract. Two of them state a convention that was never written down: the report arrays are shift-ordered with slot 0 newest, and slot 0 covers only the time since the last rotation. channelUtilization and utilizationTX use the opposite convention - a modular ring indexed by uptime phase - and reading one as if it were the other is a defect that has already happened once. * Characterise AirTime window decay, TX gates, and sleep behaviour Thirty-three tests in three kinds. Invariants must hold forever; boundaries pin off-by-ones a refactor would move; five characterisations encode today's wrong numbers, each tagged with the phase that will flip it. Readings are asserted against an event-log oracle - airtime physically on air inside (now - window, now], computed from a list of completed packets - rather than against hand-worked constants, so a test states "this matches the definition" instead of "this looked right when I wrote it". The characterisations, all measured rather than assumed: - the window covers (N-1)p + phase but divides by Np, so a steady 10% load reads 8.33% right after a bucket boundary -> phase 5 - the same load sweeps across bucket phase instead of holding -> phase 5 - the hour window carries the same defect, 10x smaller -> phase 5 - a packet longer than its bucket is credited whole to the bucket it completed in, so a saturated LONG_SLOW channel reads >100% -> phase 4b - getSilentMinutes() reads a modular ring as if the index were an age, so identical airtime gives different answers by phase -> phase 6 Two tests needed correcting during the write, both my expectations rather than the code: a six-bucket ring sheds whole buckets, so a 30s gap drops three of five survivors and not "half"; and the oracle sees 59 completions in a 60s window, not 60, because the one on the lower edge is outside it. Not written: the planned RX_LOG/RX_ALL_LOG disjointness test. That is a property of the two radio drivers, which choose one or the other per packet - it is not observable from AirTime, which records what it is told. The AirTime-side half is already covered by the routing tests. * Drop write-only and undefined AirTime members None of this was reachable: air_period_tx / air_period_rx file-scope mirrors of airtimes.periodTX/RX, accumulated, rotated and memset in lockstep with them but never read out or serialised. Orphaned when #2552 re-pointed the writes at bare globals instead of deleting them. lastUtilPeriod, lastUtilPeriodTX written on every sync, read nowhere airtimes.lastPeriodIndex written on every rotation, read nowhere currentPeriodIndex() computes (secs / 3600) % 8 - a modular-ring index for the one array that is shift-ordered rather than a ring. Its only two uses were the dead field above and a log line. It is the fossil of the same confusion that makes getSilentMinutes() wrong. UtilizationPercentTX() declared, never defined free logAirtime()/airtimeReport() declared, never defined; the latter still carried the array-returning signature the previous commit removed, so it actively misled Also fixes the rotation log line, which read currentPeriodIndex() from inside the loop although the index is advanced before it - on a multi-hour wake it printed the same final value once per rotation. It now reports which of the crossed hours is being rotated. airtimeRotatePeriod() is kept: it has no caller in the tree either, but unlike the above it is a defined public method, so out-of-tree callers are plausible. Measured, not estimated: sizeof(AirTime) 464 -> 456 B, plus 64 B of globals, so -72 B of static RAM. Padding accounts for the difference from the 66 B the plan predicted by counting declared bytes. The whole point of writing the tests first: the suite is green here with zero test changes. * Document what the AirTime figures measure and how they are stored Comments only, but four of the things they replace were false. The header's example analytics claimed RX_ALL_LOG was "all received lora packets" and offered "RX_ALL_LOG - RX_LOG = other lora radios". Both radio drivers pick exactly one of the two per packet, so they are disjoint: RX_ALL_LOG is airtime we could not parse, the subtraction can go negative, and the total is TX + RX + RX_ALL. Replaced with the actual contract - four inputs, eight outputs, the window each spans, and the fact that the three thresholds are hard-coded members rather than the settings they look like. Names the two storage conventions on their declarations, because mixing them up is what makes getSilentMinutes() wrong: channelUtilization and utilizationTX are modular rings indexed by uptime phase, where the oldest bucket is (current + 1) % N; airtimes.period* is shift-ordered with slot 0 newest, where the index IS an age and slot 0 is a partial hour. Defines the measurement as wall time rather than awake time, and says why: a sleeping node still hears traffic, and per-node redefinition would make two broadcast readings incomparable. Records that the 60s figure is published to the mesh at >= 1h cadence, so what other nodes see is a snapshot - at LONG_FAST and 1% occupancy it reads exactly 0 in about 44% of reports - and that the contention window it feeds moves in 20-percentage-point steps, so small errors never reach the backoff. Finally, states that rotation happens on access rather than on the scheduler tick, names the test that enforces it, and leaves a TODO pointing at the plan phases that fix the characterised accuracy defects. * Serialise AirTime behind a lock proven by a private token Two mechanisms solving different halves. A lock-free inner core (Windows) holds all state and all logic; it has no lock and no way to reach one, so nesting is impossible by construction. A private Held token takes the lock in its own constructor and is the only thing that can be passed where a core method demands one, so the lock cannot be forgotten either. The rule is now uniform with no exceptions to remember: every public method takes the lock once and delegates. In particular isTxAllowed*() lock like everything else - before the split they could not, because they called the public accessors and the lock is not recursive. That asymmetry was the foot-gun the previous design documented in prose and hoped nobody would trip. getPeriodsToLog()/getSecondsPerPeriod() still take no lock; they return compile-time constants and touch no state. channelUtilization[] and utilizationTX[] were public, so the lock was bypassable at compile time. They move into the private core. Four test sites reached in; all four now use logAirtime() plus the virtual clock, and no new test seam was needed. Nothing in src/ was affected. The re-entry assert is guarded on PIO_UNIT_TESTING, so it exists in test builds only. The design sketched #ifdef DEBUG, but nothing in this tree defines DEBUG or NDEBUG, so either spelling ships the assert to every board - and nrf52_promicro_diy_tcxo has ~128 bytes of headroom under its 0xEA000 warm-store cap, which the assert's strings and abort path overrun. It would have worked on hardware, since the check runs in Held's owner initialiser and so precedes the blocking take; the objection is that abort()ing a live mesh node is a poor trade for a bug never seen in the field. Native tests are where it earns its keep anyway: Portduino compiles Lock::lock() to an empty body, so a nested take there succeeds silently and nothing else would notice. Also comments out ScopedBusyAirTime in test_traffic_management. It is inert twice over: the module holds no reference to airTime at all since hop exhaustion was shelved, and the fixture never worked anyway - writing the buckets on a fresh AirTime is undone by the first accessor call, which takes the firstTime branch and memsets them. It reported 0%, not the 100% it claimed. Left in place, commented, with both reasons recorded. Cost on the tightest board in the tree, nrf52_promicro_diy_tcxo: the six phases together add 96 bytes of flash, leaving it 32 bytes clear of the warm-store guard. RAM is 72 bytes lower from the dead-state removal. Suite green at 47/47, with test_airtime unedited apart from the added nesting test. * Count rotations with the loop variable, not a separate tally LOG_DEBUG compiles to nothing under DEBUG_MUTE, so the counter's only read disappeared with it and the tally became write-only. It does not warn today - this build has -Wunused-but-set-variable on, and it fires for other locals, but not for one that is only initialised and never read - so it was latent rather than broken: a stricter flag or -Werror would have failed muted builds only. Using the loop variable removes the class of problem, since the loop condition reads it, and drops the elapsedAirtimePeriods-- mutation as a side benefit. Same iteration count, same output. Found by compiling nrf52_promicro_diy_tcxo with -D DEBUG_MUTE, which is worth recording for its own sake: muting logs takes that image from 802 784 to 673 416 bytes, 98.5% to 82.6% of flash. Logging is 16% of the largest nrf52 image, and its 32 bytes of warm-store headroom are a logging-verbosity question rather than a code-size one. * Tighten the comments added by this branch Comment-only: with comments stripped, all five files are byte-identical to the previous commit. Removed the references to the planning notes. Those documents are working material and will go stale; the code should not depend on them. The five CHARACTERISATION tags now describe the defect they pin and stop there, and the accuracy TODO names the four defects and points at the tests instead of a plan file. Also removed, as noise rather than information: - comparisons against pre-#11291 behaviour, which nobody reading this needs - a comment describing the lock restructure as future work, written before it landed - speculation ("plausible", "worth pinning so a future...") - an aside arguing with an arithmetic slip made while writing the test Kept the mechanical facts that are slow to re-derive: the two storage orderings and which array uses which, RX_LOG/RX_ALL_LOG disjointness, the locking rule and the addSpanned() constraint that protects it, why the re-entry assert is test-only, and the concrete numbers - (N-1)p + phase, 14 164 ms, the 20 pp contention-window steps. Net 16 comment lines out of src/, 33 out of test/. * Gate the AirTime re-entry check on the host, not on testing PIO_UNIT_TESTING is injected by PlatformIO purely on BUILD_TYPE, with no platform check, so it is defined on an on-target `pio test` run too. The check arms before the lock is taken - a nested take blocks forever, so a later check would never run - which under preemption false-positives on legitimate contention and races on its own write. Derive AIRTIME_REENTRY_CHECK once from PIO_UNIT_TESTING && !HAS_FREE_RTOS and use it at all three sites. Had the three conditions ever diverged, an on-target test build would fail to compile on a member the header no longer declares. * Log AirTime outside the lock it serialises DEBUG_PORT.log() blocks on a UART write, and `lock` is a plain binary semaphore with no priority inheritance, so holding it across a log call lets the main thread stall the radio thread in getTxDelayMsec(). Move logAirtime()'s LOG_DEBUG into the shell, after the Held scope closes; the shell already has both arguments, so nothing has to be passed back out of the core. isTxAllowed{ChannelUtil,AirUtil} read into a local under the lock and warn after it. The log bodies are braced because LOG_DEBUG compiles away under DEBUG_MUTE and a bare `if (x) ;` trips -Wempty-body. Fold the two doubled index calls into `+=` while touching the lines. * Give each airtime report its own buffer handleReport() reused one array across the three airtimeReport() calls and ignored the bool. A failed report would have left the previous type's data in place and emitted it under the next type's key. Build each through a lambda whose buffer is zeroed per call, so a failure emits zeros. Unreachable today - the count is always PERIODS_TO_LOG and the type is always valid - but the old shape only read as correct by accident. * Drop a stray semicolon from the inert-guard comment * Address external review: name the race, tighten the claims and the tests The header sold the lock as mechanism without naming a second thread, which invites the reasonable objection that this is a cooperative OSThread codebase. There is a real race and it is nRF52-only: NRF52Bluetooth registers its ToRadio write callback with defer == false, so a phone's packet runs handleToRadio -> sendToMesh -> Router::send on the Bluefruit BLE task, reading utilizationTXPercent() and getSilentMinutes() while loopTask may be inside logAirtime(). ESP32 hands BLE work to the main task and does not have it. Three claims in the header were wrong or overstated: - "nesting is impossible by construction" - Windows is a nested class with an enclosing class's access rights, and `extern AirTime *airTime` is in the same header, so airTime->anyPublicMethod() from inside it is well-formed and would hang. Nothing does it; the assert is the backstop. Say that instead, because the comment below instructs contributors to add helpers to Windows on the strength of the guarantee. - "every public method takes the lock exactly once" - two constant accessors take none and isTxAllowedAirUtil() takes it zero or one times. State the exceptions where the invariant is stated, not only at the definitions. - "both radio drivers pick exactly one per packet" - five drop paths log neither. At most one. Recorded against plan4 rather than fixed here: it changes a telemetry value. getPeriodsToLog()/getSecondsPerPeriod() become static constexpr, which removes them from the locking claim structurally and lets ContentHandler size its buffer and its count from one constant. Tests: - C14's saturated AirTime is installed by a helper and restored in tearDown. Unity's TEST_ABORT() is longjmp and does not run destructors of automatic objects, so the scoped guard it replaces would leave airTime dangling into an abandoned frame on any assertion failure - and the same commit that added it removed the tearDown reset that did cover that. - test_getSilentMinutes_counts_minutes_until_enough_ages_out asserted only `mins <= 60`, which neither return path can violate. The answer is 59. - test_backwards_uptime_degrades_safely stepped 600s -> 60s, which leaves elapsedAirtimePeriods at 0, so it never reached the hourly-report branch its own comment describes. Step by the wrap instead and assert the exact figures. - test_airtime leaked EU_868 out of the duty-cycle case into every later one, and the reentry test's isTxAllowedAirUtil() coverage depended on it. Restore the region in tearDown and set it explicitly where it is wanted. - Rename that test to what it can actually check: no single method takes the lock twice. The calls are sequential, so it cannot catch two methods nesting. * trunk: suppress trufflehog/Lob false positives in test_airtime * Address CodeRabbit review: the rotate trace, the cap warn, the backoff Four findings from the CodeRabbit pass. Two were introduced by this branch, one is a real inconsistency it inherited, one is a naming slip. The rotate trace was the one that mattered. "Log AirTime outside the lock it serialises" moved the per-packet lines and the two TX-gate warnings out to the shell, but missed LOG_DEBUG("Rotate airtimes, crossed hour %u") because it does not sit in the shell at all: it is inside Windows::syncNow(), the lock-free core, which by construction only ever runs under Held. Nothing at that line looks like a lock, which is why it survived. The exposure is smaller than the review suggests - runOnce() syncs at 1 Hz, so in steady state this is one line an hour, and the PERIODS_TO_LOG - 1 burst needs an hour of light sleep with no intervening sync - but a UART write under a plain binary semaphore with no priority inheritance is exactly what the comment above logAirtime() says this code does not do. syncNow() now accumulates crossings in rotationsPendingLog and runOnce() drains it inside the Held scope, then logs after release. Any caller can cross an hour; only that thread reports it, so a crossing raised elsewhere is traced at most one tick late. The `if (rotations > 0)` guard keeps the drained value read under DEBUG_MUTE, where LOG_DEBUG expands to nothing - the write-only tally that "Count rotations with the loop variable" removed. addFromContact()'s favorite fallback stamped silently when the protected cap refused it. The stamp is new on this branch; the two sibling refusals (ignore, verify) both emit PROTECTED_CAP_WARN_FMT, so the operator lost the only signal that the cap was hit on the one path that has a fallback. lfs_assert() mixed clocks: Throttle read Time::getMillis(), the remainder was computed from a second, bare millis(). The review's stated failure mode - a native test overriding the clock - cannot happen, since the hook is behind PIO_UNIT_TESTING and this file is nRF52-only. The real defect is the second read: a tick landing on the 20-minute boundary between the check and the subtraction underflows the remainder into delay(~50 days), on a device that has just found its flash corrupt. One read, clamped, and preFSBegin() stores from the same clock. The eviction test is renamed to test_eviction_prefersCurrentBootStampOverPost2038Epoch. The finding is right that it was snake_case, but the suggested testEvictionPrefers... does not match this file either, which is test__ throughout. Not taken, both pre-existing and out of scope for a rollover branch: - t5s3_epaper's touchResumeAtMs/suppressFromMs read an active suppression as inactive if the wake lands in the 1 ms where millis() is 0. Consequence is one skipped 150 ms touch-settle window per 49.7-day wrap. - NRF52Bluetooth::onPairingPasskey() busy-waits 30 s in a BLE callback. Worth saying plainly that this branch makes it more visible: the old `millis() < start_time + 30000` overflowed at the wrap and cut the wait short, so the correct Throttle form is what lets it run the full 30 s. Reworking it into an OSThread is its own change. Native suite GREEN, 48/48, 672 cases. --- .trunk/trunk.yaml | 1 + src/airtime.cpp | 265 +++-- src/airtime.h | 209 +++- src/mesh/NodeDB.cpp | 4 +- src/mesh/http/ContentHandler.cpp | 19 +- src/platform/nrf52/main-nrf52.cpp | 11 +- test/test_airtime/test_main.cpp | 1095 +++++++++++++++++++- test/test_nodedb_blocked/test_main.cpp | 4 +- test/test_packet_signing/test_main.cpp | 34 +- test/test_traffic_management/test_main.cpp | 40 +- 10 files changed, 1496 insertions(+), 186 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 099a6a491..aec3fc6f8 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -158,6 +158,7 @@ lint: # 32-bit rollover. - linters: [trufflehog] paths: + - test/test_airtime/test_main.cpp - test/test_throttle/test_main.cpp - test/test_uptime_clock/test_main.cpp runtimes: diff --git a/src/airtime.cpp b/src/airtime.cpp index a9b4c7dc5..aaacefb09 100644 --- a/src/airtime.cpp +++ b/src/airtime.cpp @@ -2,62 +2,65 @@ #include "NodeDB.h" #include "UptimeClock.h" #include "configuration.h" +#include #include AirTime *airTime = NULL; -// Don't read out of this directly. Use the helper functions. +AirTime *AirTime::Held::armReentryCheck(AirTime *a) +{ +#ifdef AIRTIME_REENTRY_CHECK + // Before the lock: a nested take blocks forever, so a later check would never run. + assert(!a->reentryFlag); + a->reentryFlag = true; +#endif + return a; +} -uint32_t air_period_tx[PERIODS_TO_LOG]; -uint32_t air_period_rx[PERIODS_TO_LOG]; +AirTime::Held::~Held() +{ +#ifdef AIRTIME_REENTRY_CHECK + owner->reentryFlag = false; +#else + (void)owner; +#endif +} -void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms) +// --- the lock-free core ------------------------------------------------------------------------- +// Every method here requires the lock, and says so in its signature. None can take it: Windows has +// no lock to reach. + +void AirTime::Windows::logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &held) { // A packet may be logged immediately after waking from light sleep. Sync first so // the packet is counted in the current wall-time bucket, not a stale awake-time bucket. - syncNow(); + syncNow(held); + // The caller logs, once the lock is released. if (reportType == TX_LOG) { - LOG_DEBUG("Packet TX: %ums", airtime_ms); this->airtimes.periodTX[0] = this->airtimes.periodTX[0] + airtime_ms; - air_period_tx[0] = air_period_tx[0] + airtime_ms; - - this->utilizationTX[this->getPeriodUtilHour()] = this->utilizationTX[this->getPeriodUtilHour()] + airtime_ms; + this->utilizationTX[this->getPeriodUtilHour(held)] += airtime_ms; } else if (reportType == RX_LOG) { - LOG_DEBUG("Packet RX: %ums", airtime_ms); this->airtimes.periodRX[0] = this->airtimes.periodRX[0] + airtime_ms; - air_period_rx[0] = air_period_rx[0] + airtime_ms; } else if (reportType == RX_ALL_LOG) { - LOG_DEBUG("Packet RX (noise?) : %ums", airtime_ms); this->airtimes.periodRX_ALL[0] = this->airtimes.periodRX_ALL[0] + airtime_ms; } // Log all airtime type for channel utilization - this->channelUtilization[this->getPeriodUtilMinute()] = channelUtilization[this->getPeriodUtilMinute()] + airtime_ms; + this->channelUtilization[this->getPeriodUtilMinute(held)] += airtime_ms; } -uint8_t AirTime::currentPeriodIndex() -{ - return ((secSinceBoot / SECONDS_PER_PERIOD) % PERIODS_TO_LOG); -} - -uint8_t AirTime::getPeriodUtilMinute() +uint8_t AirTime::Windows::getPeriodUtilMinute(const Held &) { return (secSinceBoot / 10) % CHANNEL_UTILIZATION_PERIODS; } -uint8_t AirTime::getPeriodUtilHour() +uint8_t AirTime::Windows::getPeriodUtilHour(const Held &) { return (secSinceBoot / 60) % MINUTES_IN_HOUR; } -void AirTime::airtimeRotatePeriod() -{ - // Preserve the public helper while keeping all rotation logic in one monotonic-time path. - syncNow(); -} - -void AirTime::syncNow() +void AirTime::Windows::syncNow(const Held &) { // Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move // airtime accounting. Pure read; the main loop publishes the wrap carry it derives from. @@ -69,13 +72,8 @@ void AirTime::syncNow() memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX)); memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX)); memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL)); - memset(air_period_tx, 0, sizeof(air_period_tx)); - memset(air_period_rx, 0, sizeof(air_period_rx)); this->secSinceBoot = nowSecs; - this->lastUtilPeriod = this->getPeriodUtilMinute(); - this->lastUtilPeriodTX = this->getPeriodUtilHour(); - this->airtimes.lastPeriodIndex = this->currentPeriodIndex(); firstTime = false; return; } @@ -94,27 +92,22 @@ void AirTime::syncNow() memset(this->airtimes.periodTX, 0, sizeof(this->airtimes.periodTX)); memset(this->airtimes.periodRX, 0, sizeof(this->airtimes.periodRX)); memset(this->airtimes.periodRX_ALL, 0, sizeof(this->airtimes.periodRX_ALL)); - memset(air_period_tx, 0, sizeof(air_period_tx)); - memset(air_period_rx, 0, sizeof(air_period_rx)); } else { - while (elapsedAirtimePeriods-- > 0) { - LOG_DEBUG("Rotate airtimes to a new period = %u", this->currentPeriodIndex()); + // Hand the count to runOnce() rather than tracing each crossing here: this runs under + // the lock, and a UART write would stall every other caller waiting on it. + this->rotationsPendingLog += elapsedAirtimePeriods; + for (uint32_t h = 0; h < elapsedAirtimePeriods; h++) { for (int i = PERIODS_TO_LOG - 2; i >= 0; --i) { this->airtimes.periodTX[i + 1] = this->airtimes.periodTX[i]; this->airtimes.periodRX[i + 1] = this->airtimes.periodRX[i]; this->airtimes.periodRX_ALL[i + 1] = this->airtimes.periodRX_ALL[i]; - air_period_tx[i + 1] = this->airtimes.periodTX[i]; - air_period_rx[i + 1] = this->airtimes.periodRX[i]; } this->airtimes.periodTX[0] = 0; this->airtimes.periodRX[0] = 0; this->airtimes.periodRX_ALL[0] = 0; - air_period_tx[0] = 0; - air_period_rx[0] = 0; } } - this->airtimes.lastPeriodIndex = this->currentPeriodIndex(); // Channel utilization is a rolling 60-second view split into six 10-second buckets. // Clear every bucket crossed while asleep so old airtime decays by real elapsed time. @@ -126,7 +119,6 @@ void AirTime::syncNow() this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0; } } - this->lastUtilPeriod = this->getPeriodUtilMinute(); // TX utilization is a rolling 60-minute view used by duty-cycle checks. uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60); @@ -137,45 +129,35 @@ void AirTime::syncNow() this->utilizationTX[((oldSecSinceBoot / 60) + i) % MINUTES_IN_HOUR] = 0; } } - this->lastUtilPeriodTX = this->getPeriodUtilHour(); } -uint32_t *AirTime::airtimeReport(reportTypes reportType) +bool AirTime::Windows::airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &held) { + if (!out || count > PERIODS_TO_LOG) + return false; + // Reports may be requested before runOnce() executes after wake. - syncNow(); + syncNow(held); + const uint32_t *src = nullptr; if (reportType == TX_LOG) { - return this->airtimes.periodTX; + src = this->airtimes.periodTX; } else if (reportType == RX_LOG) { - return this->airtimes.periodRX; + src = this->airtimes.periodRX; } else if (reportType == RX_ALL_LOG) { - return this->airtimes.periodRX_ALL; + src = this->airtimes.periodRX_ALL; } - return 0; + if (!src) + return false; + + memcpy(out, src, count * sizeof(*out)); + return true; } -uint8_t AirTime::getPeriodsToLog() -{ - return PERIODS_TO_LOG; -} - -uint32_t AirTime::getSecondsPerPeriod() -{ - return SECONDS_PER_PERIOD; -} - -uint32_t AirTime::getSecondsSinceBoot() -{ - // Keep HTTP/debug reporting aligned with the same monotonic clock used by the buckets. - syncNow(); - return this->secSinceBoot; -} - -float AirTime::channelUtilizationPercent() +float AirTime::Windows::channelUtilizationPercent(const Held &held) { // Gate decisions should see buckets that have decayed across light-sleep time. - syncNow(); + syncNow(held); uint32_t sum = 0; for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) { @@ -185,10 +167,10 @@ float AirTime::channelUtilizationPercent() return (float(sum) / float(CHANNEL_UTILIZATION_PERIODS * 10 * 1000)) * 100; } -float AirTime::utilizationTXPercent() +float AirTime::Windows::utilizationTXPercent(const Held &held) { // Duty-cycle checks use this value, so keep it current even outside the periodic thread. - syncNow(); + syncNow(held); uint32_t sum = 0; for (uint32_t i = 0; i < MINUTES_IN_HOUR; i++) { @@ -198,33 +180,9 @@ float AirTime::utilizationTXPercent() return (float(sum) / float(MS_IN_HOUR)) * 100; } -bool AirTime::isTxAllowedChannelUtil(bool polite) -{ - uint8_t percentage = (polite ? polite_channel_util_percent : max_channel_util_percent); - if (channelUtilizationPercent() < percentage) { - return true; - } else { - LOG_WARN("Ch. util >%d%%. Skip send", percentage); - return false; - } -} - -bool AirTime::isTxAllowedAirUtil() -{ - float effectiveDutyCycle = getEffectiveDutyCycle(); - if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) { - if (utilizationTXPercent() < effectiveDutyCycle * polite_duty_cycle_percent / 100) { - return true; - } else { - LOG_WARN("TX air util. >%f%%. Skip send", effectiveDutyCycle * polite_duty_cycle_percent / 100); - return false; - } - } - return true; -} - -// Get the amount of minutes we have to be silent before we can send again -uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle) +// Minutes we must be silent before sending again. Does not sync, and walks the ring as if the index +// were an age; both are wrong and both are pinned by characterisation tests. See airtime.h's TODO. +uint8_t AirTime::Windows::getSilentMinutes(float txPercent, float dutyCycle, const Held &) { float newTxPercent = txPercent; for (int8_t i = MINUTES_IN_HOUR - 1; i >= 0; --i) { @@ -236,10 +194,119 @@ uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle) return MINUTES_IN_HOUR; } -AirTime::AirTime() : concurrency::OSThread("AirTime"), airtimes({}) {} +// --- the locking shell -------------------------------------------------------------------------- +// Each takes the lock exactly once and delegates. Nothing below calls another method on `this`. + +void AirTime::logAirtime(reportTypes reportType, uint32_t airtime_ms) +{ + { + Held held(this); + w.logAirtime(reportType, airtime_ms, held); + } + + // Outside the lock: DEBUG_PORT.log() blocks on a UART write, and `lock` is a plain binary + // semaphore with no priority inheritance, so holding it here would stall the radio thread. + if (reportType == TX_LOG) { + LOG_DEBUG("Packet TX: %ums", airtime_ms); + } else if (reportType == RX_LOG) { + LOG_DEBUG("Packet RX: %ums", airtime_ms); + } else if (reportType == RX_ALL_LOG) { + LOG_DEBUG("Packet RX (noise?) : %ums", airtime_ms); + } +} + +void AirTime::airtimeRotatePeriod() +{ + // Preserve the public helper while keeping all rotation logic in one monotonic-time path. + Held held(this); + w.syncNow(held); +} + +bool AirTime::airtimeReport(reportTypes reportType, uint32_t *out, size_t count) +{ + Held held(this); + return w.airtimeReport(reportType, out, count, held); +} + +uint32_t AirTime::getSecondsSinceBoot() +{ + // Keep HTTP/debug reporting aligned with the same monotonic clock used by the buckets. + Held held(this); + w.syncNow(held); + return w.secSinceBoot; +} + +float AirTime::channelUtilizationPercent() +{ + Held held(this); + return w.channelUtilizationPercent(held); +} + +float AirTime::utilizationTXPercent() +{ + Held held(this); + return w.utilizationTXPercent(held); +} + +// These lock like everything else, because they call the core rather than the public accessors. +// Both read under the lock and warn after it, for the reason logAirtime() does. +bool AirTime::isTxAllowedChannelUtil(bool polite) +{ + uint8_t percentage = (polite ? polite_channel_util_percent : max_channel_util_percent); + float utilization; + { + Held held(this); + utilization = w.channelUtilizationPercent(held); + } + + if (utilization < percentage) + return true; + LOG_WARN("Ch. util >%d%%. Skip send", percentage); + return false; +} + +bool AirTime::isTxAllowedAirUtil() +{ + float effectiveDutyCycle = getEffectiveDutyCycle(); + if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) { + float limit = effectiveDutyCycle * polite_duty_cycle_percent / 100; + float utilization; + { + Held held(this); + utilization = w.utilizationTXPercent(held); + } + + if (utilization < limit) + return true; + LOG_WARN("TX air util. >%f%%. Skip send", limit); + return false; + } + return true; +} + +uint8_t AirTime::getSilentMinutes(float txPercent, float dutyCycle) +{ + Held held(this); + return w.getSilentMinutes(txPercent, dutyCycle, held); +} + +AirTime::AirTime() : concurrency::OSThread("AirTime") {} int32_t AirTime::runOnce() { - syncNow(); + uint32_t rotations; + { + Held held(this); + w.syncNow(held); + rotations = w.rotationsPendingLog; + w.rotationsPendingLog = 0; + } + + // Outside the lock, for the reason logAirtime() gives. Any caller can cross an hour, but only + // this thread reports it, so a crossing raised elsewhere is traced at most one tick late. + if (rotations > 0) { + LOG_DEBUG("Rotate airtimes, crossed %u hour(s)", rotations); + } + return (1000 * 1); } diff --git a/src/airtime.h b/src/airtime.h index 39c1d3e03..b1e1172a7 100644 --- a/src/airtime.h +++ b/src/airtime.h @@ -1,28 +1,79 @@ #pragma once #include "MeshRadio.h" +#include "concurrency/Lock.h" +#include "concurrency/LockGuard.h" #include "concurrency/OSThread.h" #include "configuration.h" #include #include /* - TX_LOG - Time on air this device has transmitted + AirTime records how long the radio was busy and turns that into the two + percentages the transmit gates and DeviceMetrics use. - RX_LOG - Time on air used by valid and routable mesh packets, does not include - TX air time + INPUTS - four events change this class's state: - RX_ALL_LOG - Time of all received lora packets. This includes packets that are not - for meshtastic devices. Does not include TX air time. + logAirtime(TX_LOG, ms) one per completed transmission, ours and relayed + logAirtime(RX_LOG, ms) one per well-formed reception. The interface is + promiscuous: this counts packets not addressed + to us, and every duplicate relay copy. + logAirtime(RX_ALL_LOG, ms) one per reception that could NOT be parsed - + failed CRC, truncated, region unset, collision + elapsed time Time::getUptimeSecs(), read by syncNow() on + every public entry point. The only input that + removes airtime. - Example analytics: + RX_LOG and RX_ALL_LOG are DISJOINT, and a reception logs AT MOST one of them. + RX_ALL_LOG is unparseable airtime, not a superset of RX_LOG, so the total is + TX + RX + RX_ALL - but it under-counts: five drop paths log neither. A packet + with from == 0 returns unlogged from handleReceiveInterrupt(), unlike every + neighbouring drop, and SimRadio drops a collision during transmission plus + three allocation failures. Pre-existing; see the TODO below. - TX_LOG + RX_LOG = Total air time for a particular meshtastic channel. + OUTPUTS: - TX_LOG + RX_ALL_LOG = Total air time for a particular meshtastic channel, including - other lora radios. + channelUtilizationPercent() % of the last 60s busy, all three types + utilizationTXPercent() % of the last hour we transmitted + isTxAllowedChannelUtil() gate on the former, 40% or 25% "polite" + isTxAllowedAirUtil() gate on the latter, at HALF the duty cycle + getSilentMinutes() minutes until the TX figure clears a limit. + Feeds a log line and a client notification; it + gates nothing. + airtimeReport() 8 x 1h of raw ms per type, for the HTTP report + getSecondsSinceBoot() the clock the buckets are keyed to - RX_ALL_LOG - RX_LOG = Other lora radios on our frequency channel. + The three thresholds are hard-coded members with no config binding. + + STORAGE - two orderings, easily confused: + + channelUtilization[], utilizationTX[] + Modular rings indexed by absolute uptime phase, (secs / p) % N. The + index is NOT an age; the oldest bucket is (current + 1) % N. Crossing + into a bucket zeroes it. + + airtimes.period{TX,RX,RX_ALL}[] + Shift-ordered, slot 0 newest, index IS age in hours. Slot 0 is a partial + hour; normalise it by getSecondsSinceBoot() % getSecondsPerPeriod(). + + The percentages measure wall time, not time awake. A light-sleeping node still + hears traffic, and reporting over observed time would make two nodes' + broadcast readings incomparable. + + channelUtilization spans 60s but reaches the mesh at >= 1h cadence, so remote + readings are a snapshot rather than an average. Its contention-window consumer + moves in 20-percentage-point steps, map(chanutil, 0, 100, CWmin, CWmax), so + small errors never reach the backoff. + + Rotation happens on access, not on the scheduler tick: every public method + calls syncNow() first and runOnce() only guarantees once a second. A + scheduler-driven window stops advancing during light sleep. Enforced by + test_channel_utilization_is_independent_of_scheduler_rate. + + TODO: airtime accuracy. Four known defects remain - the quantised denominator, + its sawtooth, whole-packet attribution to the completing bucket, and + getSilentMinutes() reading a modular ring as if the index were an age. Each is + pinned by a test tagged CHARACTERISATION in test/test_airtime. */ #define CHANNEL_UTILIZATION_PERIODS 6 @@ -35,16 +86,42 @@ enum reportTypes { TX_LOG, RX_LOG, RX_ALL_LOG }; -void logAirtime(reportTypes reportType, uint32_t airtime_ms); +// Arms AirTime's nested-take check. Sound only where the lock is not a real lock: the check runs +// before the take, because a nested take blocks forever and a later check would never run - so +// under preemption it would false-positive on legitimate contention and race on its own write. +// Portduino is where it earns its keep anyway; there Lock::lock() is empty, so a nested take +// succeeds silently and nothing else would notice. On an on-target test build the nesting it +// catches shows up as a hang instead. Test builds only: nothing in this tree defines DEBUG or +// NDEBUG, so either spelling would ship an abort() to every board, and nrf52_promicro_diy_tcxo +// has no flash for it. +#if defined(PIO_UNIT_TESTING) && !defined(HAS_FREE_RTOS) +#define AIRTIME_REENTRY_CHECK +#endif -uint32_t *airtimeReport(reportTypes reportType); - -// Not thread-safe: everything but getPeriodsToLog()/getSecondsPerPeriod() either rotates the -// windows via syncNow() or reads the buckets. Current callers are all on the OSThread scheduler - -// RadioLibInterface/SimRadio, RadioInterface, Router, DeviceTelemetry, ContentHandler, and the -// screen renderers. New callers must be on that thread too, or this needs a lock. -// TODO: airtime lock-guarding - serialise the above behind a lock so the contract is enforced -// rather than documented. Kept out of this PR: it is a separate concern from millis() rollover. +// Serialised behind `lock` because two FreeRTOS tasks genuinely reach this class at once on nRF52. +// NRF52Bluetooth registers its ToRadio write callback with defer == false, so a phone's packet runs +// PhoneAPI::handleToRadio -> MeshService::sendToMesh -> Router::send on the Bluefruit BLE task, +// which reads utilizationTXPercent() and getSilentMinutes() while loopTask may be inside +// logAirtime() from a reception. That is an unsynchronised read-modify-write of utilizationTX[] and +// secSinceBoot against a summing read. ESP32 hands BLE work to the main task and does not have it. +// +// Two mechanisms keep it serialised: +// +// - a lock-free inner core (Windows) holds all state and all logic. It has no lock member, and +// must never reach one through the global `airTime` - `airTime->anyPublicMethod()` from inside +// a Windows method would take a second Held and hang, because concurrency::Lock is a +// non-recursive binary semaphore taken with portMAX_DELAY. Nothing does this today; the +// AIRTIME_REENTRY_CHECK assert is the backstop, and it only builds on host test builds. +// - a private Held token takes the lock in its constructor and is the only thing that satisfies a +// core method's `const Held &`, so the lock cannot be forgotten. +// +// Every public method takes the lock exactly once and delegates, with two exceptions: the two +// constexpr accessors below touch no state and take none, and isTxAllowedAirUtil() takes it zero or +// one times, depending on whether the duty-cycle branch is entered at all. Nothing inside locks - +// that includes isTxAllowed*(), which call the core rather than the public accessors. +// +// A new write-path helper belongs to Windows or is a free function, never a method on AirTime: an +// AirTime method locks, and logAirtime() would call it while already holding the lock. class AirTime : private concurrency::OSThread { @@ -55,43 +132,85 @@ class AirTime : private concurrency::OSThread float channelUtilizationPercent(); float utilizationTXPercent(); - float UtilizationPercentTX(); - uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0}; - uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; - + /// Compatibility shim: no caller in the tree, kept for out-of-tree ones. void airtimeRotatePeriod(); - uint8_t getPeriodsToLog(); - uint32_t getSecondsPerPeriod(); + /// Constants, not state: no lock, and usable where a constant expression is required so a + /// caller's buffer and the count it passes to airtimeReport() cannot drift apart. + static constexpr uint8_t getPeriodsToLog() { return PERIODS_TO_LOG; } + static constexpr uint32_t getSecondsPerPeriod() { return SECONDS_PER_PERIOD; } uint32_t getSecondsSinceBoot(); - uint32_t *airtimeReport(reportTypes reportType); + /// Copies `count` buckets into `out`, newest first. Copies rather than returning the array so a + /// caller cannot hold a handle to buckets that every other entry point rotates underneath it. + /// False if `out` is null, `count` exceeds the log depth, or the report type is unknown. + bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count); uint8_t getSilentMinutes(float txPercent, float dutyCycle); bool isTxAllowedChannelUtil(bool polite = false); bool isTxAllowedAirUtil(); private: - bool firstTime = true; - uint8_t lastUtilPeriod = 0; - uint8_t lastUtilPeriodTX = 0; - // Time::getUptimeSecs() as of the last syncNow(); the gap since is what the windows rotate by, - // so they stay correct even if the scheduler was paused by light sleep. - uint32_t secSinceBoot = 0; + concurrency::Lock lock; + +#ifdef AIRTIME_REENTRY_CHECK + // Set for the lifetime of a Held and checked before the lock is taken, so a nested take is + // reported rather than hung at. See the macro's definition for why it is host-only. + bool reentryFlag = false; +#endif + + /// Takes `lock` for its lifetime and doubles as proof that it is held. Only AirTime can + /// construct one, so a core method taking `const Held &` cannot be called without the lock. + /// A bare LockGuard would not do: it proves only that *some* lock is held. + class Held + { + public: + explicit Held(AirTime *a) : owner(armReentryCheck(a)), guard(&a->lock) {} + ~Held(); + Held(const Held &) = delete; + Held &operator=(const Held &) = delete; + + private: + static AirTime *armReentryCheck(AirTime *a); + AirTime *owner; // declared first, so its initialiser runs before the lock is taken + concurrency::LockGuard guard; + }; + + /// All state, all logic, no lock. Cannot take one, so cannot nest. + struct Windows { + bool firstTime = true; + // Time::getUptimeSecs() as of the last syncNow(). The windows rotate by the gap since, so + // they stay correct across a paused scheduler. + uint32_t secSinceBoot = 0; + + // Modular rings: index is absolute phase, (uptime secs / period) % N, never age. + uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0}; // 6 x 10s + uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only + + // Hour crossings rotated but not yet traced. The core cannot log its own rotations: it + // only ever runs under the lock, and DEBUG_PORT.log() blocks on a UART write. runOnce() + // drains this and logs after releasing, so the trace costs the lock nothing. + uint32_t rotationsPendingLog = 0; + + // Shift-ordered, unlike the rings above: slot 0 is the newest hour and the index is age. + struct airtimeStruct { + uint32_t periodTX[PERIODS_TO_LOG] = {0}; // AirTime transmitted + uint32_t periodRX[PERIODS_TO_LOG] = {0}; // AirTime received and repeated (valid mesh packets) + uint32_t periodRX_ALL[PERIODS_TO_LOG] = {0}; // AirTime received regardless of validity. May be noise. + } airtimes; + + void logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &); + float channelUtilizationPercent(const Held &); + float utilizationTXPercent(const Held &); + bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &); + uint8_t getSilentMinutes(float txPercent, float dutyCycle, const Held &); + uint8_t getPeriodUtilMinute(const Held &); + uint8_t getPeriodUtilHour(const Held &); + // Advance rolling airtime windows from monotonic uptime, not from runOnce() calls. + void syncNow(const Held &); + } w; + uint8_t max_channel_util_percent = 40; uint8_t polite_channel_util_percent = 25; uint8_t polite_duty_cycle_percent = 50; // half of Duty Cycle allowance is ok for metadata - struct airtimeStruct { - uint32_t periodTX[PERIODS_TO_LOG]; // AirTime transmitted - uint32_t periodRX[PERIODS_TO_LOG]; // AirTime received and repeated (Only valid mesh packets) - uint32_t periodRX_ALL[PERIODS_TO_LOG]; // AirTime received regardless of valid mesh packet. Could include noise. - uint8_t lastPeriodIndex; - } airtimes; - - uint8_t getPeriodUtilMinute(); - uint8_t getPeriodUtilHour(); - uint8_t currentPeriodIndex(); - // Advance rolling airtime windows from monotonic uptime, not from runOnce() calls. - void syncNow(); - protected: virtual int32_t runOnce() override; }; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 699d2fab5..6f87422f2 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3525,8 +3525,10 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) // last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB). // If the protected cap refuses the favorite, fall back to a heard-now stamp so the // contact still isn't the first eviction victim. - if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) + if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) { + LOG_WARN(PROTECTED_CAP_WARN_FMT, "favorite", contact.node_num, MAX_NUM_NODES - 2); stampContactHeardNow(info); + } } // As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified diff --git a/src/mesh/http/ContentHandler.cpp b/src/mesh/http/ContentHandler.cpp index 95712403e..d6c4904b7 100644 --- a/src/mesh/http/ContentHandler.cpp +++ b/src/mesh/http/ContentHandler.cpp @@ -628,13 +628,18 @@ void handleReport(HTTPRequest *req, HTTPResponse *res) return s; }; - uint32_t *logArray; - logArray = airTime->airtimeReport(TX_LOG); - std::string txLog = arrayFromLog(logArray, airTime->getPeriodsToLog()); - logArray = airTime->airtimeReport(RX_LOG); - std::string rxLog = arrayFromLog(logArray, airTime->getPeriodsToLog()); - logArray = airTime->airtimeReport(RX_ALL_LOG); - std::string rxAllLog = arrayFromLog(logArray, airTime->getPeriodsToLog()); + // One constant sizes the buffer and the count, so they cannot drift. Buffer is per call, so a + // report that fails emits zeros rather than the previous type's data. + constexpr size_t periods = AirTime::getPeriodsToLog(); + auto reportFor = [&](reportTypes reportType) { + uint32_t logArray[periods] = {0}; + (void)airTime->airtimeReport(reportType, logArray, periods); + return arrayFromLog(logArray, (int)periods); + }; + + std::string txLog = reportFor(TX_LOG); + std::string rxLog = reportFor(RX_LOG); + std::string rxAllLog = reportFor(RX_ALL_LOG); String wifiIPString = WiFi.localIP().toString(); std::string wifiIP = wifiIPString.c_str(); diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index eb2084403..865e1c363 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -1,3 +1,4 @@ +#include "UptimeClock.h" #include "configuration.h" #include "mesh/Throttle.h" #include @@ -296,7 +297,7 @@ void preFSBegin() if (!(NRF_POWER->RESETREAS == 0 && NRF_POWER->GPREGRET == NRF52_MAGIC_LFS_IS_CORRUPT)) return; NRF_POWER->GPREGRET = 0; - last_format_ms = millis(); + last_format_ms = Time::getMillis(); formatted_this_boot = true; InternalFS.format(); LOG_INFO("LittleFS format complete; restoring default settings"); @@ -309,8 +310,12 @@ extern "C" void lfs_assert(const char *reason) // minutes after each wrap. if (formatted_this_boot && Throttle::isWithinTimespanMs(last_format_ms, MULTIPLE_CORRUPTION_DELAY_MILLIS)) { RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE); - const long millis_remain = MULTIPLE_CORRUPTION_DELAY_MILLIS - (millis() - last_format_ms); - LOG_WARN("Pausing %d seconds to avoid wear on flash storage", millis_remain / 1000); + // Same clock Throttle just read, and clamped: the check above and a second, later read + // can straddle the backoff, which would wrap the remainder into a ~50-day delay(). + const uint32_t elapsed = Time::getMillis() - last_format_ms; + const uint32_t millis_remain = + elapsed < MULTIPLE_CORRUPTION_DELAY_MILLIS ? MULTIPLE_CORRUPTION_DELAY_MILLIS - elapsed : 0; + LOG_WARN("Pausing %u seconds to avoid wear on flash storage", millis_remain / 1000); delay(millis_remain); } LOG_INFO("Rebooting to format LittleFS"); diff --git a/test/test_airtime/test_main.cpp b/test/test_airtime/test_main.cpp index 97adeac0c..6edab96fb 100644 --- a/test/test_airtime/test_main.cpp +++ b/test/test_airtime/test_main.cpp @@ -6,21 +6,38 @@ // the rotation/decay math on top of that, including across the 32-bit millis() wrap. The wrap cases // therefore step the clock the way the main loop does - advance, then publish. #include "Arduino.h" +#include "MeshRadio.h" +#include "NodeDB.h" #include "TestUtil.h" #include "UptimeClock.h" #include "airtime.h" #include +#include #include +static meshtastic_Config_LoRaConfig_RegionCode savedRegion; +static meshtastic_Config_DeviceConfig_Role savedRole; +static bool savedOverrideDutyCycle; + void setUp(void) { // Absolute uptime assertions (e.g. getSecondsSinceBoot()) must not inherit wraps counted by // an earlier case that moved the test clock backwards via setTestMillis(). Time::resetMonotonicForTests(); + savedRegion = config.lora.region; + savedRole = config.device.role; + savedOverrideDutyCycle = config.lora.override_duty_cycle; } void tearDown(void) { Time::useRealClock(); // don't leak the fake clock into other suites + // Restore the duty-cycle globals here, not at the end of a test body: an assertion aborts the + // body via longjmp and would leak the region into every later case. initRegion() on the way + // out, because getEffectiveDutyCycle() dereferences myRegion. + config.lora.region = savedRegion; + config.device.role = savedRole; + config.lora.override_duty_cycle = savedOverrideDutyCycle; + initRegion(); } // --- first sync / immediate writes --- @@ -32,7 +49,9 @@ void test_logAirtime_writes_into_current_bucket_immediately() a.logAirtime(TX_LOG, 100); - TEST_ASSERT_EQUAL_UINT32(100, a.airtimeReport(TX_LOG)[0]); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(100, report[0]); } void test_getSecondsSinceBoot_tracks_elapsed_time() @@ -55,7 +74,8 @@ void test_period_rotates_after_one_hour() Time::advanceTestMillis(3600u * 1000u); // exactly one SECONDS_PER_PERIOD - uint32_t *report = a.airtimeReport(TX_LOG); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); TEST_ASSERT_EQUAL_UINT32(0, report[0]); // new period starts empty TEST_ASSERT_EQUAL_UINT32(500, report[1]); // old period shifted back one slot } @@ -70,7 +90,8 @@ void test_period_rotates_once_per_hour_crossed_while_asleep() Time::advanceTestMillis(3u * 3600u * 1000u); // 3 hours in one jump - uint32_t *report = a.airtimeReport(TX_LOG); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); TEST_ASSERT_EQUAL_UINT32(200, report[3]); TEST_ASSERT_EQUAL_UINT32(0, report[0]); TEST_ASSERT_EQUAL_UINT32(0, report[1]); @@ -87,7 +108,8 @@ void test_period_history_clears_when_asleep_longer_than_the_whole_log() Time::advanceTestMillis(9u * 3600u * 1000u); // 9 hours > PERIODS_TO_LOG (8) - uint32_t *report = a.airtimeReport(TX_LOG); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); for (uint8_t i = 0; i < a.getPeriodsToLog(); i++) { TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, report[i], "stale history must be cleared, not rotated in"); } @@ -170,11 +192,1014 @@ void test_period_rotation_survives_millis_wrap() Time::advanceTestMillis(3600u * 1000u); // wraps partway through Time::serviceMonotonic(); - uint32_t *report = a.airtimeReport(TX_LOG); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); TEST_ASSERT_EQUAL_UINT32(0, report[0]); TEST_ASSERT_EQUAL_UINT32(777, report[1]); } +// --- report routing: which array each type feeds --- +// +// Asserted through the public API, not the bucket arrays: those are private. + +void test_tx_log_feeds_tx_report_and_tx_utilization() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 6000); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(6000, report[0]); + // TX is the only type that reaches all three stores. + TEST_ASSERT_TRUE(a.utilizationTXPercent() > 0.0f); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +// Duty cycle is about our own transmissions. Counting received airtime here would throttle a node +// for other people's traffic. +void test_rx_log_feeds_rx_report_but_not_tx_utilization() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_LOG, 6000); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(RX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(6000, report[0]); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.utilizationTXPercent()); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +void test_rx_all_log_feeds_only_the_noise_report() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_ALL_LOG, 6000); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(RX_ALL_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(6000, report[0]); + + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_TRUE(a.airtimeReport(RX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.utilizationTXPercent()); +} + +// The shared property: channel utilisation counts all airtime, ours and other people's. +void test_every_report_type_feeds_channel_utilization() +{ + const reportTypes types[] = {TX_LOG, RX_LOG, RX_ALL_LOG}; + for (uint8_t i = 0; i < 3; i++) { + Time::resetMonotonicForTests(); + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(types[i], 6000); + + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 10.0f, a.channelUtilizationPercent(), + "every report type must reach channelUtilization"); + } +} + +void test_report_types_do_not_cross_contaminate() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 111); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(RX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_TRUE(a.airtimeReport(RX_ALL_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(0, report[0]); +} + +// --- airtimeReport() contract --- + +void test_airtimeReport_rejects_a_null_buffer() +{ + Time::setTestMillis(0); + AirTime a; + + TEST_ASSERT_FALSE(a.airtimeReport(TX_LOG, nullptr, PERIODS_TO_LOG)); +} + +void test_airtimeReport_rejects_a_count_above_the_log_depth() +{ + Time::setTestMillis(0); + AirTime a; + + uint32_t report[PERIODS_TO_LOG + 1] = {0}; + TEST_ASSERT_FALSE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG + 1)); +} + +void test_airtimeReport_accepts_a_partial_count() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 42); + + const uint32_t sentinel = 0xDEADBEEFu; + uint32_t report[PERIODS_TO_LOG]; + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + report[i] = sentinel; + + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, 2)); + + TEST_ASSERT_EQUAL_UINT32(42, report[0]); + TEST_ASSERT_EQUAL_UINT32(0, report[1]); + for (uint8_t i = 2; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32_MESSAGE(sentinel, report[i], "a partial count must not write past it"); +} + +void test_airtimeReport_rejects_an_unknown_report_type() +{ + Time::setTestMillis(0); + AirTime a; + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_FALSE(a.airtimeReport(static_cast(99), report, PERIODS_TO_LOG)); +} + +// The regression guard for the copy-out: if anyone reintroduces the array-returning form, the +// caller's buffer starts tracking the live buckets and this fails. +void test_airtimeReport_returns_a_snapshot_not_an_alias() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 100); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(100, report[0]); + + a.logAirtime(TX_LOG, 900); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(100, report[0], "the copy must not follow the live bucket"); +} + +// --- storage conventions --- +// +// Two orderings: the report arrays are shift-ordered (slot 0 newest); channelUtilization and +// utilizationTX are modular rings indexed by uptime phase. Reading one as the other is a defect. + +void test_report_arrays_are_shift_ordered_slot_zero_newest() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 100); // oldest + Time::advanceTestMillis(3600u * 1000u); + a.logAirtime(TX_LOG, 200); + Time::advanceTestMillis(3600u * 1000u); + a.logAirtime(TX_LOG, 300); // newest + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(300, report[0], "slot 0 is the newest hour"); + TEST_ASSERT_EQUAL_UINT32(200, report[1]); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(100, report[2], "index is age in hours, not ring phase"); +} + +// Slot 0 covers only the time since the last rotation; treating it as a whole hour under-reports. +// getSecondsSinceBoot() % getSecondsPerPeriod() recovers the elapsed part. +void test_report_slot_zero_is_a_partial_hour() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 100); + + Time::advanceTestMillis(3600u * 1000u); // rotate; slot 0 is now brand new + Time::advanceTestMillis(120u * 1000u); // and 120s into its hour + a.logAirtime(TX_LOG, 250); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(250, report[0], "slot 0 holds only airtime since the boundary"); + TEST_ASSERT_EQUAL_UINT32(100, report[1]); + + const uint32_t elapsedInSlotZero = a.getSecondsSinceBoot() % a.getSecondsPerPeriod(); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(120, elapsedInSlotZero, "the partial-hour phase must be recoverable"); +} + +// --- first sync and seeding --- + +// The firstTime branch seeds secSinceBoot from the clock; seeding 0 would rotate 500s of empty +// windows through on first access. +void test_first_sync_seeds_from_current_uptime_not_zero() +{ + Time::setTestMillis(500u * 1000u); + AirTime a; + + TEST_ASSERT_EQUAL_UINT32(500, a.getSecondsSinceBoot()); + + a.logAirtime(RX_LOG, 6000); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 10.0f, a.channelUtilizationPercent(), + "no phantom decay from the pre-construction uptime"); +} + +void test_first_sync_zeroes_every_window() +{ + Time::setTestMillis(1234u * 1000u); + AirTime a; + + uint32_t report[PERIODS_TO_LOG] = {0}; + const reportTypes types[] = {TX_LOG, RX_LOG, RX_ALL_LOG}; + for (uint8_t t = 0; t < 3; t++) { + TEST_ASSERT_TRUE(a.airtimeReport(types[t], report, PERIODS_TO_LOG)); + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32(0, report[i]); + } + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.channelUtilizationPercent()); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.utilizationTXPercent()); +} + +void test_late_construction_does_not_backdate_airtime() +{ + Time::setTestMillis(7200u * 1000u); // two hours of uptime before AirTime exists + AirTime a; + + a.logAirtime(TX_LOG, 400); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(400, report[0], "airtime belongs to the current bucket, not a backdated one"); + for (uint8_t i = 1; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32(0, report[i]); +} + +// --- sync idempotency --- + +void test_repeated_sync_within_one_second_does_not_rotate() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 6000); + + Time::advanceTestMillis(500); // sub-second: the nowSecs == secSinceBoot early return + for (uint8_t i = 0; i < 5; i++) { + (void)a.channelUtilizationPercent(); + (void)a.getSecondsSinceBoot(); + } + + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +// Every public entry point syncs. Calling several in the same interval must not compound the +// rotation: two instances see identical wall time and airtime, differing only in how many entry +// points were called. +void test_rotation_is_once_per_second_regardless_of_entry_point() +{ + Time::setTestMillis(0); + AirTime oneEntryPoint; + AirTime everyEntryPoint; + + oneEntryPoint.logAirtime(RX_LOG, 6000); + everyEntryPoint.logAirtime(RX_LOG, 6000); + + Time::advanceTestMillis(20u * 1000u); // two 10s buckets crossed + + uint32_t scratch[PERIODS_TO_LOG] = {0}; + (void)everyEntryPoint.getSecondsSinceBoot(); + (void)everyEntryPoint.utilizationTXPercent(); + everyEntryPoint.airtimeRotatePeriod(); + (void)everyEntryPoint.airtimeReport(TX_LOG, scratch, PERIODS_TO_LOG); + (void)everyEntryPoint.isTxAllowedChannelUtil(); + + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, oneEntryPoint.channelUtilizationPercent(), + everyEntryPoint.channelUtilizationPercent(), + "rotation must be driven by the clock, not by the call count"); +} + +void test_period_constants_are_stable() +{ + Time::setTestMillis(0); + AirTime a; + + // Public API: ContentHandler sizes its buffer from getPeriodsToLog(). + TEST_ASSERT_EQUAL_UINT8(8, a.getPeriodsToLog()); + TEST_ASSERT_EQUAL_UINT32(3600, a.getSecondsPerPeriod()); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(PERIODS_TO_LOG, a.getPeriodsToLog(), "the accessor and the macro must agree"); +} + +// ============================================================================ +// Window decay, gates, and sleep behaviour. Three kinds of test: +// +// invariant - must hold now and forever; any failure is a bug +// boundary - pins an off-by-one a refactor would silently move +// CHARACTERISATION - encodes today's wrong number. Replace it when the defect +// it describes is fixed; the tag is greppable. +// ============================================================================ + +// --- the oracle ------------------------------------------------------------- +// +// The definition the buckets approximate: airtime physically on air inside +// (now - window, now]. Assert against this rather than hand-worked constants. +// A packet is stamped with its END time, as completeSending() has it; the +// start is end - airtime. + +struct AirtimeEvent { + uint64_t endMs; + uint32_t airtimeMs; +}; + +static float expectedUtilisation(const AirtimeEvent *ev, size_t n, uint64_t nowMs, uint32_t windowMs) +{ + const uint64_t lo = (nowMs > windowMs) ? (nowMs - windowMs) : 0; + uint64_t busy = 0; + for (size_t i = 0; i < n; i++) { + const uint64_t start = (ev[i].airtimeMs < ev[i].endMs) ? (ev[i].endMs - ev[i].airtimeMs) : 0; + const uint64_t from = start > lo ? start : lo; + const uint64_t to = ev[i].endMs < nowMs ? ev[i].endMs : nowMs; + if (to > from) + busy += (to - from); + } + return (float)busy / (float)windowMs * 100.0f; +} + +// Steady load helper: logs `msPerSecond` of airtime once a second for `seconds`, +// leaving the clock exactly `seconds` later than it started. +static void logEverySecond(AirTime &a, uint32_t seconds, uint32_t msPerSecond, reportTypes type = RX_LOG) +{ + for (uint32_t i = 0; i < seconds; i++) { + a.logAirtime(type, msPerSecond); + Time::advanceTestMillis(1000); + } +} + +static char g_msg[160]; // Unity messages must outlive the assert + +// --- hourly period rotation: boundaries the first three tests miss ----------- + +// The shift loop runs PERIODS_TO_LOG-2 -> 0; an off-by-one resurrects hour-old +// data into slot 0 instead of dropping it. +void test_oldest_period_falls_off_the_end() +{ + Time::setTestMillis(0); + AirTime a; + + for (uint32_t h = 0; h < PERIODS_TO_LOG; h++) { + a.logAirtime(TX_LOG, (h + 1) * 100); + Time::advanceTestMillis(3600u * 1000u); + } + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + // Slot 0 is the (empty) current hour; 800 was the newest logged, 100 the oldest. + TEST_ASSERT_EQUAL_UINT32(0, report[0]); + TEST_ASSERT_EQUAL_UINT32(800, report[1]); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(200, report[7], "the oldest survivor sits in the last slot"); + + Time::advanceTestMillis(3600u * 1000u); + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(300, report[7], "one more hour drops 200 off the end"); + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_NOT_EQUAL_UINT32_MESSAGE(200, report[i], "dropped data must not wrap back in"); +} + +void test_period_boundary_is_exact_at_one_hour() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 500); + + Time::advanceTestMillis(3599u * 1000u); + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(500, report[0], "3599s must not rotate"); + + Time::advanceTestMillis(1000); + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, report[0], "3600s rotates exactly once"); + TEST_ASSERT_EQUAL_UINT32(500, report[1]); +} + +// The >= is the seam between "rotate N times" and "wipe the lot". +void test_period_clear_boundary_is_exactly_the_log_depth() +{ + { + Time::setTestMillis(0); + AirTime shift; + shift.logAirtime(TX_LOG, 500); + Time::advanceTestMillis(7u * 3600u * 1000u); // 7 h: shift branch + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(shift.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(500, report[7], "7h shifts to the last slot"); + } + { + Time::resetMonotonicForTests(); + Time::setTestMillis(0); + AirTime wipe; + wipe.logAirtime(TX_LOG, 500); + Time::advanceTestMillis(8u * 3600u * 1000u); // 8 h: memset branch + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(wipe.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, report[i], "8h wipes rather than rotating"); + } +} + +// --- channelUtilization: the 6 x 10 s modular ring -------------------------- + +// Airtime ages out oldest-first. The ring's index is absolute uptime phase, so +// the oldest bucket is (current + 1) % N, never index N-1 - the assumption +// getSilentMinutes() wrongly makes about the other ring. Stated as a property +// so it holds at any geometry. +void test_channel_utilization_ages_out_oldest_first() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_LOG, 6000); // A: 10% of the window + Time::advanceTestMillis(15u * 1000u); + a.logAirtime(RX_LOG, 3000); // B: 5%, logged later, must outlive A + + bool sawBOnly = false; + for (uint32_t t = 16; t <= 120; t++) { + Time::advanceTestMillis(1000); + const float pct = a.channelUtilizationPercent(); + // "A alone" would be 10% with B already gone: that is out-of-order ageing. + TEST_ASSERT_FALSE_MESSAGE(pct > 9.0f && pct < 11.0f && sawBOnly, "A must not outlive B"); + if (pct > 4.0f && pct < 6.0f) + sawBOnly = true; + } + TEST_ASSERT_TRUE_MESSAGE(sawBOnly, "there must be a window where only the newer airtime remains"); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.channelUtilizationPercent()); +} + +void test_channel_utilization_clears_only_the_buckets_crossed() +{ + Time::setTestMillis(0); + AirTime a; + + // One distinct value per 10 s bucket: 1000, 2000, ... 6000 ms. + for (uint32_t b = 0; b < 6; b++) { + a.logAirtime(RX_LOG, (b + 1) * 1000); + Time::advanceTestMillis(10u * 1000u); + } + // t = 60 s: bucket 0 has just been cleared, so 1000 is already gone. + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, (2000 + 3000 + 4000 + 5000 + 6000) / 600.0f, a.channelUtilizationPercent(), + "entering a bucket clears exactly that bucket"); + + Time::advanceTestMillis(20u * 1000u); // crosses two more + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, (4000 + 5000 + 6000) / 600.0f, a.channelUtilizationPercent(), + "20s must clear exactly two buckets, oldest first"); +} + +void test_channel_utilization_clear_boundary_is_exactly_six_periods() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 6000); + + Time::advanceTestMillis(59u * 1000u); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 10.0f, a.channelUtilizationPercent(), "59s: still inside the window"); + + Time::advanceTestMillis(1000); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 0.0f, a.channelUtilizationPercent(), "60s: the bucket is reused"); +} + +void test_channel_utilization_is_zero_when_nothing_logged() +{ + Time::setTestMillis(0); + AirTime a; + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.channelUtilizationPercent()); + Time::advanceTestMillis(3600u * 1000u); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.channelUtilizationPercent()); +} + +void test_channel_utilization_decays_proportionally_across_light_sleep() +{ + Time::setTestMillis(0); + AirTime a; + AirtimeEvent ev[6]; + for (uint32_t b = 0; b < 6; b++) { + a.logAirtime(RX_LOG, 1000); + ev[b].endMs = (uint64_t)b * 10000u; + ev[b].airtimeMs = 1000; + Time::advanceTestMillis(10u * 1000u); + } + const float full = a.channelUtilizationPercent(); + TEST_ASSERT_TRUE(full > 0.0f); + + Time::advanceTestMillis(30u * 1000u); // asleep: not one call for half the window + + const float after = a.channelUtilizationPercent(); + const float truth = expectedUtilisation(ev, 6, 90000, 60000); + snprintf(g_msg, sizeof(g_msg), "before %.4f%%, after a 30s gap %.4f%%, oracle %.4f%%", full, after, truth); + TEST_ASSERT_TRUE_MESSAGE(after < full, g_msg); + // Whole buckets shed, so the survivors are exactly what was still on air in + // the last 60s. + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, truth, after, g_msg); +} + +// Hold wall time and airtime fixed, vary only how often the class is polled, +// and assert the answer does not move. Fails if rotation moves back into +// runOnce() only. +void test_channel_utilization_is_independent_of_scheduler_rate() +{ + Time::setTestMillis(0); + AirTime polledOften; + AirTime polledOnce; + + for (uint32_t s = 0; s < 45; s++) { + polledOften.logAirtime(RX_LOG, 200); + polledOnce.logAirtime(RX_LOG, 200); + Time::advanceTestMillis(1000); + (void)polledOften.channelUtilizationPercent(); // once a second + } + + snprintf(g_msg, sizeof(g_msg), "polled 45x: %.4f%%, polled once: %.4f%%", polledOften.channelUtilizationPercent(), + polledOnce.channelUtilizationPercent()); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, polledOnce.channelUtilizationPercent(), polledOften.channelUtilizationPercent(), + g_msg); +} + +// A percentage of a fixed window cannot exceed 100. Holds for every preset +// whose packets fit inside a bucket; LONG_SLOW is characterised below. +void test_channel_utilization_never_exceeds_100_percent() +{ + Time::setTestMillis(0); + AirTime a; + + float peak = 0.0f; + for (uint32_t s = 0; s < 200; s++) { + a.logAirtime(RX_LOG, 1000); // a fully saturated channel: 1000ms of airtime per second + Time::advanceTestMillis(1000); + const float pct = a.channelUtilizationPercent(); + if (pct > peak) + peak = pct; + } + snprintf(g_msg, sizeof(g_msg), "peak reading was %.4f%%", peak); + TEST_ASSERT_TRUE_MESSAGE(peak <= 100.01f, g_msg); +} + +void test_channel_utilization_counts_each_packet_once() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 1000); + a.logAirtime(RX_LOG, 2000); + a.logAirtime(RX_ALL_LOG, 3000); + + // 6000ms of the 60s window, counted once each. + TEST_ASSERT_FLOAT_WITHIN(0.01f, 10.0f, a.channelUtilizationPercent()); +} + +// CHARACTERISATION. The current bucket is zeroed on entry and fills across its +// period, so the window covers (N-1)p + phase against a denominator of Np - +// right after a boundary, 50s of coverage divided by 60s. +void test_channel_utilization_covers_less_than_its_denominator() +{ + Time::setTestMillis(0); + AirTime a; + + AirtimeEvent ev[61]; + size_t n = 0; + for (uint32_t s = 0; s < 60; s++) { + a.logAirtime(RX_LOG, 100); + ev[n].endMs = (uint64_t)s * 1000; + ev[n].airtimeMs = 100; + n++; + Time::advanceTestMillis(1000); + } + // t = 60 000 ms, phase 0: the bucket holding t=0..9 has just been reused. + const float truth = expectedUtilisation(ev, n, 60000, 60000); + const float reported = a.channelUtilizationPercent(); + + snprintf(g_msg, sizeof(g_msg), "oracle %.4f%%, reported %.4f%% (deficit %.4f pp)", truth, reported, truth - reported); + TEST_ASSERT_TRUE_MESSAGE(truth > 9.5f, g_msg); // a steady 10% load, less the event on the window edge + TEST_ASSERT_TRUE_MESSAGE(reported < truth - 1.0f, g_msg); +} + +// CHARACTERISATION. The same defect numerically: under a steady load the +// reading sweeps with position inside the current bucket instead of holding. +void test_channel_utilization_quantisation_error_by_phase() +{ + Time::setTestMillis(0); + AirTime a; + for (uint32_t s = 0; s < 60; s++) { + a.logAirtime(RX_LOG, 100); + Time::advanceTestMillis(1000); + } + + float lo = 1000.0f, hi = 0.0f; + for (uint32_t s = 0; s < 10; s++) { // one full bucket period of phases + const float pct = a.channelUtilizationPercent(); + if (pct < lo) + lo = pct; + if (pct > hi) + hi = pct; + a.logAirtime(RX_LOG, 100); + Time::advanceTestMillis(1000); + } + + snprintf(g_msg, sizeof(g_msg), "steady 10%% load reads %.4f%%..%.4f%% across bucket phase", lo, hi); + TEST_ASSERT_TRUE_MESSAGE(lo < 9.0f, g_msg); // under-reports at the start of a bucket + TEST_ASSERT_TRUE_MESSAGE(hi > 9.5f, g_msg); // recovers by the end of it + TEST_ASSERT_TRUE_MESSAGE(hi - lo > 1.0f, g_msg); // and the sawtooth is the jitter defect +} + +// CHARACTERISATION. A packet's whole airtime is credited to the bucket it +// completed in, so a bucket can hold more than its own period. LONG_SLOW at max +// payload is 14 164 ms against a 10 s bucket. +void test_channel_utilization_exceeds_100_percent_on_long_slow() +{ + Time::setTestMillis(0); + AirTime a; + + const uint32_t LONG_SLOW_MAX_MS = 14164; + float peak = 0.0f; + for (uint32_t i = 0; i < 40; i++) { + Time::advanceTestMillis(LONG_SLOW_MAX_MS); // back-to-back: the channel is 100% busy + a.logAirtime(RX_LOG, LONG_SLOW_MAX_MS); + const float pct = a.channelUtilizationPercent(); + if (pct > peak) + peak = pct; + } + + snprintf(g_msg, sizeof(g_msg), "true occupancy 100%%, peak reading %.4f%%", peak); + TEST_ASSERT_TRUE_MESSAGE(peak > 100.0f, g_msg); +} + +// --- utilizationTX: the 60 x 60 s modular ring ------------------------------ + +void test_tx_utilization_ages_out_oldest_first() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(TX_LOG, 60000); // A + Time::advanceTestMillis(15u * 60u * 1000u); + a.logAirtime(TX_LOG, 30000); // B, newer and smaller + + bool sawBOnly = false; + for (uint32_t m = 16; m <= 120; m++) { + Time::advanceTestMillis(60u * 1000u); + const float pct = a.utilizationTXPercent(); + const float bOnly = 30000.0f / (60.0f * 60.0f * 1000.0f) * 100.0f; + TEST_ASSERT_FALSE_MESSAGE(sawBOnly && pct > bOnly * 1.5f, "A must not outlive B"); + if (pct > bOnly * 0.9f && pct < bOnly * 1.1f) + sawBOnly = true; + } + TEST_ASSERT_TRUE_MESSAGE(sawBOnly, "there must be a window where only the newer airtime remains"); +} + +void test_tx_utilization_clears_only_the_minutes_crossed() +{ + Time::setTestMillis(0); + AirTime a; + for (uint32_t m = 0; m < 4; m++) { + a.logAirtime(TX_LOG, (m + 1) * 1000); + Time::advanceTestMillis(60u * 1000u); + } + const float all = (1000 + 2000 + 3000 + 4000) / (float)MS_IN_HOUR * 100.0f; + TEST_ASSERT_FLOAT_WITHIN(0.001f, all, a.utilizationTXPercent()); + + Time::advanceTestMillis(56u * 60u * 1000u); // t = 60 min: the first minute-bucket is reused + const float withoutFirst = (2000 + 3000 + 4000) / (float)MS_IN_HOUR * 100.0f; + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.001f, withoutFirst, a.utilizationTXPercent(), + "only the crossed minute buckets are cleared"); +} + +void test_tx_utilization_clear_boundary_is_exactly_sixty_minutes() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 36000); + + Time::advanceTestMillis(59u * 60u * 1000u); + TEST_ASSERT_TRUE_MESSAGE(a.utilizationTXPercent() > 0.0f, "59 min: still inside the hour"); + + Time::advanceTestMillis(60u * 1000u); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.0001f, 0.0f, a.utilizationTXPercent(), "60 min: the bucket is reused"); +} + +void test_tx_utilization_counts_only_transmissions() +{ + Time::setTestMillis(0); + AirTime a; + + a.logAirtime(RX_LOG, MS_IN_HOUR / 2); + a.logAirtime(RX_ALL_LOG, MS_IN_HOUR / 2); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.0001f, 0.0f, a.utilizationTXPercent(), + "received airtime must never reach the duty-cycle figure"); + + a.logAirtime(TX_LOG, 36000); + TEST_ASSERT_TRUE(a.utilizationTXPercent() > 0.0f); +} + +// CHARACTERISATION. The same quantisation defect on the hour window: 10x +// smaller because N is 60 rather than 6, but not zero. +void test_tx_utilization_quantisation_error() +{ + Time::setTestMillis(0); + AirTime a; + for (uint32_t m = 0; m < 60; m++) { + a.logAirtime(TX_LOG, 1000); + Time::advanceTestMillis(60u * 1000u); + } + // 60 000 ms of TX in the hour just elapsed = 1.6667% true. + const float truth = 60000.0f / (float)MS_IN_HOUR * 100.0f; + const float reported = a.utilizationTXPercent(); + + snprintf(g_msg, sizeof(g_msg), "true %.4f%%, reported %.4f%%", truth, reported); + TEST_ASSERT_TRUE_MESSAGE(reported < truth, g_msg); + TEST_ASSERT_TRUE_MESSAGE(reported > truth * 0.95f, g_msg); // ~1/60, not gross +} + +// --- TX gates ---------------------------------------------------------------- + +void test_isTxAllowedChannelUtil_polite_threshold_is_lower() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 18000); // 30% of the 60s window + + TEST_ASSERT_TRUE_MESSAGE(a.isTxAllowedChannelUtil(false), "30% is under the 40% default"); + TEST_ASSERT_FALSE_MESSAGE(a.isTxAllowedChannelUtil(true), "30% is over the 25% polite limit"); +} + +// The compare is `< percentage`, so exactly the threshold must block. +void test_isTxAllowedChannelUtil_boundary_is_exclusive() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(RX_LOG, 24000); // exactly 40.0% + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 40.0f, a.channelUtilizationPercent()); + TEST_ASSERT_FALSE_MESSAGE(a.isTxAllowedChannelUtil(false), "exactly 40.0% must block, not allow"); +} + +void test_isTxAllowedAirUtil_allows_when_override_is_set() +{ + Time::setTestMillis(0); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866; + config.lora.override_duty_cycle = true; + initRegion(); + AirTime a; + a.logAirtime(TX_LOG, MS_IN_HOUR); // 100% TX utilisation + + TEST_ASSERT_TRUE(a.isTxAllowedAirUtil()); + config.lora.override_duty_cycle = false; +} + +void test_isTxAllowedAirUtil_allows_when_the_region_is_unlimited() +{ + Time::setTestMillis(0); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.override_duty_cycle = false; + initRegion(); + AirTime a; + a.logAirtime(TX_LOG, MS_IN_HOUR); + + TEST_ASSERT_TRUE_MESSAGE(getEffectiveDutyCycle() >= 100.0f, "US has no duty cycle limit"); + TEST_ASSERT_TRUE(a.isTxAllowedAirUtil()); +} + +// The polite gate is half the allowance, not the whole of it. +void test_isTxAllowedAirUtil_blocks_at_half_the_duty_cycle() +{ + Time::setTestMillis(0); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866; + config.lora.override_duty_cycle = false; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + initRegion(); + const float duty = getEffectiveDutyCycle(); // 2.5% for a non-router on EU_866 + TEST_ASSERT_FLOAT_WITHIN(0.01f, 2.5f, duty); + + AirTime a; + // 40% of the allowance: under half, so still allowed. + a.logAirtime(TX_LOG, (uint32_t)(MS_IN_HOUR * duty / 100.0f * 0.40f)); + TEST_ASSERT_TRUE_MESSAGE(a.isTxAllowedAirUtil(), "40% of the allowance is under the polite half"); + + // Push past half. + a.logAirtime(TX_LOG, (uint32_t)(MS_IN_HOUR * duty / 100.0f * 0.30f)); + TEST_ASSERT_FALSE_MESSAGE(a.isTxAllowedAirUtil(), "70% of the allowance is over the polite half"); +} + +// Two thresholds ride on one figure: isTxAllowedAirUtil() is polite at half the +// duty cycle, while Router::send() aborts only at the whole of it. There is a +// band where the polite gate blocks and the hard gate would not - pinning it +// here means an accuracy change has to be evaluated against both. +void test_router_send_gate_uses_the_whole_duty_cycle() +{ + Time::setTestMillis(0); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866; + config.lora.override_duty_cycle = false; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + initRegion(); + const float duty = getEffectiveDutyCycle(); + + AirTime a; + a.logAirtime(TX_LOG, (uint32_t)(MS_IN_HOUR * duty / 100.0f * 0.70f)); // 70% of the allowance + + TEST_ASSERT_FALSE_MESSAGE(a.isTxAllowedAirUtil(), "the polite gate blocks at 70% of the allowance"); + TEST_ASSERT_TRUE_MESSAGE(a.utilizationTXPercent() < duty, + "...while the figure is still under the whole duty cycle Router::send() uses"); +} + +// getEffectiveDutyCycle() special-cases EU_866 by role. Every other region - +// including EU_868, one digit away - takes the generic myRegion->dutyCycle path. +void test_effective_duty_cycle_special_case_is_eu_866_only() +{ + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866; + initRegion(); + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + const float eu866Client = getEffectiveDutyCycle(); + config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + const float eu866Router = getEffectiveDutyCycle(); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 2.5f, eu866Client); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 10.0f, eu866Router, "EU_866 is role-dependent"); + + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + initRegion(); + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + const float eu868Client = getEffectiveDutyCycle(); + config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + const float eu868Router = getEffectiveDutyCycle(); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, eu868Client, eu868Router, "EU_868 must NOT be role-dependent"); + + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; +} + +// --- getSilentMinutes() ------------------------------------------------------ + +void test_getSilentMinutes_returns_zero_when_already_under_the_limit() +{ + Time::setTestMillis(0); + AirTime a; + TEST_ASSERT_EQUAL_UINT8(0, a.getSilentMinutes(1.0f, 2.5f)); +} + +void test_getSilentMinutes_returns_a_full_hour_when_nothing_ages_out() +{ + Time::setTestMillis(0); + AirTime a; // empty ring, but told we are over the limit + TEST_ASSERT_EQUAL_UINT8_MESSAGE(60, a.getSilentMinutes(10.0f, 2.5f), "nothing to age out means the full hour"); +} + +void test_getSilentMinutes_counts_minutes_until_enough_ages_out() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 120000); // two minutes of TX, all of it in minute-bucket 0 + const float pct = a.utilizationTXPercent(); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 3.3333f, pct); + + // Fully determined: the walk subtracts nothing for i in 59..1, then the whole 3.3333% at i == 0, + // returning MINUTES_IN_HOUR - 1 - 0. That answer is one minute short of the truth - syncNow() + // clears bucket 0 at minute 60, not 59 - which test_getSilentMinutes_depends_on_ring_phase pins. + const uint8_t mins = a.getSilentMinutes(pct, 2.5f); + TEST_ASSERT_EQUAL_UINT8(59, mins); +} + +// CHARACTERISATION. getSilentMinutes() walks utilizationTX from index 59 down +// to 0 and returns 59 - i, treating the index as an age. That is the report +// array's convention; utilizationTX is a modular ring indexed by minute phase, +// so identical airtime gives different answers at different phases. +void test_getSilentMinutes_depends_on_ring_phase() +{ + uint8_t answers[6] = {0}; + float pcts[6] = {0}; + for (uint8_t i = 0; i < 6; i++) { + Time::resetMonotonicForTests(); + Time::setTestMillis((uint32_t)i * 10u * 60u * 1000u); // 0, 10, 20... minutes of uptime + AirTime a; + a.logAirtime(TX_LOG, 120000); + pcts[i] = a.utilizationTXPercent(); + answers[i] = a.getSilentMinutes(pcts[i], 2.5f); + } + + for (uint8_t i = 1; i < 6; i++) + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.0001f, pcts[0], pcts[i], "the inputs must be identical"); + + bool varies = false; + for (uint8_t i = 1; i < 6; i++) + if (answers[i] != answers[0]) + varies = true; + + snprintf(g_msg, sizeof(g_msg), "same airtime, answers by phase: %u %u %u %u %u %u", answers[0], answers[1], answers[2], + answers[3], answers[4], answers[5]); + TEST_ASSERT_TRUE_MESSAGE(varies, g_msg); +} + +// --- clock robustness --------------------------------------------------------- + +// A gap longer than the window that also crosses the 49.7-day millis() wrap. +void test_survives_heavy_sleep_across_the_wrap() +{ + const uint32_t beforeWrap = 0xFFFFFFFFu - (30u * 1000u); + Time::setTestMillis(beforeWrap); + Time::serviceMonotonic(); + AirTime a; + a.logAirtime(RX_LOG, 6000); + TEST_ASSERT_TRUE(a.channelUtilizationPercent() > 0.0f); + + Time::advanceTestMillis(120u * 1000u); // wraps, and outlasts the 60s window + Time::serviceMonotonic(); + + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, 0.0f, a.channelUtilizationPercent(), + "a window that outlasts its span must be empty, wrap or not"); +} + +void test_multi_day_sleep_clears_every_window() +{ + Time::setTestMillis(0); + AirTime a; + a.logAirtime(TX_LOG, 6000); + a.logAirtime(RX_LOG, 6000); + a.logAirtime(RX_ALL_LOG, 6000); + + Time::advanceTestMillis(3u * 24u * 3600u * 1000u); // three days + Time::serviceMonotonic(); + + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.channelUtilizationPercent()); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0f, a.utilizationTXPercent()); + uint32_t report[PERIODS_TO_LOG] = {0}; + const reportTypes types[] = {TX_LOG, RX_LOG, RX_ALL_LOG}; + for (uint8_t t = 0; t < 3; t++) { + TEST_ASSERT_TRUE(a.airtimeReport(types[t], report, PERIODS_TO_LOG)); + for (uint8_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32(0, report[i]); + } +} + +// getUptimeSecs() is monotonic by construction. If it ever stops being, the +// elapsed calculation underflows to a huge value, which trips every >= branch +// and clears the windows. Benign, and pinned so a swap back to bare millis() +// fails loudly rather than corrupting buckets. +void test_backwards_uptime_degrades_safely() +{ + // Step by the wrap, which is the size the regression would actually produce: uptime falls from + // 4294967s to 0. A smaller backwards step leaves elapsedAirtimePeriods at 0, so the hourly + // report below is never reached - which is what this case used to miss. + Time::setTestMillis(UINT32_MAX); + AirTime a; + a.logAirtime(TX_LOG, 6000); + TEST_ASSERT_TRUE(a.channelUtilizationPercent() > 0.0f); + + Time::setTestMillis(0); // the wrap, as a naive millis() clock would present it + + const float pct = a.channelUtilizationPercent(); + snprintf(g_msg, sizeof(g_msg), "channel utilisation after the wrap: %.4f%%", pct); + TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.0001f, 0.0f, pct, g_msg); + + uint32_t report[PERIODS_TO_LOG] = {0}; + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + for (uint32_t i = 0; i < PERIODS_TO_LOG; i++) + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, report[i], "every hourly bucket clears across the wrap"); +} + +// --- the lock ---------------------------------------------------------------------------------- + +// No single public method may take the lock twice: a second Held on the same instance trips the +// re-entry assert. The calls below are sequential and each Held is destroyed before the next, so +// this catches a method re-entering itself, not two methods nesting. That is the regression guard +// for isTxAllowedChannelUtil() regaining its pre-split shape. Two of the methods called take no +// lock at all. Portduino compiles Lock::lock() to an empty body, so the assert is the only check +// that works natively; on hardware the same bug is a deadlock. +void test_no_public_method_takes_the_lock_twice() +{ + Time::setTestMillis(0); + // EU_868 explicitly, not inherited: isTxAllowedAirUtil() constructs a Held only inside its + // duty-cycle branch, so under the default US region (100%) it would return before locking and + // this test would not cover it at all. + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.override_duty_cycle = false; + initRegion(); + + AirTime a; + uint32_t report[PERIODS_TO_LOG] = {0}; + + a.logAirtime(TX_LOG, 100); + a.logAirtime(RX_LOG, 100); + a.logAirtime(RX_ALL_LOG, 100); + (void)a.channelUtilizationPercent(); + (void)a.utilizationTXPercent(); + a.airtimeRotatePeriod(); + (void)a.getPeriodsToLog(); + (void)a.getSecondsPerPeriod(); + (void)a.getSecondsSinceBoot(); + (void)a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG); + (void)a.getSilentMinutes(10.0f, 2.5f); + (void)a.isTxAllowedChannelUtil(false); + (void)a.isTxAllowedChannelUtil(true); + (void)a.isTxAllowedAirUtil(); + + // Reaching here without the assert firing IS the assertion; check the object still works. + TEST_ASSERT_TRUE(a.airtimeReport(TX_LOG, report, PERIODS_TO_LOG)); + TEST_ASSERT_EQUAL_UINT32(100, report[0]); +} + void setup() { initializeTestEnvironment(); @@ -190,6 +1215,66 @@ void setup() RUN_TEST(test_tx_utilization_decays_once_the_60_minute_window_passes); RUN_TEST(test_syncNow_survives_millis_wrap); RUN_TEST(test_period_rotation_survives_millis_wrap); + + // report routing + RUN_TEST(test_tx_log_feeds_tx_report_and_tx_utilization); + RUN_TEST(test_rx_log_feeds_rx_report_but_not_tx_utilization); + RUN_TEST(test_rx_all_log_feeds_only_the_noise_report); + RUN_TEST(test_every_report_type_feeds_channel_utilization); + RUN_TEST(test_report_types_do_not_cross_contaminate); + // airtimeReport() contract + RUN_TEST(test_airtimeReport_rejects_a_null_buffer); + RUN_TEST(test_airtimeReport_rejects_a_count_above_the_log_depth); + RUN_TEST(test_airtimeReport_accepts_a_partial_count); + RUN_TEST(test_airtimeReport_rejects_an_unknown_report_type); + RUN_TEST(test_airtimeReport_returns_a_snapshot_not_an_alias); + // storage conventions + RUN_TEST(test_report_arrays_are_shift_ordered_slot_zero_newest); + RUN_TEST(test_report_slot_zero_is_a_partial_hour); + // first sync and seeding + RUN_TEST(test_first_sync_seeds_from_current_uptime_not_zero); + RUN_TEST(test_first_sync_zeroes_every_window); + RUN_TEST(test_late_construction_does_not_backdate_airtime); + // sync idempotency + RUN_TEST(test_repeated_sync_within_one_second_does_not_rotate); + RUN_TEST(test_rotation_is_once_per_second_regardless_of_entry_point); + RUN_TEST(test_period_constants_are_stable); + + // --- phase 3: windows, gates, sleep --- + RUN_TEST(test_oldest_period_falls_off_the_end); + RUN_TEST(test_period_boundary_is_exact_at_one_hour); + RUN_TEST(test_period_clear_boundary_is_exactly_the_log_depth); + RUN_TEST(test_channel_utilization_ages_out_oldest_first); + RUN_TEST(test_channel_utilization_clears_only_the_buckets_crossed); + RUN_TEST(test_channel_utilization_clear_boundary_is_exactly_six_periods); + RUN_TEST(test_channel_utilization_is_zero_when_nothing_logged); + RUN_TEST(test_channel_utilization_decays_proportionally_across_light_sleep); + RUN_TEST(test_channel_utilization_is_independent_of_scheduler_rate); + RUN_TEST(test_channel_utilization_never_exceeds_100_percent); + RUN_TEST(test_channel_utilization_counts_each_packet_once); + RUN_TEST(test_channel_utilization_covers_less_than_its_denominator); + RUN_TEST(test_channel_utilization_quantisation_error_by_phase); + RUN_TEST(test_channel_utilization_exceeds_100_percent_on_long_slow); + RUN_TEST(test_tx_utilization_ages_out_oldest_first); + RUN_TEST(test_tx_utilization_clears_only_the_minutes_crossed); + RUN_TEST(test_tx_utilization_clear_boundary_is_exactly_sixty_minutes); + RUN_TEST(test_tx_utilization_counts_only_transmissions); + RUN_TEST(test_tx_utilization_quantisation_error); + RUN_TEST(test_isTxAllowedChannelUtil_polite_threshold_is_lower); + RUN_TEST(test_isTxAllowedChannelUtil_boundary_is_exclusive); + RUN_TEST(test_isTxAllowedAirUtil_allows_when_override_is_set); + RUN_TEST(test_isTxAllowedAirUtil_allows_when_the_region_is_unlimited); + RUN_TEST(test_isTxAllowedAirUtil_blocks_at_half_the_duty_cycle); + RUN_TEST(test_router_send_gate_uses_the_whole_duty_cycle); + RUN_TEST(test_effective_duty_cycle_special_case_is_eu_866_only); + RUN_TEST(test_getSilentMinutes_returns_zero_when_already_under_the_limit); + RUN_TEST(test_getSilentMinutes_returns_a_full_hour_when_nothing_ages_out); + RUN_TEST(test_getSilentMinutes_counts_minutes_until_enough_ages_out); + RUN_TEST(test_getSilentMinutes_depends_on_ring_phase); + RUN_TEST(test_survives_heavy_sleep_across_the_wrap); + RUN_TEST(test_multi_day_sleep_clears_every_window); + RUN_TEST(test_backwards_uptime_degrades_safely); + RUN_TEST(test_no_public_method_takes_the_lock_twice); exit(UNITY_END()); } diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index 8b35d6534..96d392cd8 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -181,7 +181,7 @@ static void test_eviction_preservesFavorite(void) // A node heard during this boot is newer than every persisted epoch, including valid epochs after // 2038. Ranking both domains in one uint32_t incorrectly evicts the current-boot node first. -static void test_eviction_prefers_current_boot_stamp_over_post2038_epoch(void) +static void test_eviction_prefersCurrentBootStampOverPost2038Epoch(void) { constexpr NodeNum futureDated = 0x70000001; constexpr NodeNum heardThisBoot = 0x70000002; @@ -291,7 +291,7 @@ NDB_TEST_ENTRY void setup() RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm); RUN_TEST(test_migration_carriesSignerBitThroughWarm); RUN_TEST(test_eviction_preservesFavorite); - RUN_TEST(test_eviction_prefers_current_boot_stamp_over_post2038_epoch); + RUN_TEST(test_eviction_prefersCurrentBootStampOverPost2038Epoch); RUN_TEST(test_ignored_survivesEvictionAndCleanup); RUN_TEST(test_protectedCap_refusesBeyondLimit); RUN_TEST(test_removeNodeByNum_absentNodeOnFullDb); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index d8234290a..3b5c70ad3 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -417,6 +417,9 @@ void setUp(void) resetRoutingAuthEvaluationCount(); } +// Set while C14's saturated AirTime is installed; see useDutyCycleSaturatedAirTime() below. +static AirTime *c14SavedAirTime = nullptr; + void tearDown(void) { delete mockNodeDB; @@ -425,13 +428,15 @@ void tearDown(void) // Restore globals here, not at the end of a test body: an assertion aborts the body, and these // would otherwise leak into every later case. The injected clock is the one the N8-N11 - // suppression-window cases drive; the region and TX bucket are C14's duty-cycle setup. + // suppression-window cases drive; the region and the AirTime swap are C14's duty-cycle setup. Time::useRealClock(); Time::resetMonotonicForTests(); - if (airTime) - airTime->utilizationTX[0] = 0; config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; initRegion(); + if (c14SavedAirTime) { + airTime = c14SavedAirTime; + c14SavedAirTime = nullptr; + } } // =========================================================================== @@ -1500,12 +1505,32 @@ void test_C13_failed_initial_reliable_send_does_not_retry(void) "failed interface enqueue must not leave a retransmission pending"); } +// C14 needs a node that has used its whole hourly duty-cycle allowance. Swaps in a separate AirTime +// rather than poking the global's buckets, which are private now. +// +// Deliberately NOT a scoped guard: Unity's TEST_ABORT() is longjmp, which does not run destructors +// of automatic objects, so a guard would leave `airTime` dangling into an abandoned stack frame on +// any assertion failure - and later cases dereference it (NodeInfoModule::allocReply). tearDown() +// restores the global unconditionally instead. The instance is a function-local static so it +// outlives the longjmp. +// +// Note it also parks channel utilisation at ~6000%, because logAirtime() credits that for every +// report type. C14 gates on utilizationTXPercent() alone; do not reuse this for an +// isTxAllowedChannelUtil() path, which would then pass for the wrong reason. +static void useDutyCycleSaturatedAirTime() +{ + static AirTime saturated; + c14SavedAirTime = airTime; + airTime = &saturated; + saturated.logAirtime(TX_LOG, MS_IN_HOUR); // utilizationTXPercent() sums every bucket -> 100% +} + 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; + useDutyCycleSaturatedAirTime(); meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); initial.id = 0xC14C14C1; @@ -1519,7 +1544,6 @@ void test_C14_duty_cycle_limited_reliable_send_remains_pending(void) 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(); } diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 0395f5830..cfe06e7f5 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -37,24 +37,26 @@ constexpr NodeNum kTargetNode = 0x33333333; // a fresh requester for their "served again" step to avoid the per-requester window masking them. constexpr NodeNum kRemoteNode2 = 0x44444444; -// Telemetry hop exhaustion is gated on channel congestion (alterReceived checks -// airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global -// airTime reporting 100% channel utilization for the enclosing scope. -class ScopedBusyAirTime -{ - public: - ScopedBusyAirTime() : previous(airTime) - { - for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) - busy.channelUtilization[i] = 10000; // 10 s of airtime per 10 s period - airTime = &busy; - } - ~ScopedBusyAirTime() { airTime = previous; } - - private: - AirTime busy; - AirTime *previous; -}; +// INERT - commented out, not deleted. TrafficManagementModule holds no reference to airTime: +// the gating this described went with exhaust_hop_telemetry / exhaust_hop_position, and +// shouldExhaustHops() is now a compare of three members nothing sets. Writing the buckets did not +// work either - the first accessor call takes AirTime's firstTime branch and memsets them, so this +// reported 0%, not 100%. A revived version must fill them via logAirtime(); they are private now. +// +// class ScopedBusyAirTime +// { +// public: +// ScopedBusyAirTime() : previous(airTime) +// { +// busy.logAirtime(RX_ALL_LOG, CHANNEL_UTILIZATION_PERIODS * 10 * 1000); // a full window +// airTime = &busy; +// } +// ~ScopedBusyAirTime() { airTime = previous; } +// +// private: +// AirTime busy; +// AirTime *previous; +// }; class MockNodeDB : public NodeDB { @@ -2307,7 +2309,7 @@ static void test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed(void) */ static void test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged(void) { - ScopedBusyAirTime busyChannel; // congestion present but exhaust is disabled + // ScopedBusyAirTime busyChannel; // INERT: the module never reads airTime TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); packet.hop_start = 5; From 3b608b8fc56ed14faf5bcc46816668012cdc1fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 13 Aug 2026 19:12:54 +0200 Subject: [PATCH 049/109] fix(mesh): keep ROUTING_APP responses when toPhoneQueue is full (#11480) * fix(mesh): keep ROUTING_APP responses when toPhoneQueue is full #2918 narrowed the overflow policy to evict the oldest entry only for TEXT_MESSAGE_APP and RANGE_TEST_APP, dropping every other portnum. A dropped ROUTING_APP response leaves the phone with no delivery confirmation for a message it sent. Add ROUTING_APP to the eviction list and pin the policy in test/test_tophone_queue. Fixes #11439 * fix(mesh): gate the queue-overflow portnum check on the decoded variant decoded.portnum aliases encrypted.size in the payload union, so an encrypted packet could be read as a privileged portnum by its ciphertext length. Restore config.device.rebroadcast_mode in the test teardown. * test: rename a test to avoid a trufflehog false positive test_text_still_admitted_when_queue_full is "test_" followed by exactly 35 characters, which matches the Lob API key shape and fails trunk check. --- src/mesh/MeshService.cpp | 7 +- test/test_tophone_queue/test_main.cpp | 167 ++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 test/test_tophone_queue/test_main.cpp diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 0d450804c..591245db9 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -492,8 +492,11 @@ void MeshService::sendToPhone(meshtastic_MeshPacket *p) #endif if (toPhoneQueue.numFree() == 0) { - if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP || - p->decoded.portnum == meshtastic_PortNum_RANGE_TEST_APP) { + // ROUTING_APP is the phone's only delivery confirmation, so it displaces the oldest like + // text does. Gate the variant: decoded.portnum aliases encrypted.size in the union. + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && + (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP || + p->decoded.portnum == meshtastic_PortNum_RANGE_TEST_APP || p->decoded.portnum == meshtastic_PortNum_ROUTING_APP)) { LOG_WARN("ToPhone queue full, discard oldest"); meshtastic_MeshPacket *d = toPhoneQueue.dequeuePtr(0); if (d) diff --git a/test/test_tophone_queue/test_main.cpp b/test/test_tophone_queue/test_main.cpp new file mode 100644 index 000000000..fb04c5f78 --- /dev/null +++ b/test/test_tophone_queue/test_main.cpp @@ -0,0 +1,167 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#if ARCH_PORTDUINO // portduino_config.maxtophone is what sizes the queue under test + +#include "configuration.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "platform/portduino/PortduinoGlue.h" +#include +#include +#include + +// Queue depth for the suite. MAX_RX_TOPHONE resolves to portduino_config.maxtophone, read when +// MeshService constructs its queue. +static const int TEST_QUEUE_LEN = 4; + +static MeshService *testService = nullptr; +static MeshService *savedService = nullptr; +static int savedMaxToPhone = 0; +static meshtastic_Config_DeviceConfig_RebroadcastMode savedRebroadcastMode; + +static meshtastic_MeshPacket basePacket(uint32_t id) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = 0x11223344; + p.to = NODENUM_BROADCAST; + p.id = id; + return p; +} + +static void sendPacket(const meshtastic_MeshPacket &src) +{ + meshtastic_MeshPacket *p = packetPool.allocCopy(src); + TEST_ASSERT_NOT_NULL(p); + service->sendToPhone(p); +} + +static void send(uint32_t id, meshtastic_PortNum portnum, uint32_t requestId = 0) +{ + meshtastic_MeshPacket src = basePacket(id); + src.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + src.decoded.portnum = portnum; + src.decoded.request_id = requestId; + sendPacket(src); +} + +static void fillWith(meshtastic_PortNum portnum, uint32_t firstId) +{ + for (int i = 0; i < TEST_QUEUE_LEN; i++) + send(firstId + i, portnum); +} + +/// Drain the queue, returning the delivered packet ids in order. +static std::vector drainIds() +{ + std::vector ids; + while (meshtastic_MeshPacket *p = service->getForPhone()) { + ids.push_back(p->id); + service->releaseToPool(p); + } + return ids; +} + +static void assertIds(const std::vector &expected, const char *what) +{ + const std::vector actual = drainIds(); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)expected.size(), (int)actual.size(), what); + for (size_t i = 0; i < expected.size(); i++) + TEST_ASSERT_EQUAL_UINT32_MESSAGE(expected[i], actual[i], what); +} + +// An ACK/NAK is the phone's only delivery confirmation, so it must displace the oldest packet +// rather than be dropped when sustained downlink keeps the queue full. +static void test_routing_response_admitted_when_queue_full(void) +{ + fillWith(meshtastic_PortNum_TELEMETRY_APP, 1); + send(100, meshtastic_PortNum_ROUTING_APP, /*requestId=*/7); + + assertIds({2, 3, 4, 100}, "oldest telemetry should have been evicted for the routing response"); +} + +static void test_text_evicts_oldest_when_full(void) +{ + fillWith(meshtastic_PortNum_TELEMETRY_APP, 1); + send(200, meshtastic_PortNum_TEXT_MESSAGE_APP); + + assertIds({2, 3, 4, 200}, "text should still evict the oldest packet"); +} + +static void test_low_priority_packet_still_dropped_when_full(void) +{ + fillWith(meshtastic_PortNum_TEXT_MESSAGE_APP, 1); + send(200, meshtastic_PortNum_TELEMETRY_APP); + + assertIds({1, 2, 3, 4}, "a low-priority arrival should still be dropped on a full queue"); +} + +// decoded.portnum aliases encrypted.size in the payload union, so a still-encrypted packet whose +// ciphertext length happens to equal a privileged portnum must not be read as one. +static void test_encrypted_packet_is_not_classified_by_portnum(void) +{ + fillWith(meshtastic_PortNum_TELEMETRY_APP, 1); + + meshtastic_MeshPacket src = basePacket(300); + src.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + src.encrypted.size = meshtastic_PortNum_ROUTING_APP; + sendPacket(src); + + assertIds({1, 2, 3, 4}, "an encrypted packet must not be classified from the aliased portnum"); +} + +void setUp(void) +{ + savedMaxToPhone = portduino_config.maxtophone; + savedRebroadcastMode = config.device.rebroadcast_mode; + portduino_config.maxtophone = TEST_QUEUE_LEN; + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + + testService = new MeshService(); + savedService = service; + service = testService; +} + +void tearDown(void) +{ + drainIds(); // the queue owns its pointers; a failed assertion longjmps past any in-test drain + service = savedService; + delete testService; + testService = nullptr; + portduino_config.maxtophone = savedMaxToPhone; + config.device.rebroadcast_mode = savedRebroadcastMode; +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== toPhoneQueue overflow policy ===\n"); + + RUN_TEST(test_routing_response_admitted_when_queue_full); + RUN_TEST(test_text_evicts_oldest_when_full); + RUN_TEST(test_low_priority_packet_still_dropped_when_full); + RUN_TEST(test_encrypted_packet_is_not_classified_by_portnum); + + exit(UNITY_END()); +} + +void loop() {} + +#else // !ARCH_PORTDUINO + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +void loop() {} + +#endif From 944fd26580565c8a9e7c21454a8e117696aee2f9 Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Thu, 13 Aug 2026 19:13:50 +0200 Subject: [PATCH 050/109] Add SEN6x sensors (#11390) * Add SEN6X * Adds new SENXX class for SEN5X and SEN6X * Adds CO2 sensor calibration class to be shared among othre CO2 sensors * Make existing CO2 sensor draw from CO2Sensor class * Minor coment for CO2 sensor class * Move away from getRTC in SENXX class to keep track of time changes. * Change all sensors to millis for tracking time, instead of using getRTC * Add comments regarding VOC state * Avoid storing non-valid RTC Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Avoid CO2 sensor warm-up time to be below PM measured started Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix limits in CO2 sensor calibration Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Add pragma once on headers * Avoid non-working ASC commands Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix data poll * Move pm measure started before warmup check * Make cleaning non-blocking * Restore previous state if cleaning fails. Fix data ready condition. * Fix CO2 sensor checks for calibration --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/configuration.h | 1 + src/detect/ScanI2C.cpp | 4 +- src/detect/ScanI2C.h | 1 + src/detect/ScanI2CTwoWire.cpp | 16 +- src/modules/Telemetry/AirQualityTelemetry.cpp | 4 + src/modules/Telemetry/Sensor/CO2Sensor.h | 111 ++ src/modules/Telemetry/Sensor/HM330XSensor.cpp | 6 +- src/modules/Telemetry/Sensor/HM330XSensor.h | 2 + .../Telemetry/Sensor/PMSA003ISensor.cpp | 6 +- src/modules/Telemetry/Sensor/PMSA003ISensor.h | 2 + src/modules/Telemetry/Sensor/SCD30Sensor.cpp | 39 +- src/modules/Telemetry/Sensor/SCD30Sensor.h | 24 +- src/modules/Telemetry/Sensor/SCD4XSensor.cpp | 121 +- src/modules/Telemetry/Sensor/SCD4XSensor.h | 38 +- src/modules/Telemetry/Sensor/SEN5XSensor.cpp | 1003 ---------- src/modules/Telemetry/Sensor/SEN5XSensor.h | 197 +- src/modules/Telemetry/Sensor/SEN6XSensor.h | 18 + src/modules/Telemetry/Sensor/SENXXSensor.cpp | 1614 +++++++++++++++++ src/modules/Telemetry/Sensor/SENXXSensor.h | 310 ++++ src/modules/Telemetry/Sensor/SFA30Sensor.cpp | 10 +- src/modules/Telemetry/Sensor/SFA30Sensor.h | 2 + 21 files changed, 2216 insertions(+), 1313 deletions(-) create mode 100644 src/modules/Telemetry/Sensor/CO2Sensor.h delete mode 100644 src/modules/Telemetry/Sensor/SEN5XSensor.cpp create mode 100644 src/modules/Telemetry/Sensor/SEN6XSensor.h create mode 100644 src/modules/Telemetry/Sensor/SENXXSensor.cpp create mode 100644 src/modules/Telemetry/Sensor/SENXXSensor.h diff --git a/src/configuration.h b/src/configuration.h index 795856955..1a6550366 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -300,6 +300,7 @@ along with this program. If not, see . #define BQ25896_ADDR 0x6B #define LTR553ALS_ADDR 0x23 #define SEN5X_ADDR 0x69 +#define SEN6X_ADDR 0x6B // same as QMI8658_ADDR and BQ25896_ADDR #define SCD30_ADDR 0x61 #define ADS1X15_ADDR 0x48 #define ADS1X15_ADDR_ALT1 0x49 diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index a919bd105..eb0710101 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -50,8 +50,8 @@ ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const ScanI2C::FoundDevice ScanI2C::firstAQI() const { - ScanI2C::DeviceType types[] = {PMSA003I, SEN5X, SCD4X, SFA30}; - return firstOfOrNONE(4, types); + ScanI2C::DeviceType types[] = {PMSA003I, SEN5X, SEN6X, SCD4X, SFA30}; + return firstOfOrNONE(5, types); } ScanI2C::FoundDevice ScanI2C::firstRGBLED() const diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 46d5395a6..6a97370dc 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -96,6 +96,7 @@ class ScanI2C CST3530, BMI270, SEN5X, + SEN6X, SFA30, CW2015, SCD30, diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index af484eb15..f0f4671f5 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -160,12 +160,19 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1 #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR #include "../modules/Telemetry/Sensor/SEN5XSensor.h" +#include "../modules/Telemetry/Sensor/SEN6XSensor.h" bool probeSEN5X(TwoWire *i2cBus, uint8_t address, ScanI2C::I2CPort port) { SEN5XSensor sen5xsensor; return sen5xsensor.probe(i2cBus, address, port); } +bool probeSEN6X(TwoWire *i2cBus, uint8_t address, ScanI2C::I2CPort port) +{ + SEN6XSensor sen6xsensor; + return sen6xsensor.probe(i2cBus, address, port); +} + bool probeHM330x(TwoWire *i2cBus, uint8_t address) { @@ -700,7 +707,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("QMC6310U", (uint8_t)addr.address); break; - case QMI8658_ADDR: + case QMI8658_ADDR: // same as BQ25896_ADDR and SEN6X_ADDR registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0A), 1); // get ID if (registerValue == 0xC0) { type = BQ24295; @@ -721,6 +728,13 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) type = ISM330DHCX; logFoundDevice("ISM330DHCX", (uint8_t)addr.address); } else { +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + if (probeSEN6X(i2cBus, addr.address, port)) { + type = SEN6X; + logFoundDevice("SEN6X", addr.address); + break; + } +#endif type = QMI8658; logFoundDevice("QMI8658", (uint8_t)addr.address); } diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index b67a18327..2eb596bd9 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -27,6 +27,7 @@ static constexpr uint16_t TX_HISTORY_KEY_AIR_QUALITY_TELEMETRY = 0x8004; #include "Sensor/AddI2CSensorTemplate.h" #include "Sensor/PMSA003ISensor.h" #include "Sensor/SEN5XSensor.h" +#include "Sensor/SEN6XSensor.h" #if __has_include() #include "Sensor/SCD4XSensor.h" #endif @@ -66,6 +67,8 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) supportedSensors[PMSA003I_ADDR] = ScanI2C::DeviceType::PMSA003I; if (!supportedSensors.count(SEN5X_ADDR)) supportedSensors[SEN5X_ADDR] = ScanI2C::DeviceType::SEN5X; + if (!supportedSensors.count(SEN6X_ADDR)) + supportedSensors[SEN6X_ADDR] = ScanI2C::DeviceType::SEN6X; #if __has_include() if (!supportedSensors.count(SCD4X_ADDR)) supportedSensors[SCD4X_ADDR] = ScanI2C::DeviceType::SCD4X; @@ -108,6 +111,7 @@ void AirQualityTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) // order by priority of metrics/values (low top, high bottom) addSensor(i2cScanner, ScanI2C::DeviceType::PMSA003I); addSensor(i2cScanner, ScanI2C::DeviceType::SEN5X); + addSensor(i2cScanner, ScanI2C::DeviceType::SEN6X); #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::SCD4X); #endif diff --git a/src/modules/Telemetry/Sensor/CO2Sensor.h b/src/modules/Telemetry/Sensor/CO2Sensor.h new file mode 100644 index 000000000..57e747a6f --- /dev/null +++ b/src/modules/Telemetry/Sensor/CO2Sensor.h @@ -0,0 +1,111 @@ +#pragma once + +#include "MeshModule.h" + +/* +Shared CO2 calibration interface + admin-message dispatch for any sensor that +exposes Sensirion-style CO2 auto/forced calibration: automatic self-calibration +(ASC), forced recalibration (FRC), altitude/ambient-pressure compensation, and +a calibration-history factory reset. SCD4XSensor, SCD30Sensor and the +CO2-capable SEN6X variants (SEN63C/SEN66/SEN69C, via SENXXSensor) all implement +this instead of duplicating the same admin-message branching logic. + +Concrete classes only need to implement the low-level co2* operations against +their own I2C command set; handleCo2AdminRequest() below is the one shared +place that decides *when* to call FRC vs ASC, validates that a target CO2 was +supplied for FRC, and reverts ASC on a failed FRC attempt. +*/ +class CO2CalibrationSensor +{ + protected: + virtual ~CO2CalibrationSensor() {} + + // Forced recalibration against a known reference CO2 concentration (ppm). + virtual bool co2PerformFRC(uint32_t targetCO2ppm) = 0; + + // Automatic self-calibration on/off. + virtual bool co2GetASC(bool &ascEnabled) = 0; + virtual bool co2SetASC(bool ascEnabled) = 0; + // Optional: not every sensor exposes a settable ASC baseline (e.g. SCD30/SEN6X don't). + virtual bool co2SetASCBaseline(uint32_t targetCO2ppm) { return true; } + + // Altitude/pressure compensation. altitude in meters above sea level, + // ambientPressure in Pa (implementations convert to whatever unit their + // own command set expects). + virtual bool co2SetAltitude(uint32_t altitude) = 0; + virtual bool co2SetAmbientPressure(uint32_t ambientPressurePa) { return false; } + + // Erases the sensor's FRC/ASC calibration history. Optional. + virtual bool co2FactoryReset() { return false; } + + // Snapshot of whichever *_config admin message fields were populated, + // translated once by the caller into this sensor-agnostic shape. + struct Co2AdminRequest { + bool hasFactoryReset = false; + bool hasSetAsc = false; + bool setAsc = false; + bool hasTargetCo2 = false; + uint32_t targetCo2 = 0; + bool hasSetAltitude = false; + uint32_t setAltitude = 0; + bool hasSetAmbientPressure = false; + uint32_t setAmbientPressure = 0; + }; + + // Returns false if a requested operation failed - callers should map + // that to AdminMessageHandleResult::NOT_HANDLED like they already do for + // their sensor-specific fields (e.g. temperature offset, power mode). + bool handleCo2AdminRequest(const Co2AdminRequest &cfg, const char *sensorName) + { + if (cfg.hasFactoryReset) { + LOG_DEBUG("%s: Requested CO2 calibration factory reset", sensorName); + return co2FactoryReset(); + } + + if (cfg.hasSetAsc) { + if (!cfg.setAsc) { + bool currentASC = false; + if (!co2GetASC(currentASC)) { + return false; + } + // Disabling ASC is how you request a forced recalibration (FRC). + if (!cfg.hasTargetCo2) { + LOG_ERROR("%s: target CO2 not provided for FRC", sensorName); + return false; + } + LOG_DEBUG("%s: Request for FRC", sensorName); + if (!co2SetASC(false)) { + return false; + } + if (!co2PerformFRC(cfg.targetCo2)) { + // Restore previous ASC state since the FRC attempt failed. + co2SetASC(currentASC); + return false; + } + } else { + LOG_DEBUG("%s: Request for ASC", sensorName); + if (!co2SetASC(true)) { + return false; + } + // ASC with target CO2 is only available in SCD4X + if (cfg.hasTargetCo2) { + if (!co2SetASCBaseline(cfg.targetCo2)) { + return false; + } + } + } + } + + if (cfg.hasSetAltitude) { + if (!co2SetAltitude(cfg.setAltitude)) { + return false; + } + } else if (cfg.hasSetAmbientPressure) { + if (!co2SetAmbientPressure(cfg.setAmbientPressure)) { + return false; + } + } + + return true; + } +}; diff --git a/src/modules/Telemetry/Sensor/HM330XSensor.cpp b/src/modules/Telemetry/Sensor/HM330XSensor.cpp index 20b2a5e66..91c0267e7 100644 --- a/src/modules/Telemetry/Sensor/HM330XSensor.cpp +++ b/src/modules/Telemetry/Sensor/HM330XSensor.cpp @@ -42,7 +42,7 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) uint32_t HM330XSensor::wakeUp() { state = State::ACTIVE; - measureStarted = getTime(); + measureStarted = millis(); return HM330X_WARMUP_MS; } @@ -64,9 +64,7 @@ bool HM330XSensor::isActive() int32_t HM330XSensor::pendingForReadyMs() { - uint32_t now; - now = getTime(); - uint32_t sincePMMeasureStarted = (now - measureStarted) * 1000; + uint32_t sincePMMeasureStarted = millis() - measureStarted; LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePMMeasureStarted); if (sincePMMeasureStarted < HM330X_WARMUP_MS) { diff --git a/src/modules/Telemetry/Sensor/HM330XSensor.h b/src/modules/Telemetry/Sensor/HM330XSensor.h index 76312b04c..f8edb0a47 100644 --- a/src/modules/Telemetry/Sensor/HM330XSensor.h +++ b/src/modules/Telemetry/Sensor/HM330XSensor.h @@ -18,6 +18,8 @@ class HM330XSensor : public TelemetrySensor private: enum class State { IDLE, ACTIVE }; State state = State::IDLE; + // millis()-based, not wall-clock: this only measures in-session warmup elapsed time, + // and getTime() can jump discontinuously when RTC quality improves mid-session. uint32_t measureStarted = 0; uint8_t buffer[HM330X_FRAME_LENGTH]{}; TwoWire *_bus{}; diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp index c36605773..8b9151379 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp @@ -157,9 +157,7 @@ int32_t PMSA003ISensor::wakeUpTimeMs() int32_t PMSA003ISensor::pendingForReadyMs() { #ifdef PMSA003I_ENABLE_PIN - uint32_t now; - now = getTime(); - uint32_t sincePmMeasureStarted = (now - pmMeasureStarted) * 1000; + uint32_t sincePmMeasureStarted = millis() - pmMeasureStarted; LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); if (sincePmMeasureStarted < PMSA003I_WARMUP_MS) { @@ -195,7 +193,7 @@ uint32_t PMSA003ISensor::wakeUp() LOG_INFO("%s Waking", sensorName); digitalWrite(PMSA003I_ENABLE_PIN, HIGH); state = PMSA003I_ACTIVE; - pmMeasureStarted = getTime(); + pmMeasureStarted = millis(); return PMSA003I_WARMUP_MS; #endif diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.h b/src/modules/Telemetry/Sensor/PMSA003ISensor.h index b65ef99a9..7243a0ddf 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.h +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.h @@ -39,6 +39,8 @@ class PMSA003ISensor : public TelemetrySensor uint16_t computedChecksum = 0; uint16_t receivedChecksum = 0; + // millis()-based, not wall-clock: this only measures in-session warmup elapsed time, + // and getTime() can jump discontinuously when RTC quality improves mid-session. uint32_t pmMeasureStarted = 0; uint8_t buffer[PMSA003I_FRAME_LENGTH]{}; diff --git a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp index c380f0f42..c2631f5b0 100644 --- a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp @@ -424,37 +424,34 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa LOG_DEBUG("%s: Requested soft reset", sensorName); this->softReset(); } else { + const auto &cfg = request->sensor_config.scd30_config; - if (request->sensor_config.scd30_config.has_set_asc) { - this->setASC(request->sensor_config.scd30_config.set_asc); - if (request->sensor_config.scd30_config.set_asc == false) { - LOG_DEBUG("%s: Request for FRC", sensorName); - if (request->sensor_config.scd30_config.has_set_target_co2_conc) { - this->performFRC(request->sensor_config.scd30_config.set_target_co2_conc); - } else { - // FRC requested but no target CO2 provided - LOG_ERROR("%s: target CO2 not provided", sensorName); - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } + // ASC/FRC/altitude calibration branching is shared with SCD4XSensor and the + // CO2-capable SEN6X variants via CO2CalibrationSensor. + if (cfg.has_set_asc || cfg.has_set_altitude) { + Co2AdminRequest co2req; + co2req.hasSetAsc = cfg.has_set_asc; + co2req.setAsc = cfg.set_asc; + co2req.hasTargetCo2 = cfg.has_set_target_co2_conc; + co2req.targetCo2 = cfg.set_target_co2_conc; + co2req.hasSetAltitude = cfg.has_set_altitude; + co2req.setAltitude = cfg.set_altitude; + if (!this->handleCo2AdminRequest(co2req, sensorName)) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; } } // Check for temperature offset // NOTE: this requires to have a sensor working on stable environment // And to make it between readings - if (request->sensor_config.scd30_config.has_set_temperature) { - this->setTemperature(request->sensor_config.scd30_config.set_temperature); - } - - // Check for altitude - if (request->sensor_config.scd30_config.has_set_altitude) { - this->setAltitude(request->sensor_config.scd30_config.set_altitude); + if (cfg.has_set_temperature) { + this->setTemperature(cfg.set_temperature); } // Check for set measuremen interval - if (request->sensor_config.scd30_config.has_set_measurement_interval) { - this->setMeasurementInterval(request->sensor_config.scd30_config.set_measurement_interval); + if (cfg.has_set_measurement_interval) { + this->setMeasurementInterval(cfg.set_measurement_interval); } } diff --git a/src/modules/Telemetry/Sensor/SCD30Sensor.h b/src/modules/Telemetry/Sensor/SCD30Sensor.h index 82c9a5532..51bc872ba 100644 --- a/src/modules/Telemetry/Sensor/SCD30Sensor.h +++ b/src/modules/Telemetry/Sensor/SCD30Sensor.h @@ -4,12 +4,13 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "CO2Sensor.h" #include "TelemetrySensor.h" #include #define SCD30_I2C_CLOCK_SPEED 100000 -class SCD30Sensor : public TelemetrySensor +class SCD30Sensor : public TelemetrySensor, public CO2CalibrationSensor { private: SensirionI2cScd30 scd30; @@ -29,6 +30,27 @@ class SCD30Sensor : public TelemetrySensor bool startMeasurement(); bool stopMeasurement(); + // CO2CalibrationSensor overrides - thin wrappers, shared with SCD4XSensor and + // the CO2-capable SEN6X variants via CO2CalibrationSensor::handleCo2AdminRequest(). + // SCD30 has no ambient-pressure command or calibration-history factory reset, so + // those two are left at CO2CalibrationSensor's default (unsupported) implementation. + bool co2PerformFRC(uint32_t targetCO2ppm) override + { + return targetCO2ppm <= UINT16_MAX && performFRC(static_cast(targetCO2ppm)); + } + bool co2GetASC(bool &ascEnabled) override + { + uint16_t v = 0; + bool ok = getASC(v); + ascEnabled = v != 0; + return ok; + } + bool co2SetASC(bool ascEnabled) override { return setASC(ascEnabled); } + bool co2SetAltitude(uint32_t altitude) override + { + return altitude <= UINT16_MAX && setAltitude(static_cast(altitude)); + } + // Parameters uint16_t ascActive = 1; uint16_t measurementInterval = 2; diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp index 7c6bc3ecf..261f8259b 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp @@ -705,7 +705,7 @@ uint32_t SCD4XSensor::wakeUp() #endif /* SCD4X_I2C_CLOCK_SPEED */ if (startMeasurement()) { - co2MeasureStarted = getTime(); + co2MeasureStarted = millis(); #ifdef SCD4X_I2C_CLOCK_SPEED reClockI2C.restoreClock(); #endif /* SCD4X_I2C_CLOCK_SPEED */ @@ -755,9 +755,7 @@ int32_t SCD4XSensor::wakeUpTimeMs() int32_t SCD4XSensor::pendingForReadyMs() { - uint32_t now; - now = getTime(); - uint32_t sinceCO2MeasureStarted = (now - co2MeasureStarted) * 1000; + uint32_t sinceCO2MeasureStarted = millis() - co2MeasureStarted; LOG_DEBUG("%s: Since measure started: %ums", sensorName, sinceCO2MeasureStarted); if (sinceCO2MeasureStarted < SCD4X_WARMUP_MS) { @@ -785,85 +783,48 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa break; } - if (request->sensor_config.scd4x_config.has_factory_reset) { - LOG_DEBUG("%s: Requested factory reset", sensorName); - if (!this->factoryReset()) { + { + const auto &cfg = request->sensor_config.scd4x_config; + bool ok = true; + + // FRC/ASC/altitude/pressure/factory-reset calibration branching is shared with + // SCD30Sensor and the CO2-capable SEN6X variants via CO2CalibrationSensor. + if (cfg.has_factory_reset || cfg.has_set_asc || cfg.has_set_altitude || cfg.has_set_ambient_pressure) { + Co2AdminRequest co2req; + co2req.hasFactoryReset = cfg.has_factory_reset; + co2req.hasSetAsc = cfg.has_set_asc; + co2req.setAsc = cfg.set_asc; + co2req.hasTargetCo2 = cfg.has_set_target_co2_conc; + co2req.targetCo2 = cfg.set_target_co2_conc; + co2req.hasSetAltitude = cfg.has_set_altitude; + co2req.setAltitude = cfg.set_altitude; + co2req.hasSetAmbientPressure = cfg.has_set_ambient_pressure; + co2req.setAmbientPressure = cfg.set_ambient_pressure; + ok &= this->handleCo2AdminRequest(co2req, sensorName); + } + + // A factory reset erases calibration history outright - matches the original + // behavior of skipping every other field when it's requested. + if (ok && !cfg.has_factory_reset) { + // Check for temperature offset + // NOTE: this requires to have a sensor working on stable environment + // And to make it between readings + if (cfg.has_set_temperature) { + ok &= this->setTemperature(cfg.set_temperature); + } + + // Check for low power mode + // NOTE: to switch from one mode to another do: + // setPowerMode -> startMeasurement + if (cfg.has_set_power_mode) { + ok &= this->setPowerMode(cfg.set_power_mode); + } + } + + if (!ok) { result = AdminMessageHandleResult::NOT_HANDLED; break; } - } else { - if (request->sensor_config.scd4x_config.has_set_asc) { - getASC(ascActive); - bool currentASC = ascActive; - if (request->sensor_config.scd4x_config.set_asc == false) { - LOG_DEBUG("%s: Request for FRC", sensorName); - if (request->sensor_config.scd4x_config.has_set_target_co2_conc) { - if (this->setASC(request->sensor_config.scd4x_config.set_asc)) { - if (!this->performFRC(request->sensor_config.scd4x_config.set_target_co2_conc)) { - result = AdminMessageHandleResult::NOT_HANDLED; - // Set it back to ASC if failed - setASC(currentASC); - break; - }; - } else { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } else { - // FRC requested but no target CO2 provided - LOG_ERROR("%s: target CO2 not provided", sensorName); - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } else { - LOG_DEBUG("%s: Request for ASC", sensorName); - if (this->setASC(request->sensor_config.scd4x_config.set_asc)) { - if (request->sensor_config.scd4x_config.has_set_target_co2_conc) { - LOG_DEBUG("%s: Request has target CO2", sensorName); - this->setASCBaseline(request->sensor_config.scd4x_config.set_target_co2_conc); - // NOTE - in this situation, if we set ASC, but baseline set fails, we stay on ASC - } else { - LOG_DEBUG("%s: Request doesn't have target CO2", sensorName); - } - } else { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } - } - - // Check for temperature offset - // NOTE: this requires to have a sensor working on stable environment - // And to make it between readings - if (request->sensor_config.scd4x_config.has_set_temperature) { - if (!this->setTemperature(request->sensor_config.scd4x_config.set_temperature)) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } - - // Check for altitude or pressure offset - if (request->sensor_config.scd4x_config.has_set_altitude) { - if (!this->setAltitude(request->sensor_config.scd4x_config.set_altitude)) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } else if (request->sensor_config.scd4x_config.has_set_ambient_pressure) { - if (!this->setAmbientPressure(request->sensor_config.scd4x_config.set_ambient_pressure)) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } - - // Check for low power mode - // NOTE: to switch from one mode to another do: - // setPowerMode -> startMeasurement - if (request->sensor_config.scd4x_config.has_set_power_mode) { - if (!this->setPowerMode(request->sensor_config.scd4x_config.set_power_mode)) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - } } result = AdminMessageHandleResult::HANDLED; diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.h b/src/modules/Telemetry/Sensor/SCD4XSensor.h index f9161942e..af7703151 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.h +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.h @@ -4,6 +4,7 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "CO2Sensor.h" #include "TelemetrySensor.h" #include "gps/RTC.h" #include @@ -13,7 +14,7 @@ #define SCD4X_WARMUP_MS 5000 #define SCD4X_MAX_RETRIES 3 -class SCD4XSensor : public TelemetrySensor +class SCD4XSensor : public TelemetrySensor, public CO2CalibrationSensor { private: SensirionI2cScd4x scd4x; @@ -35,10 +36,45 @@ class SCD4XSensor : public TelemetrySensor bool startMeasurement(); bool stopMeasurement(); + // CO2CalibrationSensor overrides - thin wrappers around the methods above, + // shared with SCD30Sensor and the CO2-capable SEN6X variants via + // CO2CalibrationSensor::handleCo2AdminRequest(). + bool co2PerformFRC(uint32_t targetCO2ppm) override + { + return targetCO2ppm <= UINT16_MAX && performFRC(static_cast(targetCO2ppm)); + } + bool co2GetASC(bool &ascEnabled) override + { + uint16_t v = 0; + bool ok = getASC(v); + ascEnabled = v != 0; + return ok; + } + bool co2SetASC(bool ascEnabled) override { return setASC(ascEnabled); } + bool co2SetASCBaseline(uint32_t targetCO2ppm) override + { + return targetCO2ppm <= UINT16_MAX && setASCBaseline(static_cast(targetCO2ppm)); + } + bool co2SetAltitude(uint32_t altitude) override + { + if (altitude > 3000) + return false; + return altitude <= UINT16_MAX && setAltitude(static_cast(altitude)); + } + bool co2SetAmbientPressure(uint32_t ambientPressurePa) override + { + if (ambientPressurePa < 70000 || ambientPressurePa > 120000) + return false; + return setAmbientPressure(ambientPressurePa); + } + bool co2FactoryReset() override { return factoryReset(); } + uint16_t ascActive = 1; // low power measurement mode (on sensirion side). Disables sleep mode // Improvement and testing needed for timings bool lowPower = true; + // millis()-based, not wall-clock: this only measures in-session warmup elapsed time, + // and getTime() can jump discontinuously when RTC quality improves mid-session. uint32_t co2MeasureStarted = 0; public: diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp deleted file mode 100644 index 37df1204b..000000000 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp +++ /dev/null @@ -1,1003 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "FSCommon.h" -#include "SEN5XSensor.h" -#include "SPILock.h" -#include "SafeFile.h" -#include "TelemetrySensor.h" -#include // FLT_MAX -#include -#include - -SEN5XSensor::SEN5XSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SEN5X, "SEN5X") {} - -bool SEN5XSensor::getVersion() -{ - if (!sendCommand(SEN5X_GET_FIRMWARE_VERSION)) { - LOG_ERROR("%s: Error sending version command", sensorName); - return false; - } - delay(20); // From Sensirion Datasheet - - // Version reply layout: fw major/minor, fw debug, hw major/minor, - // protocol major/minor, padding - uint8_t versionBuffer[SEN5X_VERSION_BUFFER_SIZE]{}; - size_t charNumber = readBuffer(&versionBuffer[0], SEN5X_VERSION_BUFFER_SIZE + (SEN5X_VERSION_BUFFER_SIZE / 2)); - if (charNumber < SEN5X_VERSION_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting device version value", sensorName); - return false; - } - - firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10.0f); - hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10.0f); - protocolVer = versionBuffer[5] + (versionBuffer[6] / 10.0f); - - LOG_INFO("%s: Firmware Version: %0.2f", sensorName, firmwareVer); - LOG_INFO("%s: Hardware Version: %0.2f", sensorName, hardwareVer); - LOG_INFO("%s: Protocol Version: %0.2f", sensorName, protocolVer); - - return true; -} - -bool SEN5XSensor::findModel() -{ - if (!sendCommand(SEN5X_GET_PRODUCT_NAME)) { - LOG_ERROR("%s: Error asking for product name", sensorName); - return false; - } - delay(50); // From Sensirion Datasheet - - uint8_t name[SEN5X_PRODUCT_NAME_BUFFER_SIZE]{}; - size_t charNumber = readBuffer(&name[0], SEN5X_PRODUCT_NAME_BUFFER_SIZE + (SEN5X_PRODUCT_NAME_BUFFER_SIZE / 2)); - bool foundModel = false; - - if (charNumber < SEN5X_PRODUCT_NAME_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting device name", sensorName); - return foundModel; - } - - // We only check the last character that defines the model SEN5X - switch (name[4]) { - case 48: - model = SEN50; - LOG_INFO("%s: found sensor model SEN50", sensorName); - foundModel = true; - break; - case 52: - model = SEN54; - LOG_INFO("%s: found sensor model SEN54", sensorName); - foundModel = true; - break; - case 53: - model = SEN55; - LOG_INFO("%s: found sensor model SEN55", sensorName); - foundModel = true; - break; - } - - return foundModel; -} - -bool SEN5XSensor::probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port) -{ - LOG_INFO("SEN5X: probing sensor"); - - _bus = bus; - _address = address; - -#ifdef SEN5X_I2C_CLOCK_SPEED - _port = port; - reClockI2C.setup(_bus, _port); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - if (!findModel()) { - LOG_DEBUG("SEN5X: can't find SEN5X model"); - return false; - } - - return true; -} - -bool SEN5XSensor::sendCommand(uint16_t command) -{ - uint8_t nothing; - return sendCommand(command, ¬hing, 0); -} - -bool SEN5XSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber) -{ - // At least we need two bytes for the command - uint8_t bufferSize = 2; - - // Add space for CRC bytes (one every two bytes) - if (byteNumber > 0) - bufferSize += byteNumber + (byteNumber / 2); - - uint8_t toSend[bufferSize]; - uint8_t i = 0; - toSend[i++] = static_cast((command & 0xFF00) >> 8); - toSend[i++] = static_cast((command & 0x00FF) >> 0); - - // Prepare buffer with CRC every third byte - uint8_t bi = 0; - if (byteNumber > 0) { - while (bi < byteNumber) { - toSend[i++] = buffer[bi++]; - toSend[i++] = buffer[bi++]; - uint8_t calcCRC = sen5xCRC(&buffer[bi - 2]); - toSend[i++] = calcCRC; - } - } - -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.setClock(SEN5X_I2C_CLOCK_SPEED); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - // Transmit the data - // LOG_DEBUG("Beginning connection to SEN5X: 0x%x. Size: %u", address, bufferSize); - // Note: this delay is necessary to allow for long-buffers - delay(20); - _bus->beginTransmission(_address); - size_t writtenBytes = _bus->write(toSend, bufferSize); - uint8_t i2c_error = _bus->endTransmission(); - -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - if (writtenBytes != bufferSize) { - LOG_ERROR("%s: Error writing on I2C bus", sensorName); - return false; - } - - if (i2c_error != 0) { - LOG_ERROR("%s: Error on I2C communication: %x", sensorName, i2c_error); - return false; - } - return true; -} - -uint8_t SEN5XSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) -{ -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.setClock(SEN5X_I2C_CLOCK_SPEED); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - size_t readBytes = _bus->requestFrom(_address, byteNumber); - if (readBytes != byteNumber) { - LOG_ERROR("%s: Error reading I2C bus", sensorName); -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - return 0; - } - - uint8_t i = 0; - uint8_t receivedBytes = 0; - while (readBytes > 0) { - buffer[i++] = _bus->read(); // Just as a reminder: i++ returns i and after that increments. - buffer[i++] = _bus->read(); - uint8_t recvCRC = _bus->read(); - uint8_t calcCRC = sen5xCRC(&buffer[i - 2]); - if (recvCRC != calcCRC) { - LOG_ERROR("%s: Checksum error receiving msg", sensorName); -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - return 0; - } - readBytes -= 3; - receivedBytes += 2; - } - -#ifdef SEN5X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - return receivedBytes; -} - -uint8_t SEN5XSensor::sen5xCRC(const uint8_t *buffer) -{ - // This code is based on Sensirion's own implementation - // https://github.com/Sensirion/arduino-core/blob/41fd02cacf307ec4945955c58ae495e56809b96c/src/SensirionCrc.cpp - uint8_t crc = 0xff; - - for (uint8_t i = 0; i < 2; i++) { - - crc ^= buffer[i]; - - for (uint8_t bit = 8; bit > 0; bit--) { - if (crc & 0x80) - crc = (crc << 1) ^ 0x31; - else - crc = (crc << 1); - } - } - - return crc; -} - -void SEN5XSensor::sleep() -{ - idle(true); -} - -bool SEN5XSensor::idle(bool checkState) -{ - // From the datasheet: - // By default, the VOC algorithm resets its state to initial - // values each time a measurement is started, - // even if the measurement was stopped only for a short - // time. So, the VOC index output value needs a long time - // until it is stable again. This can be avoided by - // restoring the previously memorized algorithm state before - // starting the measure mode - - if (checkState) { - // If the stabilisation period is not passed for SEN54 or SEN55, don't go to idle - if (model != SEN50) { - // Get VOC state before going to idle mode - vocValid = false; - if (vocStateFromSensor()) { - vocValid = vocStateValid(); - // Check if we have time, and store it - uint32_t now; // If time is RTCQualityNone, it will return zero - now = getValidTime(RTCQuality::RTCQualityDevice); - // Check if state is valid (non-zero) - if (now) { - vocTime = now; - } - } - - if (!(vocStateStable() && vocValid)) { - LOG_INFO("%s: Not stopping measurement, vocState not stable yet", sensorName); - return true; - } - } - // Save state and prefs (on all models) - saveState(); - } - - if (!oneShotMode) { - LOG_INFO("%s: Not stopping measurement, continuous mode", sensorName); - return true; - } else { - LOG_INFO("%s: One shot mode enabled", sensorName); - } - - // Switch to low-power based on the model - if (model == SEN50) { - if (!sendCommand(SEN5X_STOP_MEASUREMENT)) { - LOG_ERROR("%s: Error stopping measurement", sensorName); - return false; - } - state = SEN5X_IDLE; - LOG_INFO("%s: Stop measurement mode", sensorName); - } else { - if (!sendCommand(SEN5X_START_MEASUREMENT_RHT_GAS)) { - LOG_ERROR("%s: Error switching to RHT/Gas measurement", sensorName); - return false; - } - state = SEN5X_RHTGAS_ONLY; - LOG_INFO("%s: Switch to RHT/Gas only measurement mode", sensorName); - } - - delay(200); // From Sensirion Datasheet - pmMeasureStarted = 0; - return true; -} - -bool SEN5XSensor::vocStateRecent(uint32_t now) -{ - if (now) { - uint32_t passed = now - vocTime; // in seconds - - // Check if state is recent, less than 10 minutes (600 seconds) - if (passed < SEN5X_VOC_VALID_TIME && (now > SEN5X_VOC_VALID_DATE)) { - return true; - } - } - return false; -} - -bool SEN5XSensor::vocStateValid() -{ - if (!vocState[0] && !vocState[1] && !vocState[2] && !vocState[3] && !vocState[4] && !vocState[5] && !vocState[6] && - !vocState[7]) { - LOG_DEBUG("%s: VOC state is all 0, invalid", sensorName); - return false; - } else { - LOG_DEBUG("%s: VOC state is valid", sensorName); - return true; - } -} - -bool SEN5XSensor::vocStateToSensor() -{ - if (model == SEN50) { - return true; - } - - if (!vocStateValid()) { - LOG_INFO("%s: VOC state is invalid, not sending", sensorName); - return true; - } - - if (!sendCommand(SEN5X_STOP_MEASUREMENT)) { - LOG_ERROR("%s: Error stopping measurement", sensorName); - return false; - } - delay(200); // From Sensirion Datasheet - - LOG_DEBUG("%s: Sending VOC state to sensor", sensorName); - LOG_DEBUG("[%u, %u, %u, %u, %u, %u, %u, %u]", vocState[0], vocState[1], vocState[2], vocState[3], vocState[4], vocState[5], - vocState[6], vocState[7]); - - // Note: send command already takes into account the CRC - // buffer size increment needed - if (!sendCommand(SEN5X_RW_VOCS_STATE, vocState, SEN5X_VOC_STATE_BUFFER_SIZE)) { - LOG_ERROR("%s: Error sending VOC's state command", sensorName); - return false; - } - - return true; -} - -bool SEN5XSensor::vocStateFromSensor() -{ - if (model == SEN50) { - return true; - } - - LOG_INFO("%s: Getting VOC state from sensor", sensorName); - // Ask VOCs state from the sensor - if (!sendCommand(SEN5X_RW_VOCS_STATE)) { - LOG_ERROR("%s: Error sending VOC's state command", sensorName); - return false; - } - - delay(20); // From Sensirion Datasheet - - // Retrieve the data into a staging buffer so a partial read (e.g. a CRC - // failure halfway through) cannot corrupt the current vocState. - // The requested size accounts for the CRC bytes - uint8_t stateBuffer[SEN5X_VOC_STATE_BUFFER_SIZE]{}; - size_t receivedNumber = readBuffer(&stateBuffer[0], SEN5X_VOC_STATE_BUFFER_SIZE + (SEN5X_VOC_STATE_BUFFER_SIZE / 2)); - delay(20); // From Sensirion Datasheet - - if (receivedNumber < SEN5X_VOC_STATE_BUFFER_SIZE) { - LOG_DEBUG("%s: Error getting VOC's state", sensorName); - return false; - } - memcpy(vocState, stateBuffer, SEN5X_VOC_STATE_BUFFER_SIZE); - - // Print the state (if debug is on) - LOG_DEBUG("%s: VOC state from sensor: [%u, %u, %u, %u, %u, %u, %u, %u]", sensorName, vocState[0], vocState[1], vocState[2], - vocState[3], vocState[4], vocState[5], vocState[6], vocState[7]); - - return true; -} - -bool SEN5XSensor::loadState() -{ -#ifdef FSCom - spiLock->lock(); - auto file = FSCom.open(sen5XStateFileName, FILE_O_READ); - bool okay = false; - if (file) { - LOG_INFO("%s: state read from %s", sensorName, sen5XStateFileName); - pb_istream_t stream = {&readcb, &file, meshtastic_SEN5XState_size}; - - if (!pb_decode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate)) { - LOG_ERROR("%s: can't decode protobuf %s", sensorName, PB_GET_ERROR(&stream)); - } else { - lastCleaning = sen5xstate.last_cleaning_time; - lastCleaningValid = sen5xstate.last_cleaning_valid; - oneShotMode = sen5xstate.one_shot_mode; - - if (model != SEN50) { - vocTime = sen5xstate.voc_state_time; - vocValid = sen5xstate.voc_state_valid; - // Unpack state - vocState[7] = (uint8_t)(sen5xstate.voc_state_array >> 56); - vocState[6] = (uint8_t)(sen5xstate.voc_state_array >> 48); - vocState[5] = (uint8_t)(sen5xstate.voc_state_array >> 40); - vocState[4] = (uint8_t)(sen5xstate.voc_state_array >> 32); - vocState[3] = (uint8_t)(sen5xstate.voc_state_array >> 24); - vocState[2] = (uint8_t)(sen5xstate.voc_state_array >> 16); - vocState[1] = (uint8_t)(sen5xstate.voc_state_array >> 8); - vocState[0] = (uint8_t)sen5xstate.voc_state_array; - } - - // LOG_DEBUG("Loaded lastCleaning %u", lastCleaning); - // LOG_DEBUG("Loaded lastCleaningValid %u", lastCleaningValid); - // LOG_DEBUG("Loaded oneShotMode %s", oneShotMode ? "true" : "false"); - // LOG_DEBUG("Loaded vocTime %u", vocTime); - // LOG_DEBUG("Loaded [%u, %u, %u, %u, %u, %u, %u, %u]", - // vocState[7], vocState[6], vocState[5], vocState[4], vocState[3], vocState[2], vocState[1], vocState[0]); - // LOG_DEBUG("Loaded %svalid VOC state", vocValid ? "" : "in"); - - okay = true; - } - file.close(); - } else { - LOG_INFO("%s: No state found (File: %s)", sensorName, sen5XStateFileName); - } - spiLock->unlock(); - return okay; -#else - LOG_ERROR("%s: Filesystem not implemented", sensorName); - return false; -#endif -} - -bool SEN5XSensor::saveState() -{ -#ifdef FSCom - auto file = SafeFile(sen5XStateFileName); - - sen5xstate.last_cleaning_time = lastCleaning; - sen5xstate.last_cleaning_valid = lastCleaningValid; - sen5xstate.one_shot_mode = oneShotMode; - - if (model != SEN50) { - sen5xstate.has_voc_state_time = true; - sen5xstate.has_voc_state_valid = true; - sen5xstate.has_voc_state_array = true; - - sen5xstate.voc_state_time = vocTime; - sen5xstate.voc_state_valid = vocValid; - // Unpack state (8 bytes) - sen5xstate.voc_state_array = (((uint64_t)vocState[7]) << 56) | ((uint64_t)vocState[6] << 48) | - ((uint64_t)vocState[5] << 40) | ((uint64_t)vocState[4] << 32) | - ((uint64_t)vocState[3] << 24) | ((uint64_t)vocState[2] << 16) | - ((uint64_t)vocState[1] << 8) | ((uint64_t)vocState[0]); - } - - bool okay = false; - - LOG_INFO("%s: state write to %s", sensorName, sen5XStateFileName); - pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN5XState_size}; - - if (!pb_encode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate)) { - LOG_ERROR("%s: can't encode protobuf %s", sensorName, PB_GET_ERROR(&stream)); - } else { - okay = true; - } - - okay &= file.close(); - - if (okay) - LOG_INFO("%s: state write to %s OK", sensorName, sen5XStateFileName); - - return okay; -#else - LOG_ERROR("%s: Filesystem not implemented", sensorName); - return false; -#endif -} - -bool SEN5XSensor::isActive() -{ - return state == SEN5X_MEASUREMENT || state == SEN5X_MEASUREMENT_2; -} - -uint32_t SEN5XSensor::wakeUp() -{ - - LOG_TRACE("%s Waking", sensorName); - - if (!sendCommand(SEN5X_START_MEASUREMENT)) { - LOG_ERROR("%s: Error starting measurement", sensorName); - // TODO - what should this return?? Something actually on the default interval? - return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; - } - delay(50); // From Sensirion Datasheet - - // TODO - This is currently "problematic" - // If time is updated in between reads, there is no way to - // keep track of how long it has passed - pmMeasureStarted = getTime(); - state = SEN5X_MEASUREMENT; - LOG_INFO("%s: Started measurement mode", sensorName); - return SEN5X_WARMUP_MS_1; -} - -bool SEN5XSensor::vocStateStable() -{ - uint32_t now; - now = getTime(); - uint32_t sinceFirstMeasureStarted = (now - rhtGasMeasureStarted); - LOG_TRACE("%s: sinceFirstMeasureStarted: %us", sensorName, sinceFirstMeasureStarted); - return sinceFirstMeasureStarted > SEN5X_VOC_STATE_WARMUP_S; -} - -bool SEN5XSensor::startCleaning() -{ - // Note: we only should enter here if we have a valid RTC with at least - // RTCQuality::RTCQualityDevice - state = SEN5X_CLEANING; - - // Note that cleaning command can only be run when the sensor is in measurement mode - if (!sendCommand(SEN5X_START_MEASUREMENT)) { - LOG_ERROR("%s: Error starting measurement mode", sensorName); - return false; - } - delay(50); // From Sensirion Datasheet - - if (!sendCommand(SEN5X_START_FAN_CLEANING)) { - LOG_ERROR("%s: Error starting fan cleaning", sensorName); - return false; - } - delay(20); // From Sensirion Datasheet - - // This message will be always printed so the user knows the device it's not hung - LOG_INFO("%s: Started fan cleaning (10 sec)", sensorName); - - uint32_t started = millis(); - while (millis() - started < 10500) { - delay(500); - } - LOG_INFO("%s: Cleaning done", sensorName); - - // Save timestamp in flash so we know when a week has passed - uint32_t now; - now = getValidTime(RTCQuality::RTCQualityDevice); - // If time is not RTCQualityNone, it will return non-zero - lastCleaning = now; - lastCleaningValid = true; - saveState(); - - idle(); - return true; -} - -bool SEN5XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) -{ - state = SEN5X_NOT_DETECTED; - LOG_INFO("%s: Init sensor", sensorName); - - _bus = bus; - _address = dev->address.address; -#ifdef SEN5X_I2C_CLOCK_SPEED - _port = dev->address.port; - reClockI2C.setup(_bus, _port); -#endif /* SEN5X_I2C_CLOCK_SPEED */ - - delay(50); // without this there is an error on the deviceReset function - - if (!sendCommand(SEN5X_RESET)) { - LOG_ERROR("%s: error resetting device", sensorName); - return false; - } - delay(200); // From Sensirion Datasheet - - if (!findModel()) { - LOG_ERROR("%s: error finding sensor model", sensorName); - return false; - } - - // Check the firmware version - if (!getVersion()) - return false; - if (firmwareVer < 2) { - LOG_ERROR("%s: firmware too old, unsupported", sensorName); - return false; - } - delay(200); // From Sensirion Datasheet - - // Detection succeeded - state = SEN5X_IDLE; - status = 1; - - // Load state - loadState(); - - // Check if it is time to do a cleaning - uint32_t now; - int32_t passed = 0; - now = getValidTime(RTCQuality::RTCQualityDevice); - - // If time is not RTCQualityNone, it will return non-zero - if (now) { - if (lastCleaningValid) { - - passed = now - lastCleaning; // in seconds - - if (passed > ONE_WEEK_IN_SECONDS && (now > SEN5X_VOC_VALID_DATE)) { - // If current date greater than 01/01/2018 (validity check) - LOG_INFO("%s: Over a week (%us) since last cleaning (%us), trigger cleaning", sensorName, passed, lastCleaning); - startCleaning(); - } else { - LOG_INFO("%s: Cleaning not needed (%ds passed), last cleaning: %us", sensorName, passed, lastCleaning); - } - } else { - // We assume the device has just been updated or it is new, - // so no need to trigger a cleaning. - // Just save the timestamp to do a cleaning one week from now. - // Otherwise, we will never trigger cleaning in some cases - lastCleaning = now; - lastCleaningValid = true; - LOG_INFO("%s: No valid last cleaning date, saving now: %us", sensorName, lastCleaning); - saveState(); - } - - if (model != SEN50) { - if (!vocValid) { - LOG_INFO("%s: No valid VOC's state found", sensorName); - } else { - // Check if state is recent - if (vocStateRecent(now)) { - // If current date greater than 01/01/2018 (validity check) - // Send it to the sensor - LOG_INFO("%s: VOC state is valid and recent", sensorName); - vocStateToSensor(); - } else { - LOG_INFO("%s: VOC state too old or date invalid", sensorName); - LOG_DEBUG("%s: vocTime %u, Passed %u, and now %u", sensorName, vocTime, passed, now); - } - } - } - } else { - // TODO - Should this actually ignore? We could end up never cleaning... - LOG_INFO("%s: Not enough RTCQuality, ignoring saved cleaning and VOC state", sensorName); - } - - idle(false); - rhtGasMeasureStarted = now; - - initI2CSensor(); - return true; -} - -bool SEN5XSensor::readValues() -{ - if (!sendCommand(SEN5X_READ_VALUES)) { - LOG_ERROR("%s: Error sending read command", sensorName); - return false; - } - LOG_TRACE("%s: Reading PM Values", sensorName); - delay(20); // From Sensirion Datasheet - - uint8_t dataBuffer[SEN5X_READ_VALUES_BUFFER_SIZE]{}; - size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_VALUES_BUFFER_SIZE + (SEN5X_READ_VALUES_BUFFER_SIZE / 2)); - if (receivedNumber < SEN5X_READ_VALUES_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting values", sensorName); - return false; - } - - // Get the integers - uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); - uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); - uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); - uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); - - int16_t int_humidity = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); - int16_t int_temperature = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); - int16_t int_vocIndex = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); - int16_t int_noxIndex = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); - - // Convert values based on Sensirion Arduino lib. - // Map values the sensor reports as unavailable (SEN5X_UINT_INVALID / - // SEN5X_INT_INVALID) to the sentinels getMetrics() checks for - sen5xmeasurement.pM1p0 = (uint_pM1p0 != SEN5X_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX; - sen5xmeasurement.pM2p5 = (uint_pM2p5 != SEN5X_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX; - sen5xmeasurement.pM4p0 = (uint_pM4p0 != SEN5X_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX; - sen5xmeasurement.pM10p0 = (uint_pM10p0 != SEN5X_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX; - sen5xmeasurement.humidity = (int_humidity != SEN5X_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX; - sen5xmeasurement.temperature = (int_temperature != SEN5X_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX; - sen5xmeasurement.vocIndex = (int_vocIndex != SEN5X_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX; - sen5xmeasurement.noxIndex = (int_noxIndex != SEN5X_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX; - - LOG_TRACE("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, sen5xmeasurement.pM1p0, - sen5xmeasurement.pM2p5, sen5xmeasurement.pM4p0, sen5xmeasurement.pM10p0); - - if (model != SEN50) { - LOG_TRACE("%s: Got readings: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", sensorName, sen5xmeasurement.humidity, - sen5xmeasurement.temperature, sen5xmeasurement.vocIndex); - } - - if (model == SEN55) { - LOG_TRACE("%s: Got readings: noxIndex=%.2f", sensorName, sen5xmeasurement.noxIndex); - } - - return true; -} - -bool SEN5XSensor::readPNValues(bool cumulative) -{ - if (!sendCommand(SEN5X_READ_PM_VALUES)) { - LOG_ERROR("%s: Error sending read command", sensorName); - return false; - } - - LOG_TRACE("%s: Reading PN Values", sensorName); - delay(20); // From Sensirion Datasheet - - uint8_t dataBuffer[SEN5X_READ_PM_BUFFER_SIZE]{}; - size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_PM_BUFFER_SIZE + (SEN5X_READ_PM_BUFFER_SIZE / 2)); - if (receivedNumber < SEN5X_READ_PM_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting PN values", sensorName); - return false; - } - - // Get the integers - // uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); - // uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); - // uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); - // uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); - uint16_t uint_pN0p5 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); - uint16_t uint_pN1p0 = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); - uint16_t uint_pN2p5 = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); - uint16_t uint_pN4p0 = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); - uint16_t uint_pN10p0 = static_cast((dataBuffer[16] << 8) | dataBuffer[17]); - uint16_t uint_tSize = static_cast((dataBuffer[18] << 8) | dataBuffer[19]); - - // Convert values based on Sensirion Arduino lib. - // Raw PN values are #/cm3 with 0.1 resolution; multiplying by 10 - // converts to #/0.1l without the truncation of dividing first. - // Map values the sensor reports as unavailable (SEN5X_UINT_INVALID) to the - // sentinels getMetrics() checks for - sen5xmeasurement.pN0p5 = (uint_pN0p5 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX; - sen5xmeasurement.pN1p0 = (uint_pN1p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX; - sen5xmeasurement.pN2p5 = (uint_pN2p5 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX; - sen5xmeasurement.pN4p0 = (uint_pN4p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX; - sen5xmeasurement.pN10p0 = (uint_pN10p0 != SEN5X_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX; - sen5xmeasurement.tSize = (uint_tSize != SEN5X_UINT_INVALID) ? (uint_tSize / 1000.0f) : FLT_MAX; - - // Remove accumuluative values: - // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85 - if (!cumulative) { - if (sen5xmeasurement.pN10p0 != UINT32_MAX && sen5xmeasurement.pN4p0 != UINT32_MAX) - sen5xmeasurement.pN10p0 -= sen5xmeasurement.pN4p0; - if (sen5xmeasurement.pN4p0 != UINT32_MAX && sen5xmeasurement.pN2p5 != UINT32_MAX) - sen5xmeasurement.pN4p0 -= sen5xmeasurement.pN2p5; - if (sen5xmeasurement.pN2p5 != UINT32_MAX && sen5xmeasurement.pN1p0 != UINT32_MAX) - sen5xmeasurement.pN2p5 -= sen5xmeasurement.pN1p0; - if (sen5xmeasurement.pN1p0 != UINT32_MAX && sen5xmeasurement.pN0p5 != UINT32_MAX) - sen5xmeasurement.pN1p0 -= sen5xmeasurement.pN0p5; - } - - LOG_TRACE("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", sensorName, - sen5xmeasurement.pN0p5, sen5xmeasurement.pN1p0, sen5xmeasurement.pN2p5, sen5xmeasurement.pN4p0, - sen5xmeasurement.pN10p0, sen5xmeasurement.tSize); - - return true; -} - -uint8_t SEN5XSensor::getMeasurements() -{ - uint32_t now; - now = getTime(); - - // Try to get new data - if (!sendCommand(SEN5X_READ_DATA_READY)) { - LOG_ERROR("%s: Error sending command data ready flag", sensorName); - return 2; - } - delay(20); // From Sensirion Datasheet - - uint8_t dataReadyBuffer[SEN5X_DATA_READY_BUFFER_SIZE]{}; - size_t charNumber = readBuffer(&dataReadyBuffer[0], SEN5X_DATA_READY_BUFFER_SIZE + (SEN5X_DATA_READY_BUFFER_SIZE / 2)); - if (charNumber < SEN5X_DATA_READY_BUFFER_SIZE) { - LOG_ERROR("%s: Error getting data ready flag value", sensorName); - return 2; - } - - bool dataReady = dataReadyBuffer[1]; - uint32_t sinceLastDataPollMs = (now - lastDataPoll) * 1000; - // Check if data is ready, and if since last time we requested is less than SEN5X_POLL_INTERVAL - if (!dataReady && (sinceLastDataPollMs > SEN5X_POLL_INTERVAL)) { - LOG_INFO("%s: Data is not ready", sensorName); - return 1; - } - - if (!readValues()) { - LOG_ERROR("%s: Error getting readings", sensorName); - return 2; - } - - if (!readPNValues(false)) { - LOG_ERROR("%s: Error getting PN readings", sensorName); - return 2; - } - - lastDataPoll = now; - - return 0; -} - -int32_t SEN5XSensor::wakeUpTimeMs() -{ - return SEN5X_WARMUP_MS_2; -} - -int32_t SEN5XSensor::pendingForReadyMs() -{ - uint32_t now; - now = getTime(); - uint32_t sincePmMeasureStarted = (now - pmMeasureStarted) * 1000; - LOG_TRACE("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); - - switch (state) { - case SEN5X_MEASUREMENT: { - - if (sincePmMeasureStarted < SEN5X_WARMUP_MS_1) { - LOG_INFO("%s: not enough time since measurement start", sensorName); - return SEN5X_WARMUP_MS_1 - sincePmMeasureStarted; - } - - if (!pmMeasureStarted) { - pmMeasureStarted = now; - } - - // Get PN values to check if we are above or below threshold - readPNValues(true); - lastDataPoll = now; - - // If the reading is low (the tyhreshold is in #/cm3) and second warmUp hasn't passed we return to come back later - if ((sen5xmeasurement.pN4p0 / 100) < SEN5X_PN4P0_CONC_THD && sincePmMeasureStarted < SEN5X_WARMUP_MS_2) { - LOG_INFO("%s: Concentration low, will ask again in second warm up period", sensorName); - state = SEN5X_MEASUREMENT_2; - // Report how many seconds are pending to cover the first warm up period - return SEN5X_WARMUP_MS_2 - sincePmMeasureStarted; - } - return 0; - } - case SEN5X_MEASUREMENT_2: { - if (sincePmMeasureStarted < SEN5X_WARMUP_MS_2) { - // Report how many seconds are pending to cover the first warm up period - return SEN5X_WARMUP_MS_2 - sincePmMeasureStarted; - } - return 0; - } - default: { - return -1; - } - } -} - -bool SEN5XSensor::getMetrics(meshtastic_Telemetry *measurement) -{ - LOG_INFO("%s: Get metrics", sensorName); - if (!isActive()) { - LOG_INFO("%s: Not in measurement mode", sensorName); - return false; - } - - uint8_t response; - response = getMeasurements(); - - if (response == 0) { - if (sen5xmeasurement.pM1p0 != UINT16_MAX) { - measurement->variant.air_quality_metrics.has_pm10_standard = true; - measurement->variant.air_quality_metrics.pm10_standard = sen5xmeasurement.pM1p0; - } - if (sen5xmeasurement.pM2p5 != UINT16_MAX) { - measurement->variant.air_quality_metrics.has_pm25_standard = true; - measurement->variant.air_quality_metrics.pm25_standard = sen5xmeasurement.pM2p5; - } - if (sen5xmeasurement.pM4p0 != UINT16_MAX) { - measurement->variant.air_quality_metrics.has_pm40_standard = true; - measurement->variant.air_quality_metrics.pm40_standard = sen5xmeasurement.pM4p0; - } - if (sen5xmeasurement.pM10p0 != UINT16_MAX) { - measurement->variant.air_quality_metrics.has_pm100_standard = true; - measurement->variant.air_quality_metrics.pm100_standard = sen5xmeasurement.pM10p0; - } - if (sen5xmeasurement.pN0p5 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_05um = true; - measurement->variant.air_quality_metrics.particles_05um = sen5xmeasurement.pN0p5; - } - if (sen5xmeasurement.pN1p0 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_10um = true; - measurement->variant.air_quality_metrics.particles_10um = sen5xmeasurement.pN1p0; - } - if (sen5xmeasurement.pN2p5 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_25um = true; - measurement->variant.air_quality_metrics.particles_25um = sen5xmeasurement.pN2p5; - } - if (sen5xmeasurement.pN4p0 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_40um = true; - measurement->variant.air_quality_metrics.particles_40um = sen5xmeasurement.pN4p0; - } - if (sen5xmeasurement.pN10p0 != UINT32_MAX) { - measurement->variant.air_quality_metrics.has_particles_100um = true; - measurement->variant.air_quality_metrics.particles_100um = sen5xmeasurement.pN10p0; - } - if (sen5xmeasurement.tSize != FLT_MAX) { - measurement->variant.air_quality_metrics.has_particles_tps = true; - measurement->variant.air_quality_metrics.particles_tps = sen5xmeasurement.tSize; - } - - if (model != SEN50) { - if (sen5xmeasurement.humidity != FLT_MAX) { - measurement->variant.air_quality_metrics.has_pm_humidity = true; - measurement->variant.air_quality_metrics.pm_humidity = sen5xmeasurement.humidity; - } - if (sen5xmeasurement.temperature != FLT_MAX) { - measurement->variant.air_quality_metrics.has_pm_temperature = true; - measurement->variant.air_quality_metrics.pm_temperature = sen5xmeasurement.temperature; - } - if (sen5xmeasurement.vocIndex != FLT_MAX) { - measurement->variant.air_quality_metrics.has_pm_voc_idx = true; - measurement->variant.air_quality_metrics.pm_voc_idx = sen5xmeasurement.vocIndex; - } - } - - if (model == SEN55) { - if (sen5xmeasurement.noxIndex != FLT_MAX) { - measurement->variant.air_quality_metrics.has_pm_nox_idx = true; - measurement->variant.air_quality_metrics.pm_nox_idx = sen5xmeasurement.noxIndex; - } - } - - return true; - } else if (response == 1) { - // TODO return because data was not ready yet - // Should this return false? - idle(); - return false; - } else if (response == 2) { - // Return with error for non-existing data - idle(); - return false; - } - - return true; -} - -void SEN5XSensor::setMode(bool setOneShot) -{ - oneShotMode = setOneShot; - if (oneShotMode) { - LOG_INFO("%s: set one shot mode", sensorName); - } else { - LOG_INFO("%s: set continuous mode", sensorName); - } -} - -AdminMessageHandleResult SEN5XSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, - meshtastic_AdminMessage *response) -{ - AdminMessageHandleResult result; - result = AdminMessageHandleResult::NOT_HANDLED; - - switch (request->which_payload_variant) { - case meshtastic_AdminMessage_sensor_config_tag: - if (!request->sensor_config.has_sen5x_config) { - result = AdminMessageHandleResult::NOT_HANDLED; - break; - } - - // Check for one-shot/continuous mode request - if (request->sensor_config.sen5x_config.has_set_one_shot_mode) { - this->setMode(request->sensor_config.sen5x_config.set_one_shot_mode); - } - - // TODO - Add admin command to set temperature offset? - // Check for temperature offset - // if (request->sensor_config.sen5x_config.has_set_temperature) { - // this->setTemperature(request->sensor_config.sen5x_config.set_temperature); - // } - - // TODO - Add admin command to trigger fan cleaning? - // Check for one-shot/continuous mode request - // if (request->sensor_config.sen5x_config.has_fan_cleaning && request->sensor_config.sen5x_config.fan_cleaning) { - // this->startCleaning(); - // } - - result = AdminMessageHandleResult::HANDLED; - break; - - default: - result = AdminMessageHandleResult::NOT_HANDLED; - } - - return result; -} -#endif diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.h b/src/modules/Telemetry/Sensor/SEN5XSensor.h index eeebbd373..2c8aaf524 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.h +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.h @@ -1,200 +1,17 @@ +#pragma once #include "configuration.h" #if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR -#include "../detect/ReClockI2C.h" -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "TelemetrySensor.h" -#include "Wire.h" -#include "gps/RTC.h" +#include "SENXXSensor.h" -// Warm up times for SEN5X from the datasheet -#ifndef SEN5X_WARMUP_MS_1 -#define SEN5X_WARMUP_MS_1 15000 -#endif - -#ifndef SEN5X_WARMUP_MS_2 -#define SEN5X_WARMUP_MS_2 30000 -#endif - -#ifndef SEN5X_POLL_INTERVAL -#define SEN5X_POLL_INTERVAL 1000 -#endif - -#ifndef SEN5X_I2C_CLOCK_SPEED -#define SEN5X_I2C_CLOCK_SPEED 100000 -#endif - -/* -Time after which the sensor can go to sleep, as the warmup period has passed -and the VOCs sensor will is allowed to stop (although needs to recover the state -each time) -*/ -#ifndef SEN5X_VOC_STATE_WARMUP_S -/* Note for Testing 5' is enough -Sensirion recommends 1h -This can be bypassed completely if switching to low-power RHT/Gas mode and setting -SEN5X_VOC_STATE_WARMUP_S 0 -*/ -#define SEN5X_VOC_STATE_WARMUP_S 3600 -#endif - -#define ONE_WEEK_IN_SECONDS 604800 - -struct _SEN5XMeasurements { - uint16_t pM1p0; - uint16_t pM2p5; - uint16_t pM4p0; - uint16_t pM10p0; - uint32_t pN0p5; - uint32_t pN1p0; - uint32_t pN2p5; - uint32_t pN4p0; - uint32_t pN10p0; - float tSize; - float humidity; - float temperature; - float vocIndex; - float noxIndex; -}; - -class SEN5XSensor : public TelemetrySensor +// Thin identity wrapper around SENXXSensor for the SEN5X family (SEN50/54/55, +// I2C address SEN5X_ADDR / 0x69). All protocol/state-machine logic lives in +// SENXXSensor; the exact model is auto-detected in probe()/initDevice(). +class SEN5XSensor : public SENXXSensor { - private: -#ifdef SEN5X_I2C_CLOCK_SPEED - ReClockI2C reClockI2C; -#endif - - bool getVersion(); - float firmwareVer = -1; - float hardwareVer = -1; - float protocolVer = -1; - bool findModel(); - -// Commands -#define SEN5X_RESET 0xD304 -#define SEN5X_GET_PRODUCT_NAME 0xD014 -#define SEN5X_GET_FIRMWARE_VERSION 0xD100 -#define SEN5X_START_MEASUREMENT 0x0021 -#define SEN5X_START_MEASUREMENT_RHT_GAS 0x0037 -#define SEN5X_STOP_MEASUREMENT 0x0104 -#define SEN5X_READ_DATA_READY 0x0202 -#define SEN5X_START_FAN_CLEANING 0x5607 -#define SEN5X_RW_VOCS_STATE 0x6181 - -#define SEN5X_READ_VALUES 0x03C4 -#define SEN5X_READ_RAW_VALUES 0x03D2 -#define SEN5X_READ_PM_VALUES 0x0413 - -// Values the sensor reports when a reading is unavailable -#define SEN5X_UINT_INVALID 0xFFFF -#define SEN5X_INT_INVALID 0x7FFF - -// Reply payload sizes in data bytes; the raw I2C transfer adds one CRC byte -// per 2-byte group, so requests are + / 2 raw bytes -#define SEN5X_VERSION_BUFFER_SIZE 8 -#define SEN5X_PRODUCT_NAME_BUFFER_SIZE 32 -#define SEN5X_DATA_READY_BUFFER_SIZE 2 -#define SEN5X_READ_VALUES_BUFFER_SIZE 16 -#define SEN5X_READ_PM_BUFFER_SIZE 20 - -#define SEN5X_VOC_VALID_TIME 600 -#define SEN5X_VOC_VALID_DATE 1514764800 - - enum SEN5Xmodel { SEN5X_UNKNOWN = 0, SEN50 = 0b001, SEN54 = 0b010, SEN55 = 0b100 }; - SEN5Xmodel model = SEN5X_UNKNOWN; - - enum SEN5XState { - SEN5X_OFF, - SEN5X_IDLE, - SEN5X_RHTGAS_ONLY, - SEN5X_MEASUREMENT, - SEN5X_MEASUREMENT_2, - SEN5X_CLEANING, - SEN5X_NOT_DETECTED - }; - SEN5XState state = SEN5X_OFF; - // Flag to work on one-shot (read and sleep), or continuous mode - bool oneShotMode = true; - void setMode(bool setOneShot); - bool vocStateValid(); -/* Sensirion recommends taking a reading after 15 seconds, -if the Particle number reading is over 100#/cm3 the reading is OK, -but if it is lower wait until 30 seconds and take it again. -See: https://sensirion.com/resource/application_note/low_power_mode/sen5x -*/ -#define SEN5X_PN4P0_CONC_THD 100 - - bool sendCommand(uint16_t command); - /** - * @brief Send a command word followed by a data payload; a CRC byte is - * computed and inserted on the wire after every 2-byte pair. - * @param command 16-bit command code, sent big-endian - * @param buffer payload data bytes, without CRCs - * @param byteNumber payload size in data bytes; must be even - * @return true when the full transfer is written and acknowledged - */ - bool sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber = 0); - /** - * @brief Read a reply, verifying and stripping the interleaved CRC bytes. - * @param buffer destination for the data bytes (byteNumber * 2 / 3 of them) - * @param byteNumber raw transfer size including CRCs; must be a multiple - * of 3 (2 data bytes + 1 CRC per group) - * @return the number of data bytes written to buffer, or 0 on any error - */ - uint8_t readBuffer(uint8_t *buffer, uint8_t byteNumber); - uint8_t sen5xCRC(const uint8_t *buffer); - bool startCleaning(); - uint8_t getMeasurements(); - // bool readRawValues(); - bool readPNValues(bool cumulative); - bool readValues(); - - uint32_t pmMeasureStarted = 0; - uint32_t rhtGasMeasureStarted = 0; - uint32_t lastDataPoll = 0; - _SEN5XMeasurements sen5xmeasurement{}; - - bool idle(bool checkState = true); - - protected: - // Store status of the sensor in this file - const char *sen5XStateFileName = "/prefs/sen5X.dat"; - meshtastic_SEN5XState sen5xstate = meshtastic_SEN5XState_init_zero; - - bool loadState(); - bool saveState(); - - // Cleaning State - uint32_t lastCleaning = 0; - bool lastCleaningValid = false; - -// VOC State -#define SEN5X_VOC_STATE_BUFFER_SIZE 8 - uint8_t vocState[SEN5X_VOC_STATE_BUFFER_SIZE]{}; - uint32_t vocTime = 0; - bool vocValid = false; - - bool vocStateFromSensor(); - bool vocStateToSensor(); - bool vocStateStable(); - bool vocStateRecent(uint32_t now); - public: - SEN5XSensor(); - bool probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port); - virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; - virtual bool getMetrics(meshtastic_Telemetry *measurement) override; - - virtual bool isActive() override; - virtual void sleep() override; - virtual uint32_t wakeUp() override; - virtual bool canSleep() override { return true; } - virtual int32_t wakeUpTimeMs() override; - virtual int32_t pendingForReadyMs() override; - - AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, - meshtastic_AdminMessage *response) override; + SEN5XSensor() : SENXXSensor(meshtastic_TelemetrySensorType_SEN5X, "SEN5X") { senXXStateFileName = "/prefs/sen5X.dat"; } }; #endif diff --git a/src/modules/Telemetry/Sensor/SEN6XSensor.h b/src/modules/Telemetry/Sensor/SEN6XSensor.h new file mode 100644 index 000000000..ea624960e --- /dev/null +++ b/src/modules/Telemetry/Sensor/SEN6XSensor.h @@ -0,0 +1,18 @@ +#pragma once +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + +#include "SENXXSensor.h" + +// Thin identity wrapper around SENXXSensor for the SEN6X family (SEN62, SEN63C, +// SEN65, SEN66, SEN68, SEN69C - I2C address SEN6X_ADDR / 0x6B). All +// protocol/state-machine logic lives in SENXXSensor; the exact model is +// auto-detected in probe()/initDevice(). +class SEN6XSensor : public SENXXSensor +{ + public: + SEN6XSensor() : SENXXSensor(meshtastic_TelemetrySensorType_SEN6X, "SEN6X") { senXXStateFileName = "/prefs/sen6X.dat"; } +}; + +#endif diff --git a/src/modules/Telemetry/Sensor/SENXXSensor.cpp b/src/modules/Telemetry/Sensor/SENXXSensor.cpp new file mode 100644 index 000000000..42b8a4ae3 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SENXXSensor.cpp @@ -0,0 +1,1614 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "FSCommon.h" +#include "SENXXSensor.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "TelemetrySensor.h" +#include // FLT_MAX +#include +#include +#include // memcpy + +bool SENXXSensor::getVersion() +{ + if (!sendCommand(SENXX_GET_FIRMWARE_VERSION)) { + LOG_ERROR("%s: Error sending version command", sensorName); + return false; + } + delay(20); // From Sensirion Datasheet + + // Version reply layout: fw major/minor, fw debug, hw major/minor, + // protocol major/minor, padding + uint8_t versionBuffer[SENXX_VERSION_BUFFER_SIZE]{}; + size_t charNumber = readBuffer(&versionBuffer[0], SENXX_VERSION_BUFFER_SIZE + (SENXX_VERSION_BUFFER_SIZE / 2)); + if (charNumber < SENXX_VERSION_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting device version value", sensorName); + return false; + } + + firmwareVer = versionBuffer[0] + (versionBuffer[1] / 10.0f); + hardwareVer = versionBuffer[3] + (versionBuffer[4] / 10.0f); + protocolVer = versionBuffer[5] + (versionBuffer[6] / 10.0f); + + LOG_INFO("%s: Firmware Version: %0.2f", sensorName, firmwareVer); + LOG_INFO("%s: Hardware Version: %0.2f", sensorName, hardwareVer); + LOG_INFO("%s: Protocol Version: %0.2f", sensorName, protocolVer); + + return true; +} + +void SENXXSensor::updateCapabilities() +{ + hasRHT = hasVOC = hasNOx = hasCO2 = hasHCHO = false; + readMeasuredValuesCmd = 0; + + switch (model) { + case SEN50: + break; + case SEN54: + hasRHT = true; + hasVOC = true; + break; + case SEN55: + hasRHT = true; + hasVOC = true; + hasNOx = true; + break; + case SEN62: + hasRHT = true; + readMeasuredValuesCmd = 0x04A3; + break; + case SEN63C: + hasRHT = true; + hasCO2 = true; + readMeasuredValuesCmd = 0x0471; + break; + case SEN65: + hasRHT = true; + hasVOC = true; + hasNOx = true; + readMeasuredValuesCmd = 0x0446; + break; + case SEN66: + hasRHT = true; + hasVOC = true; + hasNOx = true; + hasCO2 = true; + readMeasuredValuesCmd = 0x0300; + break; + case SEN68: + hasRHT = true; + hasVOC = true; + hasNOx = true; + hasHCHO = true; + readMeasuredValuesCmd = 0x0467; + break; + case SEN69C: + hasRHT = true; + hasVOC = true; + hasNOx = true; + hasHCHO = true; + hasCO2 = true; + readMeasuredValuesCmd = 0x04B5; + break; + default: + break; + } +} + +bool SENXXSensor::findModel() +{ + if (!sendCommand(SENXX_GET_PRODUCT_NAME)) { + LOG_ERROR("%s: Error asking for product name", sensorName); + return false; + } + delay(50); // From Sensirion Datasheet + + uint8_t name[SENXX_PRODUCT_NAME_BUFFER_SIZE]{}; + size_t charNumber = readBuffer(&name[0], SENXX_PRODUCT_NAME_BUFFER_SIZE + (SENXX_PRODUCT_NAME_BUFFER_SIZE / 2)); + + if (charNumber < SENXX_PRODUCT_NAME_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting device name", sensorName); + return false; + } + + // Every model's product name follows "SEN[C]", + // e.g. "SEN50", "SEN55", "SEN63C", "SEN69C" - so name[3] picks the family + // (SEN5X vs SEN6X) and name[4] picks the exact variant within it. + model = SENXX_UNKNOWN; + if (name[3] == '5') { + switch (name[4]) { + case '0': + model = SEN50; + break; + case '4': + model = SEN54; + break; + case '5': + model = SEN55; + break; + } + } else if (name[3] == '6') { + switch (name[4]) { + case '2': + model = SEN62; + break; + case '3': + model = SEN63C; + break; + case '5': + model = SEN65; + break; + case '6': + model = SEN66; + break; + case '8': + model = SEN68; + break; + case '9': + model = SEN69C; + break; + } + } + + if (model == SENXX_UNKNOWN) { + return false; + } + + updateCapabilities(); + LOG_INFO("%s: found sensor model %s", sensorName, (const char *)name); + return true; +} + +bool SENXXSensor::probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port) +{ + LOG_INFO("%s: probing sensor", sensorName); + + _bus = bus; + _address = address; + +#ifdef SENXX_I2C_CLOCK_SPEED + _port = port; + reClockI2C.setup(_bus, _port); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + if (!findModel()) { + LOG_DEBUG("%s: can't find sensor model", sensorName); + return false; + } + + return true; +} + +bool SENXXSensor::sendCommand(uint16_t command) +{ + uint8_t nothing; + return sendCommand(command, ¬hing, 0); +} + +bool SENXXSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber) +{ + // At least we need two bytes for the command + uint8_t bufferSize = 2; + + // Add space for CRC bytes (one every two bytes) + if (byteNumber > 0) + bufferSize += byteNumber + (byteNumber / 2); + + uint8_t toSend[bufferSize]; + uint8_t i = 0; + toSend[i++] = static_cast((command & 0xFF00) >> 8); + toSend[i++] = static_cast((command & 0x00FF) >> 0); + + // Prepare buffer with CRC every third byte + uint8_t bi = 0; + if (byteNumber > 0) { + while (bi < byteNumber) { + toSend[i++] = buffer[bi++]; + toSend[i++] = buffer[bi++]; + uint8_t calcCRC = senxxCRC(&buffer[bi - 2]); + toSend[i++] = calcCRC; + } + } + +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, SENXX_I2C_CLOCK_SPEED); + reClockI2C.setClock(SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + // Transmit the data + // Note: this delay is necessary to allow for long-buffers + delay(20); + _bus->beginTransmission(_address); + size_t writtenBytes = _bus->write(toSend, bufferSize); + uint8_t i2c_error = _bus->endTransmission(); + +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: restoring clock speed", sensorName); + reClockI2C.restoreClock(); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + if (writtenBytes != bufferSize) { + LOG_ERROR("%s: Error writing on I2C bus", sensorName); + return false; + } + + if (i2c_error != 0) { + LOG_ERROR("%s: Error on I2C communication: %x", sensorName, i2c_error); + return false; + } + return true; +} + +uint8_t SENXXSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) +{ +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, SENXX_I2C_CLOCK_SPEED); + reClockI2C.setClock(SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + size_t readBytes = _bus->requestFrom(_address, byteNumber); + if (readBytes != byteNumber) { + LOG_ERROR("%s: Error reading I2C bus", sensorName); +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: restoring clock speed", sensorName); + reClockI2C.restoreClock(); +#endif /* SENXX_I2C_CLOCK_SPEED */ + return 0; + } + + uint8_t i = 0; + uint8_t receivedBytes = 0; + while (readBytes > 0) { + buffer[i++] = _bus->read(); // Just as a reminder: i++ returns i and after that increments. + buffer[i++] = _bus->read(); + uint8_t recvCRC = _bus->read(); + uint8_t calcCRC = senxxCRC(&buffer[i - 2]); + if (recvCRC != calcCRC) { + LOG_ERROR("%s: Checksum error while receiving msg", sensorName); +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: restoring clock speed", sensorName); + reClockI2C.restoreClock(); +#endif /* SENXX_I2C_CLOCK_SPEED */ + return 0; + } + readBytes -= 3; + receivedBytes += 2; + } + +#ifdef SENXX_I2C_CLOCK_SPEED + LOG_DEBUG("%s: restoring clock speed", sensorName); + reClockI2C.restoreClock(); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + return receivedBytes; +} + +uint8_t SENXXSensor::senxxCRC(const uint8_t *buffer) +{ + // This code is based on Sensirion's own implementation + // https://github.com/Sensirion/arduino-core/blob/41fd02cacf307ec4945955c58ae495e56809b96c/src/SensirionCrc.cpp + // Identical CRC8 (poly 0x31, init 0xFF) is used by the whole SEN5X/SEN6X family. + uint8_t crc = 0xff; + + for (uint8_t i = 0; i < 2; i++) { + + crc ^= buffer[i]; + + for (uint8_t bit = 8; bit > 0; bit--) { + if (crc & 0x80) + crc = (crc << 1) ^ 0x31; + else + crc = (crc << 1); + } + } + + return crc; +} + +void SENXXSensor::sleep() +{ + if (state == SENXX_CLEANING) { + // The scheduler's periodic "put idle-able sensors to sleep" housekeeping can reach + // here while a cleaning cycle is still running (isActive() reports SENXX_CLEANING as + // active). Don't let it interrupt the cycle - pendingForReadyMs()/finishCleaning() + // owns the transition out of SENXX_CLEANING. + LOG_INFO("%s: Not going to sleep, fan cleaning is in progress", sensorName); + return; + } + idle(true); +} + +bool SENXXSensor::idle(bool checkState) +{ + // From the datasheet: + // By default, the VOC algorithm resets its state to initial + // values each time a measurement is started, + // even if the measurement was stopped only for a short + // time. So, the VOC index output value needs a long time + // until it is stable again. This can be avoided by + // restoring the previously memorized algorithm state before + // starting the measure mode + + if (checkState) { + // If the stabilisation period is not passed for a model with a VOC sensor, don't go to idle + if (hasVOC) { + // Get VOC state before going to idle mode + vocValid = false; + if (vocStateFromSensor()) { + vocValid = vocStateValid(); + // Check if we have time, and store it + uint32_t now; // If time is RTCQualityNone, it will return zero + now = getValidTime(RTCQuality::RTCQualityDevice); + // Check if state is valid (non-zero) + if (now) { + vocTime = now; + } + } + + if (!(vocStateStable() && vocValid)) { + LOG_INFO("%s: Not stopping measurement, vocState is not stable yet!", sensorName); + return true; + } + } + // Save state and prefs (on all models) + saveState(); + } + + if (!oneShotMode) { + LOG_INFO("%s: Not stopping measurement, continuous mode!", sensorName); + return true; + } else { + LOG_INFO("%s: One shot mode enabled", sensorName); + } + + // SEN6X has no low-power "RHT/Gas only" mode - it must always fully stop. + // Within SEN5X, models without gas sensing (SEN50) also fully stop; SEN54/SEN55 + // instead switch to the RHT/Gas-only mode to keep the VOC engine warm. + // TODO - Decide if for variants with VOC/NOx sensor, the device will be kept on to avoid messing + // up with the engine. In principle, since we are giving the VOC state, the algorithm should work fine, + // however, from tests, we don't see the same. + // Recommendation: if it has VOC / NOx, suggest NOT to use oneShot mode + if (isSen6xFamily() || !hasVOC) { + if (!sendCommand(SENXX_STOP_MEASUREMENT)) { + LOG_ERROR("%s: Error stopping measurement", sensorName); + return false; + } + state = SENXX_IDLE; + LOG_INFO("%s: Stop measurement mode", sensorName); + } else { + if (!sendCommand(SEN5X_START_MEASUREMENT_RHT_GAS)) { + LOG_ERROR("%s: Error switching to RHT/Gas measurement", sensorName); + return false; + } + state = SENXX_RHTGAS_ONLY; + LOG_INFO("%s: Switch to RHT/Gas only measurement mode", sensorName); + } + + delay(200); // From Sensirion Datasheet + pmMeasureStarted = 0; + return true; +} + +bool SENXXSensor::vocStateRecent(uint32_t now) +{ + if (now) { + uint32_t passed = now - vocTime; // in seconds + + // Check if state is recent, less than 10 minutes (600 seconds) + if (passed < SENXX_VOC_VALID_TIME && (now > SENXX_VOC_VALID_DATE)) { + return true; + } + } + return false; +} + +bool SENXXSensor::vocStateValid() +{ + if (!vocState[0] && !vocState[1] && !vocState[2] && !vocState[3] && !vocState[4] && !vocState[5] && !vocState[6] && + !vocState[7]) { + LOG_DEBUG("%s: VOC state is all 0, invalid", sensorName); + return false; + } else { + LOG_DEBUG("%s: VOC state is valid", sensorName); + return true; + } +} + +bool SENXXSensor::vocStateToSensor() +{ + if (!hasVOC) { + return true; + } + + if (!vocStateValid()) { + LOG_INFO("%s: VOC state is invalid, not sending", sensorName); + return true; + } + + if (!sendCommand(SENXX_STOP_MEASUREMENT)) { + LOG_ERROR("%s: Error stopping measurement", sensorName); + return false; + } + delay(200); // From Sensirion Datasheet + + LOG_DEBUG("%s: Sending VOC state to sensor", sensorName); + LOG_DEBUG("[%u, %u, %u, %u, %u, %u, %u, %u]", vocState[0], vocState[1], vocState[2], vocState[3], vocState[4], vocState[5], + vocState[6], vocState[7]); + + // Note: send command already takes into account the CRC + // buffer size increment needed + if (!sendCommand(SENXX_RW_VOCS_STATE, vocState, SENXX_VOC_STATE_BUFFER_SIZE)) { + LOG_ERROR("%s: Error sending VOC's state command", sensorName); + return false; + } + + return true; +} + +bool SENXXSensor::vocStateFromSensor() +{ + if (!hasVOC) { + return true; + } + + LOG_INFO("%s: Getting VOC state from sensor", sensorName); + // Ask VOCs state from the sensor + if (!sendCommand(SENXX_RW_VOCS_STATE)) { + LOG_ERROR("%s: Error sending VOC's state command", sensorName); + return false; + } + + delay(20); // From Sensirion Datasheet + + // Retrieve the data into a staging buffer so a partial read (e.g. a CRC + // failure halfway through) cannot corrupt the current vocState. + // The requested size accounts for the CRC bytes + uint8_t stateBuffer[SENXX_VOC_STATE_BUFFER_SIZE]{}; + size_t receivedNumber = readBuffer(&stateBuffer[0], SENXX_VOC_STATE_BUFFER_SIZE + (SENXX_VOC_STATE_BUFFER_SIZE / 2)); + delay(20); // From Sensirion Datasheet + + if (receivedNumber < SENXX_VOC_STATE_BUFFER_SIZE) { + LOG_DEBUG("%s: Error getting VOC's state", sensorName); + return false; + } + memcpy(vocState, stateBuffer, SENXX_VOC_STATE_BUFFER_SIZE); + + // Print the state (if debug is on) + LOG_DEBUG("%s: VOC state retrieved from sensor: [%u, %u, %u, %u, %u, %u, %u, %u]", sensorName, vocState[0], vocState[1], + vocState[2], vocState[3], vocState[4], vocState[5], vocState[6], vocState[7]); + + return true; +} + +bool SENXXSensor::loadState() +{ +#ifdef FSCom + spiLock->lock(); + auto file = FSCom.open(senXXStateFileName, FILE_O_READ); + bool okay = false; + if (file) { + LOG_INFO("%s: state read from %s", sensorName, senXXStateFileName); + + bool decoded; + uint32_t lastCleaningTime = 0; + bool lastCleaningValidFlag = false; + bool oneShot = true; + uint32_t vocStateTime = 0; + bool vocStateValidFlag = false; + uint64_t vocStateArray = 0; + + if (isSen6xFamily()) { + pb_istream_t stream = {&readcb, &file, meshtastic_SEN6XState_size}; + decoded = pb_decode(&stream, &meshtastic_SEN6XState_msg, &sen6xstate); + if (decoded) { + lastCleaningTime = sen6xstate.last_cleaning_time; + lastCleaningValidFlag = sen6xstate.last_cleaning_valid; + oneShot = sen6xstate.one_shot_mode; + vocStateTime = sen6xstate.voc_state_time; + vocStateValidFlag = sen6xstate.voc_state_valid; + vocStateArray = sen6xstate.voc_state_array; + } else { + LOG_ERROR("%s: can't decode protobuf %s", sensorName, PB_GET_ERROR(&stream)); + } + } else { + pb_istream_t stream = {&readcb, &file, meshtastic_SEN5XState_size}; + decoded = pb_decode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate); + if (decoded) { + lastCleaningTime = sen5xstate.last_cleaning_time; + lastCleaningValidFlag = sen5xstate.last_cleaning_valid; + oneShot = sen5xstate.one_shot_mode; + vocStateTime = sen5xstate.voc_state_time; + vocStateValidFlag = sen5xstate.voc_state_valid; + vocStateArray = sen5xstate.voc_state_array; + } else { + LOG_ERROR("%s: can't decode protobuf %s", sensorName, PB_GET_ERROR(&stream)); + } + } + + if (decoded) { + lastCleaning = lastCleaningTime; + lastCleaningValid = lastCleaningValidFlag; + oneShotMode = oneShot; + + if (hasVOC) { + vocTime = vocStateTime; + vocValid = vocStateValidFlag; + // Unpack state + vocState[7] = (uint8_t)(vocStateArray >> 56); + vocState[6] = (uint8_t)(vocStateArray >> 48); + vocState[5] = (uint8_t)(vocStateArray >> 40); + vocState[4] = (uint8_t)(vocStateArray >> 32); + vocState[3] = (uint8_t)(vocStateArray >> 24); + vocState[2] = (uint8_t)(vocStateArray >> 16); + vocState[1] = (uint8_t)(vocStateArray >> 8); + vocState[0] = (uint8_t)vocStateArray; + } + + okay = true; + } + file.close(); + } else { + LOG_INFO("%s: No state found (File: %s)", sensorName, senXXStateFileName); + } + spiLock->unlock(); + return okay; +#else + LOG_ERROR("%s: Filesystem not implemented", sensorName); + return false; +#endif +} + +bool SENXXSensor::saveState() +{ +#ifdef FSCom + auto file = SafeFile(senXXStateFileName); + + // Pack VOC state (8 bytes) + uint64_t vocStateArray = (((uint64_t)vocState[7]) << 56) | ((uint64_t)vocState[6] << 48) | ((uint64_t)vocState[5] << 40) | + ((uint64_t)vocState[4] << 32) | ((uint64_t)vocState[3] << 24) | ((uint64_t)vocState[2] << 16) | + ((uint64_t)vocState[1] << 8) | ((uint64_t)vocState[0]); + + bool encoded; + LOG_INFO("%s: state write to %s", sensorName, senXXStateFileName); + + if (isSen6xFamily()) { + sen6xstate.last_cleaning_time = lastCleaning; + sen6xstate.last_cleaning_valid = lastCleaningValid; + sen6xstate.one_shot_mode = oneShotMode; + + if (hasVOC) { + sen6xstate.has_voc_state_time = true; + sen6xstate.has_voc_state_valid = true; + sen6xstate.has_voc_state_array = true; + sen6xstate.voc_state_time = vocTime; + sen6xstate.voc_state_valid = vocValid; + sen6xstate.voc_state_array = vocStateArray; + } + + pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN6XState_size}; + encoded = pb_encode(&stream, &meshtastic_SEN6XState_msg, &sen6xstate); + if (!encoded) + LOG_ERROR("%s: can't encode protobuf %s", sensorName, PB_GET_ERROR(&stream)); + } else { + sen5xstate.last_cleaning_time = lastCleaning; + sen5xstate.last_cleaning_valid = lastCleaningValid; + sen5xstate.one_shot_mode = oneShotMode; + + if (hasVOC) { + sen5xstate.has_voc_state_time = true; + sen5xstate.has_voc_state_valid = true; + sen5xstate.has_voc_state_array = true; + sen5xstate.voc_state_time = vocTime; + sen5xstate.voc_state_valid = vocValid; + sen5xstate.voc_state_array = vocStateArray; + } + + pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_SEN5XState_size}; + encoded = pb_encode(&stream, &meshtastic_SEN5XState_msg, &sen5xstate); + if (!encoded) + LOG_ERROR("%s: can't encode protobuf %s", sensorName, PB_GET_ERROR(&stream)); + } + + bool okay = encoded; + okay &= file.close(); + + if (okay) + LOG_INFO("%s: state write to %s successful", sensorName, senXXStateFileName); + + return okay; +#else + LOG_ERROR("%s: Filesystem not implemented", sensorName); + return false; +#endif +} + +bool SENXXSensor::isActive() +{ + // SENXX_CLEANING counts as active so the scheduler polls pendingForReadyMs() + // (which drives the cleaning cycle to completion) instead of calling wakeUp() again. + return state == SENXX_MEASUREMENT || state == SENXX_MEASUREMENT_2 || state == SENXX_CLEANING; +} + +bool SENXXSensor::checkRTCQualityImproved() +{ + RTCQuality currentQuality = getRTCQuality(); + if (currentQuality == lastRTCQuality) { + return false; + } + LOG_DEBUG("%s: RTC quality changed: %s -> %s", sensorName, RtcName(lastRTCQuality), RtcName(currentQuality)); + bool gainedUsableClock = lastRTCQuality < RTCQuality::RTCQualityDevice && currentQuality >= RTCQuality::RTCQualityDevice; + lastRTCQuality = currentQuality; + return gainedUsableClock; +} + +void SENXXSensor::reconcileTimeDependentState(uint32_t now) +{ + if (lastCleaningValid) { + int32_t passed = now - lastCleaning; // in seconds + + if (passed > ONE_WEEK_IN_SECONDS && (now > SENXX_VOC_VALID_DATE)) { + // If current date greater than 01/01/2018 (validity check) + LOG_INFO("%s: More than a week (%us) since last cleaning in epoch (%us). Trigger, cleaning...", sensorName, passed, + lastCleaning); + startCleaning(); + } else { + LOG_INFO("%s: Cleaning not needed (%ds passed). Last cleaning date (in epoch): %us", sensorName, passed, + lastCleaning); + } + } else { + // We assume the device has just been updated or it is new, + // so no need to trigger a cleaning. + // Just save the timestamp to do a cleaning one week from now. + // Otherwise, we will never trigger cleaning in some cases + lastCleaning = now; + lastCleaningValid = true; + LOG_INFO("%s: No valid last cleaning date found, saving it now: %us", sensorName, lastCleaning); + saveState(); + } + + if (hasVOC) { + if (!vocValid) { + LOG_INFO("%s: No valid VOC's state found", sensorName); + } else { + // Check if state is recent + if (vocStateRecent(now)) { + // If current date greater than 01/01/2018 (validity check) + // Send it to the sensor + LOG_INFO("%s: VOC state is valid and recent", sensorName); + vocStateToSensor(); + } else { + LOG_INFO("%s: VOC state is too old or date is invalid", sensorName); + LOG_DEBUG("%s: vocTime %u, and now %u", sensorName, vocTime, now); + } + } + } +} + +uint32_t SENXXSensor::wakeUp() +{ + + LOG_DEBUG("%s: Waking up sensor", sensorName); + + // The RTC may not have had a valid time when we last checked (e.g. right after boot, + // before a WiFi/GPS/phone time source connected). Each wake is a natural, frequent point + // to notice that it has since become valid and reconcile the saved cleaning/VOC state + // against real elapsed time, instead of only ever checking once in initDevice(). + if (checkRTCQualityImproved()) { + uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now) { + LOG_INFO("%s: RTC became available (%s), reconciling saved cleaning/VOC state", sensorName, RtcName(lastRTCQuality)); + reconcileTimeDependentState(now); + if (state == SENXX_CLEANING) { + // A cleaning cycle was just started; let it run its course via + // pendingForReadyMs() instead of overwriting state with the + // measurement-start logic below. + return SENXX_CLEANING_DURATION_MS; + } + } + } + + if (!sendCommand(SENXX_START_MEASUREMENT)) { + LOG_ERROR("%s: Error starting measurement", sensorName); + // TODO - what should this return?? Something actually on the default interval? + return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; + } + delay(50); // From Sensirion Datasheet + + pmMeasureStarted = millis(); + state = SENXX_MEASUREMENT; + LOG_INFO("%s: Started measurement mode", sensorName); + return SENXX_PM_WARMUP_MS_1; +} + +bool SENXXSensor::vocStateStable() +{ + uint32_t sinceFirstMeasureStarted = (millis() - rhtGasMeasureStarted) / 1000; + LOG_DEBUG("%s: sinceFirstMeasureStarted: %us", sensorName, sinceFirstMeasureStarted); + return sinceFirstMeasureStarted > SENXX_VOC_STATE_WARMUP_S; +} + +bool SENXXSensor::startCleaning() +{ + // Note: we only should enter here if we have a valid RTC with at least + // RTCQuality::RTCQualityDevice + SENXXState previousState = state; + state = SENXX_CLEANING; + + // Note that cleaning command can only be run when the sensor is in measurement mode + if (!sendCommand(SENXX_START_MEASUREMENT)) { + LOG_ERROR("%s: Error starting measurement mode", sensorName); + state = previousState; + return false; + } + delay(50); // From Sensirion Datasheet + + if (!sendCommand(SENXX_START_FAN_CLEANING)) { + LOG_ERROR("%s: Error starting fan cleaning", sensorName); + state = previousState; + return false; + } + delay(20); // From Sensirion Datasheet + + // This message will be always printed so the user knows the device it's not hung + LOG_INFO("%s: Started fan cleaning it will take 10 seconds...", sensorName); + + // Don't block the caller for the ~10.5s the cycle takes - pendingForReadyMs() + // polls SENXX_CLEANING and calls finishCleaning() once it's done. + cleaningStarted = millis(); + return true; +} + +void SENXXSensor::finishCleaning() +{ + LOG_INFO("%s: Cleaning done", sensorName); + + // Save timestamp in flash so we know when a week has passed + uint32_t now; + now = getValidTime(RTCQuality::RTCQualityDevice); + if (now) { + lastCleaning = now; + lastCleaningValid = true; + saveState(); + } + + idle(); +} + +bool SENXXSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + state = SENXX_NOT_DETECTED; + LOG_INFO("%s: Init sensor", sensorName); + + _bus = bus; + _address = dev->address.address; +#ifdef SENXX_I2C_CLOCK_SPEED + _port = dev->address.port; + reClockI2C.setup(_bus, _port); +#endif /* SENXX_I2C_CLOCK_SPEED */ + + delay(50); // without this there is an error on the deviceReset function + + if (!sendCommand(SENXX_RESET)) { + LOG_ERROR("%s: error resetting device", sensorName); + return false; + } + delay(200); // From Sensirion Datasheet + + if (!findModel()) { + LOG_ERROR("%s: error finding sensor model", sensorName); + return false; + } + + // Check the firmware version + if (!getVersion()) + return false; + if (firmwareVer < 2) { + LOG_ERROR("%s: firmware is too old and will not work with this implementation", sensorName); + return false; + } + delay(200); // From Sensirion Datasheet + + // Detection succeeded + state = SENXX_IDLE; + status = 1; + + // Load state + loadState(); + + // Check if it is time to do a cleaning / whether the saved VOC state is still usable. + // This needs a real clock; if we don't have one yet (typical right after boot, before + // any time source has connected), don't lose the saved state - just defer the check. + // wakeUp() re-checks getRTCQuality() on every wake via checkRTCQualityImproved() and + // will run this same reconciliation the moment a valid time becomes available. + lastRTCQuality = getRTCQuality(); + uint32_t now = getValidTime(RTCQuality::RTCQualityDevice); + if (now) { + reconcileTimeDependentState(now); + } else { + LOG_INFO("%s: Not enough RTCQuality yet, deferring saved cleaning/VOC state check until it improves", sensorName); + } + + // If reconcileTimeDependentState() just started a cleaning cycle, leave state as + // SENXX_CLEANING - idle(false) would send SENXX_STOP_MEASUREMENT and clobber it + // mid-cycle. pendingForReadyMs() will poll it to completion once the scheduler starts. + rhtGasMeasureStarted = millis(); + if (state != SENXX_CLEANING) { + idle(false); + } + + initI2CSensor(); + return true; +} + +bool SENXXSensor::readValues() +{ + if (isSen6xFamily()) { + if (!sendCommand(readMeasuredValuesCmd)) { + LOG_ERROR("%s: Error sending read command", sensorName); + return false; + } + LOG_DEBUG("%s: Reading measured values", sensorName); + delay(20); // From Sensirion Datasheet + + // Fixed field order per the SEN6x datasheet: PM1.0, PM2.5, PM4.0, PM10.0, + // [Humidity, Temperature], [VOC], [NOx], [HCHO], [CO2] - each block only + // present if the model supports it. + uint8_t wordCount = 4 + (hasRHT ? 2 : 0) + (hasVOC ? 1 : 0) + (hasNOx ? 1 : 0) + (hasHCHO ? 1 : 0) + (hasCO2 ? 1 : 0); + uint8_t dataBuffer[20]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], wordCount * 3); + if (receivedNumber < (size_t)(wordCount * 2)) { + LOG_ERROR("%s: Error getting values", sensorName); + return false; + } + + uint8_t idx = 0; + auto nextWord = [&dataBuffer, &idx]() -> int16_t { + int16_t v = static_cast((dataBuffer[idx] << 8) | dataBuffer[idx + 1]); + idx += 2; + return v; + }; + + uint16_t uint_pM1p0 = static_cast(nextWord()); + uint16_t uint_pM2p5 = static_cast(nextWord()); + uint16_t uint_pM4p0 = static_cast(nextWord()); + uint16_t uint_pM10p0 = static_cast(nextWord()); + + // Map values the sensor reports as unavailable (SENXX_UINT_INVALID / + // SENXX_INT_INVALID) to the sentinels getMetrics() checks for + senxxmeasurement.pM1p0 = (uint_pM1p0 != SENXX_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX; + senxxmeasurement.pM2p5 = (uint_pM2p5 != SENXX_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX; + senxxmeasurement.pM4p0 = (uint_pM4p0 != SENXX_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX; + senxxmeasurement.pM10p0 = (uint_pM10p0 != SENXX_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX; + + senxxmeasurement.humidity = FLT_MAX; + senxxmeasurement.temperature = FLT_MAX; + senxxmeasurement.vocIndex = FLT_MAX; + senxxmeasurement.noxIndex = FLT_MAX; + senxxmeasurement.hcho = FLT_MAX; + senxxmeasurement.co2 = FLT_MAX; + + LOG_DEBUG("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, senxxmeasurement.pM1p0, + senxxmeasurement.pM2p5, senxxmeasurement.pM4p0, senxxmeasurement.pM10p0); + + if (hasRHT) { + int16_t int_humidity = nextWord(); + int16_t int_temperature = nextWord(); + senxxmeasurement.humidity = (int_humidity != SENXX_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX; + senxxmeasurement.temperature = (int_temperature != SENXX_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX; + LOG_DEBUG("%s: Got readings: humidity=%.2f, temperature=%.2f", sensorName, senxxmeasurement.humidity, + senxxmeasurement.temperature); + } + if (hasVOC) { + int16_t int_vocIndex = nextWord(); + senxxmeasurement.vocIndex = (int_vocIndex != SENXX_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX; + LOG_DEBUG("%s: Got readings: vocIndex=%.2f", sensorName, senxxmeasurement.vocIndex); + } + if (hasNOx) { + int16_t int_noxIndex = nextWord(); + senxxmeasurement.noxIndex = (int_noxIndex != SENXX_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX; + LOG_DEBUG("%s: Got readings: noxIndex=%.2f", sensorName, senxxmeasurement.noxIndex); + } + if (hasHCHO) { + uint16_t uint_hcho = static_cast(nextWord()); + senxxmeasurement.hcho = (uint_hcho != SENXX_UINT_INVALID) ? (uint_hcho / 10.0f) : FLT_MAX; + LOG_DEBUG("%s: Got readings: HCHO=%.2f", sensorName, senxxmeasurement.hcho); + } + if (hasCO2) { + uint16_t uint_co2 = static_cast(nextWord()); + senxxmeasurement.co2 = (uint_co2 != SENXX_UINT_INVALID) ? uint_co2 : FLT_MAX; + LOG_DEBUG("%s: Got readings: CO2=%.2f", sensorName, senxxmeasurement.co2); + } + + return true; + } + + // SEN5X always answers with the same fixed 8-word layout (PM1/2.5/4/10, humidity, + // temperature, VOC, NOx) regardless of model; unsupported fields simply come + // back as Sensirion's "value unknown" placeholders. + if (!sendCommand(SEN5X_READ_VALUES)) { + LOG_ERROR("%s: Error sending read command", sensorName); + return false; + } + LOG_DEBUG("%s: Reading PM Values", sensorName); + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[SEN5X_READ_VALUES_BUFFER_SIZE]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_VALUES_BUFFER_SIZE + (SEN5X_READ_VALUES_BUFFER_SIZE / 2)); + if (receivedNumber < SEN5X_READ_VALUES_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting values", sensorName); + return false; + } + + // Get the integers + uint16_t uint_pM1p0 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); + uint16_t uint_pM2p5 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); + uint16_t uint_pM4p0 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); + uint16_t uint_pM10p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); + + int16_t int_humidity = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); + int16_t int_temperature = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); + int16_t int_vocIndex = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); + int16_t int_noxIndex = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); + + // Convert values based on Sensirion Arduino lib. Map values the sensor + // reports as unavailable (SENXX_UINT_INVALID / SENXX_INT_INVALID) to the + // sentinels getMetrics() checks for + senxxmeasurement.pM1p0 = (uint_pM1p0 != SENXX_UINT_INVALID) ? (uint_pM1p0 / 10) : UINT16_MAX; + senxxmeasurement.pM2p5 = (uint_pM2p5 != SENXX_UINT_INVALID) ? (uint_pM2p5 / 10) : UINT16_MAX; + senxxmeasurement.pM4p0 = (uint_pM4p0 != SENXX_UINT_INVALID) ? (uint_pM4p0 / 10) : UINT16_MAX; + senxxmeasurement.pM10p0 = (uint_pM10p0 != SENXX_UINT_INVALID) ? (uint_pM10p0 / 10) : UINT16_MAX; + senxxmeasurement.humidity = (int_humidity != SENXX_INT_INVALID) ? (int_humidity / 100.0f) : FLT_MAX; + senxxmeasurement.temperature = (int_temperature != SENXX_INT_INVALID) ? (int_temperature / 200.0f) : FLT_MAX; + senxxmeasurement.vocIndex = (int_vocIndex != SENXX_INT_INVALID) ? (int_vocIndex / 10.0f) : FLT_MAX; + senxxmeasurement.noxIndex = (int_noxIndex != SENXX_INT_INVALID) ? (int_noxIndex / 10.0f) : FLT_MAX; + senxxmeasurement.co2 = FLT_MAX; + senxxmeasurement.hcho = FLT_MAX; + + LOG_DEBUG("%s: Got readings: pM1p0=%u, pM2p5=%u, pM4p0=%u, pM10p0=%u", sensorName, senxxmeasurement.pM1p0, + senxxmeasurement.pM2p5, senxxmeasurement.pM4p0, senxxmeasurement.pM10p0); + + if (hasRHT) { + LOG_DEBUG("%s: Got readings: humidity=%.2f, temperature=%.2f, vocIndex=%.2f", sensorName, senxxmeasurement.humidity, + senxxmeasurement.temperature, senxxmeasurement.vocIndex); + } + + if (hasNOx) { + LOG_DEBUG("%s: Got readings: noxIndex=%.2f", sensorName, senxxmeasurement.noxIndex); + } + + return true; +} + +bool SENXXSensor::readPNValues(bool cumulative) +{ + if (isSen6xFamily()) { + if (!sendCommand(SEN6X_READ_NUMBER_CONCENTRATION_VALUES)) { + LOG_ERROR("%s: Error sending read command", sensorName); + return false; + } + + LOG_DEBUG("%s: Reading PN Values", sensorName); + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[10]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], 15); + if (receivedNumber < 10) { + LOG_ERROR("%s: Error getting PN values", sensorName); + return false; + } + + uint16_t uint_pN0p5 = static_cast((dataBuffer[0] << 8) | dataBuffer[1]); + uint16_t uint_pN1p0 = static_cast((dataBuffer[2] << 8) | dataBuffer[3]); + uint16_t uint_pN2p5 = static_cast((dataBuffer[4] << 8) | dataBuffer[5]); + uint16_t uint_pN4p0 = static_cast((dataBuffer[6] << 8) | dataBuffer[7]); + uint16_t uint_pN10p0 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); + + // Raw PN values are #/cm3 with 0.1 resolution; multiplying by 10 converts + // to #/0.1l without the truncation of dividing first. Map values the + // sensor reports as unavailable (SENXX_UINT_INVALID) to the sentinel. + senxxmeasurement.pN0p5 = (uint_pN0p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX; + senxxmeasurement.pN1p0 = (uint_pN1p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX; + senxxmeasurement.pN2p5 = (uint_pN2p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX; + senxxmeasurement.pN4p0 = (uint_pN4p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX; + senxxmeasurement.pN10p0 = (uint_pN10p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX; + // Unlike SEN5X's number-concentration command, SEN6X's doesn't return a + // "typical particle size" word. + senxxmeasurement.tSize = FLT_MAX; + + // Remove accumulative values: + // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85 + if (!cumulative) { + if (senxxmeasurement.pN10p0 != UINT32_MAX && senxxmeasurement.pN4p0 != UINT32_MAX) + senxxmeasurement.pN10p0 -= senxxmeasurement.pN4p0; + if (senxxmeasurement.pN4p0 != UINT32_MAX && senxxmeasurement.pN2p5 != UINT32_MAX) + senxxmeasurement.pN4p0 -= senxxmeasurement.pN2p5; + if (senxxmeasurement.pN2p5 != UINT32_MAX && senxxmeasurement.pN1p0 != UINT32_MAX) + senxxmeasurement.pN2p5 -= senxxmeasurement.pN1p0; + if (senxxmeasurement.pN1p0 != UINT32_MAX && senxxmeasurement.pN0p5 != UINT32_MAX) + senxxmeasurement.pN1p0 -= senxxmeasurement.pN0p5; + } + + LOG_DEBUG("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u", sensorName, senxxmeasurement.pN0p5, + senxxmeasurement.pN1p0, senxxmeasurement.pN2p5, senxxmeasurement.pN4p0, senxxmeasurement.pN10p0); + + return true; + } + + if (!sendCommand(SEN5X_READ_PM_VALUES)) { + LOG_ERROR("%s: Error sending read command", sensorName); + return false; + } + + LOG_DEBUG("%s: Reading PN Values", sensorName); + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[SEN5X_READ_PM_BUFFER_SIZE]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], SEN5X_READ_PM_BUFFER_SIZE + (SEN5X_READ_PM_BUFFER_SIZE / 2)); + if (receivedNumber < SEN5X_READ_PM_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting PN values", sensorName); + return false; + } + + // Get the integers + uint16_t uint_pN0p5 = static_cast((dataBuffer[8] << 8) | dataBuffer[9]); + uint16_t uint_pN1p0 = static_cast((dataBuffer[10] << 8) | dataBuffer[11]); + uint16_t uint_pN2p5 = static_cast((dataBuffer[12] << 8) | dataBuffer[13]); + uint16_t uint_pN4p0 = static_cast((dataBuffer[14] << 8) | dataBuffer[15]); + uint16_t uint_pN10p0 = static_cast((dataBuffer[16] << 8) | dataBuffer[17]); + uint16_t uint_tSize = static_cast((dataBuffer[18] << 8) | dataBuffer[19]); + + // Convert values based on Sensirion Arduino lib. Raw PN values are #/cm3 + // with 0.1 resolution; multiplying by 10 converts to #/0.1l without the + // truncation of dividing first. Map values the sensor reports as + // unavailable (SENXX_UINT_INVALID) to the sentinel getMetrics() checks for. + senxxmeasurement.pN0p5 = (uint_pN0p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN0p5 * 10) : UINT32_MAX; + senxxmeasurement.pN1p0 = (uint_pN1p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN1p0 * 10) : UINT32_MAX; + senxxmeasurement.pN2p5 = (uint_pN2p5 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN2p5 * 10) : UINT32_MAX; + senxxmeasurement.pN4p0 = (uint_pN4p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN4p0 * 10) : UINT32_MAX; + senxxmeasurement.pN10p0 = (uint_pN10p0 != SENXX_UINT_INVALID) ? ((uint32_t)uint_pN10p0 * 10) : UINT32_MAX; + senxxmeasurement.tSize = (uint_tSize != SENXX_UINT_INVALID) ? (uint_tSize / 1000.0f) : FLT_MAX; + + // Remove accumuluative values: + // https://github.com/fablabbcn/smartcitizen-kit-2x/issues/85 + if (!cumulative) { + if (senxxmeasurement.pN10p0 != UINT32_MAX && senxxmeasurement.pN4p0 != UINT32_MAX) + senxxmeasurement.pN10p0 -= senxxmeasurement.pN4p0; + if (senxxmeasurement.pN4p0 != UINT32_MAX && senxxmeasurement.pN2p5 != UINT32_MAX) + senxxmeasurement.pN4p0 -= senxxmeasurement.pN2p5; + if (senxxmeasurement.pN2p5 != UINT32_MAX && senxxmeasurement.pN1p0 != UINT32_MAX) + senxxmeasurement.pN2p5 -= senxxmeasurement.pN1p0; + if (senxxmeasurement.pN1p0 != UINT32_MAX && senxxmeasurement.pN0p5 != UINT32_MAX) + senxxmeasurement.pN1p0 -= senxxmeasurement.pN0p5; + } + + LOG_DEBUG("%s: Got readings: pN0p5=%u, pN1p0=%u, pN2p5=%u, pN4p0=%u, pN10p0=%u, tSize=%.2f", sensorName, + senxxmeasurement.pN0p5, senxxmeasurement.pN1p0, senxxmeasurement.pN2p5, senxxmeasurement.pN4p0, + senxxmeasurement.pN10p0, senxxmeasurement.tSize); + + return true; +} + +uint8_t SENXXSensor::getMeasurements() +{ + uint32_t now = millis(); + + // Try to get new data + if (!sendCommand(SENXX_READ_DATA_READY)) { + LOG_ERROR("%s: Error sending command data ready flag", sensorName); + return 2; + } + delay(20); // From Sensirion Datasheet + + uint8_t dataReadyBuffer[SENXX_DATA_READY_BUFFER_SIZE]{}; + size_t charNumber = readBuffer(&dataReadyBuffer[0], SENXX_DATA_READY_BUFFER_SIZE + (SENXX_DATA_READY_BUFFER_SIZE / 2)); + if (charNumber < SENXX_DATA_READY_BUFFER_SIZE) { + LOG_ERROR("%s: Error getting device version value", sensorName); + return 2; + } + + bool dataReady = dataReadyBuffer[1]; + uint32_t sinceLastDataPollMs = now - lastDataPoll; + // Check if data is ready, and if since last time we requested is less than SENXX_POLL_INTERVAL + if (!dataReady || (sinceLastDataPollMs < SENXX_POLL_INTERVAL)) { + LOG_INFO("%s: Data is not ready", sensorName); + return 1; + } + + if (!readValues()) { + LOG_ERROR("%s: Error getting readings", sensorName); + return 2; + } + + if (!readPNValues(false)) { + LOG_ERROR("%s: Error getting PN readings", sensorName); + return 2; + } + + lastDataPoll = now; + + return 0; +} + +int32_t SENXXSensor::wakeUpTimeMs() +{ + return SENXX_PM_WARMUP_MS_2; +} + +int32_t SENXXSensor::pendingForReadyMs() +{ + uint32_t now = millis(); + uint32_t sincePmMeasureStarted = now - pmMeasureStarted; + LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); + + switch (state) { + case SENXX_MEASUREMENT: { + + if (!pmMeasureStarted) { + pmMeasureStarted = now; + } + + if (sincePmMeasureStarted < SENXX_PM_WARMUP_MS_1) { + LOG_INFO("%s: not enough time passed since starting measurement", sensorName); + return SENXX_PM_WARMUP_MS_1 - sincePmMeasureStarted; + } + + // Get PN values to check if we are above or below threshold + readPNValues(true); + lastDataPoll = now; + + // If the reading is low (the threshold is in #/cm3) and second warmUp hasn't passed we return to come back later + if ((senxxmeasurement.pN4p0 / 100) < SENXX_PN4P0_CONC_THD && sincePmMeasureStarted < SENXX_PM_WARMUP_MS_2) { + LOG_INFO("%s: Concentration is low, we will ask again in the second warm up period", sensorName); + state = SENXX_MEASUREMENT_2; + // Report how many seconds are pending to cover the first warm up period + return SENXX_PM_WARMUP_MS_2 - sincePmMeasureStarted; + } + // CO2 sensor has an additional warmup time + if (hasCO2 && sincePmMeasureStarted < SEN6X_CO2_WARMUP_MS) { + return SEN6X_CO2_WARMUP_MS - sincePmMeasureStarted; + } + return 0; + } + case SENXX_MEASUREMENT_2: { + if (sincePmMeasureStarted < SENXX_PM_WARMUP_MS_2) { + // Report how many seconds are pending to cover the first warm up period + return SENXX_PM_WARMUP_MS_2 - sincePmMeasureStarted; + } + return 0; + } + case SENXX_CLEANING: { + uint32_t sinceCleaningStarted = now - cleaningStarted; + if (sinceCleaningStarted < SENXX_CLEANING_DURATION_MS) { + return SENXX_CLEANING_DURATION_MS - sinceCleaningStarted; + } + finishCleaning(); + return 0; + } + default: { + return -1; + } + } +} + +bool SENXXSensor::getMetrics(meshtastic_Telemetry *measurement) +{ + LOG_INFO("%s: Attempting to get metrics", sensorName); + if (!isActive()) { + LOG_INFO("%s: not in measurement mode", sensorName); + return false; + } + + uint8_t response; + response = getMeasurements(); + + if (response == 0) { + if (senxxmeasurement.pM1p0 != UINT16_MAX) { + measurement->variant.air_quality_metrics.has_pm10_standard = true; + measurement->variant.air_quality_metrics.pm10_standard = senxxmeasurement.pM1p0; + } + if (senxxmeasurement.pM2p5 != UINT16_MAX) { + measurement->variant.air_quality_metrics.has_pm25_standard = true; + measurement->variant.air_quality_metrics.pm25_standard = senxxmeasurement.pM2p5; + } + if (senxxmeasurement.pM4p0 != UINT16_MAX) { + measurement->variant.air_quality_metrics.has_pm40_standard = true; + measurement->variant.air_quality_metrics.pm40_standard = senxxmeasurement.pM4p0; + } + if (senxxmeasurement.pM10p0 != UINT16_MAX) { + measurement->variant.air_quality_metrics.has_pm100_standard = true; + measurement->variant.air_quality_metrics.pm100_standard = senxxmeasurement.pM10p0; + } + if (senxxmeasurement.pN0p5 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_05um = true; + measurement->variant.air_quality_metrics.particles_05um = senxxmeasurement.pN0p5; + } + if (senxxmeasurement.pN1p0 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_10um = true; + measurement->variant.air_quality_metrics.particles_10um = senxxmeasurement.pN1p0; + } + if (senxxmeasurement.pN2p5 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_25um = true; + measurement->variant.air_quality_metrics.particles_25um = senxxmeasurement.pN2p5; + } + if (senxxmeasurement.pN4p0 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_40um = true; + measurement->variant.air_quality_metrics.particles_40um = senxxmeasurement.pN4p0; + } + if (senxxmeasurement.pN10p0 != UINT32_MAX) { + measurement->variant.air_quality_metrics.has_particles_100um = true; + measurement->variant.air_quality_metrics.particles_100um = senxxmeasurement.pN10p0; + } + if (senxxmeasurement.tSize != FLT_MAX) { + measurement->variant.air_quality_metrics.has_particles_tps = true; + measurement->variant.air_quality_metrics.particles_tps = senxxmeasurement.tSize; + } + + if (hasRHT) { + if (senxxmeasurement.humidity != FLT_MAX) { + measurement->variant.air_quality_metrics.has_pm_humidity = true; + measurement->variant.air_quality_metrics.pm_humidity = senxxmeasurement.humidity; + } + if (senxxmeasurement.temperature != FLT_MAX) { + measurement->variant.air_quality_metrics.has_pm_temperature = true; + measurement->variant.air_quality_metrics.pm_temperature = senxxmeasurement.temperature; + } + } + + if (hasVOC && senxxmeasurement.vocIndex != FLT_MAX) { + measurement->variant.air_quality_metrics.has_pm_voc_idx = true; + measurement->variant.air_quality_metrics.pm_voc_idx = senxxmeasurement.vocIndex; + } + + if (hasNOx && senxxmeasurement.noxIndex != FLT_MAX) { + measurement->variant.air_quality_metrics.has_pm_nox_idx = true; + measurement->variant.air_quality_metrics.pm_nox_idx = senxxmeasurement.noxIndex; + } + + if (hasCO2 && senxxmeasurement.co2 != FLT_MAX) { + measurement->variant.air_quality_metrics.has_co2 = true; + measurement->variant.air_quality_metrics.co2 = (uint32_t)senxxmeasurement.co2; + } + + if (hasHCHO && senxxmeasurement.hcho != FLT_MAX) { + measurement->variant.air_quality_metrics.has_form_formaldehyde = true; + measurement->variant.air_quality_metrics.form_formaldehyde = senxxmeasurement.hcho; + } + + if (isSen6xFamily()) { + uint32_t statusFlags = 0; + if (readDeviceStatus(statusFlags)) { + measurement->variant.air_quality_metrics.has_pm_status_flags = true; + measurement->variant.air_quality_metrics.pm_status_flags = statusFlags; + logDeviceStatus(statusFlags); + } + } + + return true; + } else if (response == 1) { + // TODO return because data was not ready yet + // Should this return false? + idle(); + return false; + } else if (response == 2) { + // Return with error for non-existing data + idle(); + return false; + } + + return true; +} + +bool SENXXSensor::readDeviceStatus(uint32_t &statusFlags) +{ + if (!isSen6xFamily()) { + return false; + } + + if (!sendCommand(SEN6X_READ_DEVICE_STATUS)) { + LOG_ERROR("%s: Error sending read device status command", sensorName); + return false; + } + delay(20); // From Sensirion Datasheet + + uint8_t dataBuffer[4]{}; + size_t receivedNumber = readBuffer(&dataBuffer[0], 6); + if (receivedNumber == 0) { + LOG_ERROR("%s: Error getting device status", sensorName); + return false; + } + + statusFlags = (static_cast(dataBuffer[0]) << 24) | (static_cast(dataBuffer[1]) << 16) | + (static_cast(dataBuffer[2]) << 8) | static_cast(dataBuffer[3]); + return true; +} + +void SENXXSensor::logDeviceStatus(uint32_t statusFlags) +{ + if (statusFlags & SEN6X_STATUS_FAN_ERROR) + LOG_ERROR("%s: Fan error", sensorName); + if (statusFlags & SEN6X_STATUS_RHT_ERROR) + LOG_ERROR("%s: RH&T sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_GAS_ERROR) + LOG_ERROR("%s: Gas (VOC/NOx) sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_CO2_2_ERROR) + LOG_ERROR("%s: CO2 sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_HCHO_ERROR) + LOG_ERROR("%s: Formaldehyde sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_PM_ERROR) + LOG_ERROR("%s: PM sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_CO2_1_ERROR) + LOG_ERROR("%s: CO2 sensor error", sensorName); + if (statusFlags & SEN6X_STATUS_FAN_SPEED_WARNING) + LOG_WARN("%s: Fan speed warning", sensorName); +} + +bool SENXXSensor::setTemperatureOffset(float tempReference) +{ + if (!isSen6xFamily()) { + // No verified opcode for SEN5X's temperature offset command yet. + LOG_WARN("%s: Temperature offset not implemented for this model", sensorName); + return false; + } + + if (senxxmeasurement.temperature == FLT_MAX) { + LOG_ERROR("%s: No recent temperature reading to calibrate against", sensorName); + return false; + } + + float tempOffset = senxxmeasurement.temperature - tempReference; + LOG_INFO("%s: Setting temperature offset: %.2f (current=%.2f, reference=%.2f)", sensorName, tempOffset, + senxxmeasurement.temperature, tempReference); + + // Payload: offset (int16, *200), slope (int16, *10000, 0=no change over time), + // time constant (uint16 seconds, 0=apply immediately), slot (uint16, 0=base self-heating). + int16_t offsetWord = static_cast(tempOffset * 200.0f); + uint8_t buffer[8]{ + static_cast((offsetWord >> 8) & 0xFF), + static_cast(offsetWord & 0xFF), + 0, + 0, // slope = 0 + 0, + 0, // time constant = 0 (apply immediately) + 0, + 0, // slot = 0 + }; + + if (!sendCommand(SEN6X_GET_SET_TEMP_OFFSET, buffer, 8)) { + LOG_ERROR("%s: Error setting temperature offset", sensorName); + return false; + } + + return true; +} + +bool SENXXSensor::co2PerformFRC(uint32_t targetCO2ppm) +{ + if (!hasCO2) { + return false; + } + + LOG_INFO("%s: Issuing FRC. Ensure device has been working at least 3 minutes in stable target environment", sensorName); + LOG_INFO("%s: Target CO2: %u ppm", sensorName, targetCO2ppm); + + uint8_t buffer[2]{static_cast((targetCO2ppm >> 8) & 0xFF), static_cast(targetCO2ppm & 0xFF)}; + if (!sendCommand(SEN6X_PERFORM_FORCED_CO2_RECAL, buffer, 2)) { + LOG_ERROR("%s: Error sending forced recalibration command", sensorName); + return false; + } + delay(500); // From Sensirion Datasheet + + uint8_t resultBuffer[2]{}; + if (readBuffer(&resultBuffer[0], 3) == 0) { + LOG_ERROR("%s: Error reading forced recalibration result", sensorName); + return false; + } + + uint16_t correction = static_cast((resultBuffer[0] << 8) | resultBuffer[1]); + if (correction == 0xFFFF) { + LOG_ERROR("%s: Forced recalibration failed", sensorName); + return false; + } + + LOG_INFO("%s: FRC correction successful. Correction output: %d ppm", sensorName, (int32_t)correction - 0x8000); + return true; +} + +bool SENXXSensor::co2GetASC(bool &ascEnabled) +{ + if (!hasCO2) { + return false; + } + + if (!sendCommand(SEN6X_GET_SET_CO2_ASC)) { + LOG_ERROR("%s: Error sending get ASC command", sensorName); + return false; + } + delay(20); // From Sensirion Datasheet + + uint8_t buffer[2]{}; + if (readBuffer(&buffer[0], 3) == 0) { + LOG_ERROR("%s: Error reading ASC status", sensorName); + return false; + } + + ascEnabled = buffer[1] != 0; + LOG_INFO("%s: ASC is %s", sensorName, ascEnabled ? "enabled" : "disabled"); + return true; +} + +bool SENXXSensor::co2SetASC(bool ascEnabled) +{ + if (!hasCO2) { + return false; + } + + LOG_INFO("%s: %s ASC", sensorName, ascEnabled ? "Enabling" : "Disabling"); + + uint8_t buffer[2]{0, static_cast(ascEnabled ? 1 : 0)}; + if (!sendCommand(SEN6X_GET_SET_CO2_ASC, buffer, 2)) { + LOG_ERROR("%s: Error setting ASC", sensorName); + return false; + } + return true; +} + +bool SENXXSensor::co2SetAltitude(uint32_t altitude) +{ + if (!hasCO2) { + return false; + } + + LOG_INFO("%s: Setting altitude at %um (volatile - reverts on device reset)", sensorName, altitude); + + uint16_t altitudeWord = static_cast(altitude); + uint8_t buffer[2]{static_cast((altitudeWord >> 8) & 0xFF), static_cast(altitudeWord & 0xFF)}; + if (!sendCommand(SEN6X_GET_SET_ALTITUDE, buffer, 2)) { + LOG_ERROR("%s: Error setting altitude", sensorName); + return false; + } + return true; +} + +bool SENXXSensor::co2SetAmbientPressure(uint32_t ambientPressurePa) +{ + if (!hasCO2) { + return false; + } + + // The SEN6X command expects hPa (700-1200), while the admin config field + // matches SCD4X's Pa convention (70000-120000) for consistency across sensors. + uint16_t pressureHpa = static_cast(ambientPressurePa / 100); + LOG_INFO("%s: Setting ambient pressure at %u hPa (volatile - reverts on device reset)", sensorName, pressureHpa); + + uint8_t buffer[2]{static_cast((pressureHpa >> 8) & 0xFF), static_cast(pressureHpa & 0xFF)}; + if (!sendCommand(SEN6X_GET_SET_AMBIENT_PRESSURE, buffer, 2)) { + LOG_ERROR("%s: Error setting ambient pressure", sensorName); + return false; + } + return true; +} + +bool SENXXSensor::co2FactoryReset() +{ + if (!hasCO2) { + return false; + } + + LOG_INFO("%s: Requesting CO2 sensor factory reset", sensorName); + if (!sendCommand(SEN6X_CO2_FACTORY_RESET)) { + LOG_ERROR("%s: Error requesting CO2 factory reset", sensorName); + return false; + } + return true; +} + +void SENXXSensor::setMode(bool setOneShot) +{ + oneShotMode = setOneShot; + if (oneShotMode) { + LOG_INFO("%s: setting mode to one shot mode", sensorName); + } else { + LOG_INFO("%s: setting mode to continuous mode", sensorName); + } +} + +AdminMessageHandleResult SENXXSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + result = AdminMessageHandleResult::NOT_HANDLED; + + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: { + bool ok = true; + bool wasActive = isActive(); + + if (isSen6xFamily()) { + if (!request->sensor_config.has_sen6x_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + const auto &cfg = request->sensor_config.sen6x_config; + + if (cfg.has_set_one_shot_mode) { + this->setMode(cfg.set_one_shot_mode); + } + + if (cfg.has_start_fan_cleaning && cfg.start_fan_cleaning) { + ok &= this->startCleaning(); + } + + // FRC/ASC/altitude are only valid in idle mode (see SEN6X datasheet), and the + // temperature offset command doesn't need measurement running either - stop + // once, run every requested calibration step, then resume if we were active. + bool needsCalibration = cfg.has_set_temperature || cfg.has_set_asc || cfg.has_set_altitude || + cfg.has_set_ambient_pressure || cfg.has_factory_reset; + if (needsCalibration && state == SENXX_CLEANING) { + // A fan cleaning was just started above (non-blocking) - stopping measurement + // now would interrupt it. Calibration and cleaning can't be requested together; + // ask the caller to retry once the cleaning cycle completes. + LOG_WARN("%s: Skipping calibration request - fan cleaning in progress, retry once it completes", sensorName); + ok = false; + } else if (needsCalibration) { + if (wasActive) { + sendCommand(SENXX_STOP_MEASUREMENT); + delay(1400); // From Sensirion Datasheet + } + + if (cfg.has_set_temperature) { + ok &= this->setTemperatureOffset(cfg.set_temperature); + } + + if (hasCO2 && + (cfg.has_set_asc || cfg.has_set_altitude || cfg.has_set_ambient_pressure || cfg.has_factory_reset)) { + Co2AdminRequest co2req; + // Matches SCD4X_config's own convention: presence of the field (not its + // value) is what requests a factory reset. + co2req.hasFactoryReset = cfg.has_factory_reset; + co2req.hasSetAsc = cfg.has_set_asc; + co2req.setAsc = cfg.set_asc; + co2req.hasTargetCo2 = cfg.has_set_target_co2_conc; + co2req.targetCo2 = cfg.set_target_co2_conc; + co2req.hasSetAltitude = cfg.has_set_altitude; + co2req.setAltitude = cfg.set_altitude; + co2req.hasSetAmbientPressure = cfg.has_set_ambient_pressure; + co2req.setAmbientPressure = cfg.set_ambient_pressure; + ok &= this->handleCo2AdminRequest(co2req, sensorName); + } + + if (wasActive) { + this->wakeUp(); + } + } + } else { + if (!request->sensor_config.has_sen5x_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + const auto &cfg = request->sensor_config.sen5x_config; + + if (cfg.has_set_one_shot_mode) { + this->setMode(cfg.set_one_shot_mode); + } + + if (cfg.has_start_fan_cleaning && cfg.start_fan_cleaning) { + ok &= this->startCleaning(); + } + } + + result = ok ? AdminMessageHandleResult::HANDLED : AdminMessageHandleResult::NOT_HANDLED; + break; + } + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} +#endif diff --git a/src/modules/Telemetry/Sensor/SENXXSensor.h b/src/modules/Telemetry/Sensor/SENXXSensor.h new file mode 100644 index 000000000..1bc80efd8 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SENXXSensor.h @@ -0,0 +1,310 @@ +#pragma once +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR + +#include "../detect/ReClockI2C.h" +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "CO2Sensor.h" +#include "TelemetrySensor.h" +#include "Wire.h" +#include "gps/RTC.h" + +/* +Shared driver for Sensirion's SEN5X and SEN6X particulate-matter sensor families +(SEN50/54/55 and SEN62/63C/65/66/68/69C). All of these sensors speak the same +16-bit-command + CRC8-framed word I2C protocol (reset, product name, start/stop +measurement, data-ready, fan cleaning, VOC algorithm state, ...). The families +differ only in: + - I2C address (SEN5X_ADDR 0x69 vs SEN6X_ADDR 0x6B) + - which physical quantities a given model exposes (PM is universal; RHT, VOC, + NOx, CO2 and HCHO are present on some models and not others) + - the opcode used to read measured values (SEN5X always uses one fixed-format + command; each SEN6X model has its own opcode returning only the words that + model supports) +This class implements the shared protocol, state machine and admin handling +once. SEN5XSensor / SEN6XSensor (see SEN5XSensor.h / SEN6XSensor.h) are thin +subclasses that only supply the sensorType/sensorName identity. +*/ +#define SENXX_PM_WARMUP_MS_1 15000 +#define SENXX_PM_WARMUP_MS_2 30000 +#define SENXX_POLL_INTERVAL 1000 +#define SENXX_I2C_CLOCK_SPEED 100000 +// How long a fan-cleaning cycle takes once started; polled via pendingForReadyMs() +// rather than blocked on, see SENXX_CLEANING in SENXXState. +#define SENXX_CLEANING_DURATION_MS 10500 + +/* +Time after which the co2 sensor in some SEN6X variants give stable data +*/ +#define SEN6X_CO2_WARMUP_MS 24000 +#define SENXX_VOC_VALID_TIME 600 +#define SENXX_VOC_VALID_DATE 1514764800 + +/* +Time after which the sensor can go to sleep, as the warmup period has passed +and the VOCs sensor will is allowed to stop (although needs to recover the state +each time) +Note: for Testing 5' is enough. Sensirion recommends 1h +This can be bypassed completely if switching to low-power RHT/Gas mode and setting +SENXX_VOC_STATE_WARMUP_S 0 +*/ +#define SENXX_VOC_STATE_WARMUP_S 3600 +#define SENXX_VOC_STATE_BUFFER_SIZE 8 + +/* Sensirion recommends taking a reading after 15 seconds, +if the Particle number reading is over 100#/cm3 the reading is OK, +but if it is lower wait until 30 seconds and take it again. +See: https://sensirion.com/resource/application_note/low_power_mode/sen5x +*/ +#define SENXX_PN4P0_CONC_THD 100 +#ifndef ONE_WEEK_IN_SECONDS +#define ONE_WEEK_IN_SECONDS 604800 +#endif + +// Commands shared identically by every SEN5X/SEN6X model +#define SENXX_RESET 0xD304 +#define SENXX_GET_PRODUCT_NAME 0xD014 +#define SENXX_GET_FIRMWARE_VERSION 0xD100 +#define SENXX_START_MEASUREMENT 0x0021 +#define SENXX_STOP_MEASUREMENT 0x0104 +#define SENXX_READ_DATA_READY 0x0202 +#define SENXX_START_FAN_CLEANING 0x5607 +#define SENXX_RW_VOCS_STATE 0x6181 + +// SEN5X-only: low-power "RHT/Gas only" measurement mode and fixed-format read commands +#define SEN5X_START_MEASUREMENT_RHT_GAS 0x0037 +#define SEN5X_READ_VALUES 0x03C4 +#define SEN5X_READ_PM_VALUES 0x0413 + +// SEN6X-only: shared number-concentration read command (per-model measured-values +// opcode lives in readMeasuredValuesCmd, set once the model is known) +#define SEN6X_READ_NUMBER_CONCENTRATION_VALUES 0x0316 + +// Values the sensor reports when a reading is unavailable (same sentinels across +// the whole SEN5X/SEN6X family per Sensirion's datasheets) +#define SENXX_UINT_INVALID 0xFFFF +#define SENXX_INT_INVALID 0x7FFF + +// Reply payload sizes in data bytes; the raw I2C transfer adds one CRC byte per +// 2-byte group, so requests are + / 2 raw bytes +#define SENXX_VERSION_BUFFER_SIZE 8 +#define SENXX_PRODUCT_NAME_BUFFER_SIZE 32 +#define SENXX_DATA_READY_BUFFER_SIZE 2 +#define SEN5X_READ_VALUES_BUFFER_SIZE 16 +#define SEN5X_READ_PM_BUFFER_SIZE 20 + +// SEN6X-only commands (all models: SEN62/63C/65/66/68/69C) +#define SEN6X_GET_SET_TEMP_OFFSET 0x60B2 +#define SEN6X_READ_DEVICE_STATUS 0xD206 +// SEN6X-only, CO2-capable models only (SEN63C/66/69C) +#define SEN6X_PERFORM_FORCED_CO2_RECAL 0x6707 +#define SEN6X_CO2_FACTORY_RESET 0x6754 +#define SEN6X_GET_SET_CO2_ASC 0x6711 +#define SEN6X_GET_SET_AMBIENT_PRESSURE 0x6720 +#define SEN6X_GET_SET_ALTITUDE 0x6736 + +struct _SENXXMeasurements { + uint16_t pM1p0; + uint16_t pM2p5; + uint16_t pM4p0; + uint16_t pM10p0; + uint32_t pN0p5; + uint32_t pN1p0; + uint32_t pN2p5; + uint32_t pN4p0; + uint32_t pN10p0; + float tSize; + float humidity; + float temperature; + float vocIndex; + float noxIndex; + float co2; + float hcho; +}; + +class SENXXSensor : public TelemetrySensor, public CO2CalibrationSensor +{ + protected: + // Only subclasses (SEN5XSensor / SEN6XSensor) construct this; they supply the + // proto sensorType/sensorName identity, everything else is auto-detected via + // findModel() at probe/init time. + SENXXSensor(meshtastic_TelemetrySensorType sensorType, const char *sensorName) : TelemetrySensor(sensorType, sensorName) {} + + private: +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2C reClockI2C; +#endif + + bool getVersion(); + float firmwareVer = -1; + float hardwareVer = -1; + float protocolVer = -1; + bool findModel(); + + enum SENXXmodel { + SENXX_UNKNOWN = 0, + // SEN5X family - I2C address SEN5X_ADDR (0x69) + SEN50, + SEN54, + SEN55, + // SEN6X family - I2C address SEN6X_ADDR (0x6B) + SEN62, + SEN63C, + SEN65, + SEN66, + SEN68, + SEN69C, + }; + SENXXmodel model = SENXX_UNKNOWN; + + // True for any SEN6X-family model (SEN62/63C/65/66/68/69C) + bool isSen6xFamily() { return model >= SEN62; } + + // Per-model capabilities, derived once in updateCapabilities() right after + // findModel() succeeds. Every read/state routine below is written against + // these flags rather than against individual model checks, so adding a new + // family member only means extending findModel()/updateCapabilities(). + bool hasRHT = false; + bool hasVOC = false; + bool hasNOx = false; + bool hasCO2 = false; + bool hasHCHO = false; + void updateCapabilities(); + + // SEN6X: opcode for "Read Measured Values" - differs per model, see updateCapabilities() + uint16_t readMeasuredValuesCmd = 0; + + // Device Status Register bit positions (SEN6X only - see datasheet Figure 7) + static constexpr uint32_t SEN6X_STATUS_FAN_ERROR = 1u << 4; + static constexpr uint32_t SEN6X_STATUS_RHT_ERROR = 1u << 6; + static constexpr uint32_t SEN6X_STATUS_GAS_ERROR = 1u << 7; + static constexpr uint32_t SEN6X_STATUS_CO2_2_ERROR = 1u << 9; // SEN66 only + static constexpr uint32_t SEN6X_STATUS_HCHO_ERROR = 1u << 10; + static constexpr uint32_t SEN6X_STATUS_PM_ERROR = 1u << 11; + static constexpr uint32_t SEN6X_STATUS_CO2_1_ERROR = 1u << 12; // SEN63C/SEN69C only + static constexpr uint32_t SEN6X_STATUS_FAN_SPEED_WARNING = 1u << 21; + + bool readDeviceStatus(uint32_t &statusFlags); + void logDeviceStatus(uint32_t statusFlags); + + // Sets the SEN6X RHT temperature-offset compensation (slot 0, applied immediately) + // from the most recently measured temperature vs. a known-good reference. Unlike + // SCD4X/SCD30 there is no "get current offset" command to accumulate against, so + // this simply computes offset = lastMeasuredTemperature - tempReference. + bool setTemperatureOffset(float tempReference); + + // CO2CalibrationSensor overrides - only meaningful when hasCO2 (SEN63C/66/69C); + // return false/no-op otherwise. + bool co2PerformFRC(uint32_t targetCO2ppm) override; + bool co2GetASC(bool &ascEnabled) override; + bool co2SetASC(bool ascEnabled) override; + bool co2SetAltitude(uint32_t altitude) override; + bool co2SetAmbientPressure(uint32_t ambientPressurePa) override; + bool co2FactoryReset() override; + + enum SENXXState { + SENXX_OFF, + SENXX_IDLE, + SENXX_RHTGAS_ONLY, // SEN5X Only + SENXX_MEASUREMENT, + SENXX_MEASUREMENT_2, + SENXX_CLEANING, + SENXX_NOT_DETECTED + }; + SENXXState state = SENXX_OFF; + // Flag to work on one-shot (read and sleep), or continuous mode + // Recommendation: if it has VOC / NOx, suggest NOT to use oneShot mode + bool oneShotMode = true; + void setMode(bool setOneShot); + bool vocStateValid(); + + // Tracks getRTCQuality() across calls so we can notice the moment a real clock + // becomes available (e.g. the phone/WiFi/GPS sets it well after boot), rather than + // only checking once in initDevice(). See checkRTCQualityImproved()/ + // reconcileTimeDependentState() for how this is used. + RTCQuality lastRTCQuality = RTCQualityNone; + bool checkRTCQualityImproved(); + void reconcileTimeDependentState(uint32_t now); + + bool sendCommand(uint16_t command); + /** + * @brief Send a command word followed by a data payload; a CRC byte is + * computed and inserted on the wire after every 2-byte pair. + * @param command 16-bit command code, sent big-endian + * @param buffer payload data bytes, without CRCs + * @param byteNumber payload size in data bytes; must be even + * @return true when the full transfer is written and acknowledged + */ + bool sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNumber = 0); + /** + * @brief Read a reply, verifying and stripping the interleaved CRC bytes. + * @param buffer destination for the data bytes (byteNumber * 2 / 3 of them) + * @param byteNumber raw transfer size including CRCs; must be a multiple + * of 3 (2 data bytes + 1 CRC per group) + * @return the number of data bytes written to buffer, or 0 on any error + */ + uint8_t readBuffer(uint8_t *buffer, uint8_t byteNumber); + uint8_t senxxCRC(const uint8_t *buffer); + // Starts a fan-cleaning cycle and returns immediately (does not block for the + // ~10.5s the cycle takes); pendingForReadyMs() polls SENXX_CLEANING to completion + // and calls finishCleaning() once done. + bool startCleaning(); + void finishCleaning(); + uint8_t getMeasurements(); + bool readPNValues(bool cumulative); + bool readValues(); + + // Monotonic (millis()) timers for warmup/poll intervals. Deliberately not + // wall-clock (getTime()) based: getTime() can jump discontinuously the moment the RTC + // quality improves mid-session (see checkRTCQualityImproved()), which would corrupt + // these short elapsed-time computations. millis() is immune to that and wraps only every ~49 days. + uint32_t pmMeasureStarted = 0; + uint32_t rhtGasMeasureStarted = 0; + uint32_t lastDataPoll = 0; + uint32_t cleaningStarted = 0; + _SENXXMeasurements senxxmeasurement{}; + + bool idle(bool checkState = true); + + protected: + // Store status of the sensor in this file. SEN5X and SEN6X keep separate prefs + // files/proto messages so existing SEN5X saved state is unaffected. + const char *senXXStateFileName = nullptr; + meshtastic_SEN5XState sen5xstate = meshtastic_SEN5XState_init_zero; + meshtastic_SEN6XState sen6xstate = meshtastic_SEN6XState_init_zero; + + bool loadState(); + bool saveState(); + + // Cleaning State + uint32_t lastCleaning = 0; + bool lastCleaningValid = false; + + // VOC State + uint8_t vocState[SENXX_VOC_STATE_BUFFER_SIZE]{}; + uint32_t vocTime = 0; + bool vocValid = false; + + bool vocStateFromSensor(); + bool vocStateToSensor(); + bool vocStateStable(); + bool vocStateRecent(uint32_t now); + + public: + bool probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port); + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + + virtual bool isActive() override; + virtual void sleep() override; + virtual uint32_t wakeUp() override; + virtual bool canSleep() override { return true; } + virtual int32_t wakeUpTimeMs() override; + virtual int32_t pendingForReadyMs() override; + + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; +}; + +#endif diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp index aa19baf0f..90671e229 100644 --- a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp @@ -46,8 +46,8 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) status = 1; state = State::ACTIVE; - measureStarted = getTime(); - LOG_INFO("%s: Enabled", sensorName); + measureStarted = millis(); + LOG_INFO("%s Enabled", sensorName); initI2CSensor(); return true; @@ -102,7 +102,7 @@ uint32_t SFA30Sensor::wakeUp() #endif /* SFA30_I2C_CLOCK_SPEED */ state = State::ACTIVE; - measureStarted = getTime(); + measureStarted = millis(); return SFA30_WARMUP_MS; } @@ -125,9 +125,7 @@ bool SFA30Sensor::isActive() int32_t SFA30Sensor::pendingForReadyMs() { - uint32_t now; - now = getTime(); - uint32_t sinceHchoMeasureStarted = (now - measureStarted) * 1000; + uint32_t sinceHchoMeasureStarted = millis() - measureStarted; LOG_DEBUG("%s: Since measure started: %ums", sensorName, sinceHchoMeasureStarted); if (sinceHchoMeasureStarted < SFA30_WARMUP_MS) { diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.h b/src/modules/Telemetry/Sensor/SFA30Sensor.h index a72bef252..8894986a5 100644 --- a/src/modules/Telemetry/Sensor/SFA30Sensor.h +++ b/src/modules/Telemetry/Sensor/SFA30Sensor.h @@ -17,6 +17,8 @@ class SFA30Sensor : public TelemetrySensor private: enum class State { IDLE, ACTIVE }; State state = State::IDLE; + // millis()-based, not wall-clock: this only measures in-session warmup elapsed time, + // and getTime() can jump discontinuously when RTC quality improves mid-session. uint32_t measureStarted = 0; SensirionI2cSfa3x sfa30; From c144fa548496ef672a6ab169d95550783a501015 Mon Sep 17 00:00:00 2001 From: Jason P Date: Thu, 13 Aug 2026 12:14:42 -0500 Subject: [PATCH 051/109] Fix the IFDEF guard causing OLED_TINY failures (#11484) --- src/graphics/draw/DebugRenderer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 3227ed604..5cca4c4aa 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -299,8 +299,8 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++], chUtilPercentage); -#endif } +#endif graphics::drawCommonFooter(display, x, y); } From 230da77642e4c7fa0df488909d89384b16c315b8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 13 Aug 2026 12:15:21 -0500 Subject: [PATCH 052/109] fix(time): convert the millis() rollover sites #11291's CI guard cannot see (#11483) * MeshPacketQueue: fix millis() rollover in the late-packet drop test replaceLowerPriorityPacket() read `backPacket->tx_after < now`, with `now` taken from millis() on the line above. tx_after is an absolute deadline, so that comparison inverts while the deadline sits on the far side of the 32-bit wrap: a queued late packet reads as not-yet-due for the rest of the wrap window, or every late packet reads as droppable at once. The same statement ordered two deadlines against each other with `backPacket->tx_after > p->tx_after`, which has the same problem. #11291 swept every site where millis() sits next to the comparison operator, and its CI guard matches that shape. Stashing the clock in a local first is the same bug written so the guard cannot see it. Both tests now subtract before comparing: the due test through Throttle::deadlinePassedAt(), and the ordering through the elapsed-since-now form already used in AdminModule's oldest-slot scan. The snapshot comes from Time::getMillis() so the deadlines and the test read one clock, per the convention deadlinePassedAt() documents. The `dt` the log line reports is now derived from the same elapsed value rather than recomputed. Behaviour is otherwise unchanged, save the boundary: deadlinePassedAt() is inclusive, so a deadline landing exactly on `now` reads as due rather than one millisecond early. Co-Authored-By: Claude Opus 5 * RadioLibInterface: don't widen a uint32_t deadline delta into a 64-bit long TRANSMIT_DELAY_COMPLETED tested whether the front packet was still waiting with long delay_remaining = txp->tx_after ? txp->tx_after - millis() : 0; if (delay_remaining > 0) ... The subtraction is uint32_t. Where long is 32-bit - every embedded target - an already-due deadline lands negative and the packet transmits, which is why this has never been visible on device. Where long is 64-bit (portduino, and the native test build) the same value zero-extends to ~4.29e9, reads as positive, and the packet is rescheduled 49.7 days out. It stays parked until some later notifyLater() with overwrite happens to reset the timer. That is not an edge case. notifyLater() schedules through setIntervalFromNow(), so the thread wakes at or after the deadline; being a millisecond past due is the ordinary path through this branch. Ask Throttle instead. deadlinePassedAt() is the unsigned half-range test, so there is no signed conversion to get wrong at any width, and the remaining delay handed to notifyLater() is computed from the same snapshot. On 32-bit the behaviour is identical, including at the boundary: a deadline equal to now transmitted before and still does. Co-Authored-By: Claude Opus 5 * ExpressLRSFiveWay: convert the two remaining raw window checks to Throttle runOnce() dismissed the alert frame with `now > alertingSinceMs + 2000` and chose its poll rate with `now < keyDownStart + 20000`, both against a millis() snapshot in a local. Same rollover inversion as any other naive compare, and invisible to the millis-deadline-check guard because millis() is not adjacent to the operator. update() in the same file was already on Throttle. hasElapsed()/isWithinTimespanMs() with the stored event give the full ~49.7 day range and need no snapshot. Sentinels are unchanged in meaning: `alerting` is the armed flag for alertingSinceMs and is tested first, and keyDownStart == 0 reads as "recent" for the first 20s of uptime exactly as `now < 0 + 20000` did - a poll rate either way. The arm sites move to Time::getMillis() so the writes land on the clock Throttle reads, which also puts them within reach of Time::setTestMillis(). Co-Authored-By: Claude Opus 5 * GPSUpdateScheduling: record whether a search is running, don't infer it elapsedSearchMs() answered "am I searching?" by ordering two raw millis() stamps: searchStartedMs > searchEndedMs. Whichever stamp lands on the far side of the 32-bit wrap reads as the larger one, so the answer inverts once per wrap cycle, in both directions: - a search that started before the wrap and ended after it keeps reading as "searching". elapsedSearchMs() then grows without bound and searchedTooLong() aborts a search that is not running. - a search that started after the wrap, following one that ended before it, reads as "idle". elapsedSearchMs() returns 0, so an unproductive search is never aborted and the receiver stays powered until it locks. Both self-heal at the next informSearching(), which bounds the damage to one GPS cycle - but the ordering test cannot be made wrap-correct, because the two stamps carry no information about which wrap they belong to. It does not need to be. Whether a search is in progress is a fact the three inform*() calls already have in hand; the ordering was only ever standing in for it. Add the flag and set it there. elapsedSearchMs() keeps its unsigned subtraction, which was always the correct part. The file's clock reads move to Time::getMillis() so the suite can drive them across the wrap. Behaviour-preserving in production - Time::getMillis() is millis() unless a test injects a clock. test_gps_update_scheduling/ gains seven cases: the idle/searching/ended states, elapsed exactness across the wrap, both inversion directions above, and reset(). The two wrap cases fail on the old predicate. Co-Authored-By: Claude Opus 5 * MessageStore: date boot-relative messages in uptime seconds A message received before the wall clock is trustworthy is stamped boot-relative and healed by upgradeBootRelativeTimestamps() once the RTC arrives. Both the stamp and the "same boot?" test were millis() / 1000, which wraps every 49.7 days: a stamp taken before the wrap reads as newer than `bootNow` afterwards, so `m.timestamp <= bootNow` declines to heal it and the message shows "???" until it ages out. MessageRenderer's own copy of the test falls the same way and prints invalidTime. Neither produces a wrong time - the guard is what fails safe - but Time::getUptimeSecs() landed in #11291 for exactly this, and does not wrap for 136 years. Both sites take it, which makes the comparison exact rather than merely fail-safe. While here, the autosave tick had its own hand-rolled deadline helper - `reachedMs(now, target)` as `(int32_t)(now - target) >= 0`. Wrap-correct, but a competing idiom for what Throttle::isWithinTimespanMs() already answers, and the signed cast is the form #11291 replaced everywhere else. Deleted; the stamps read Time::getMillis() so the whole path is on one clock. Co-Authored-By: Claude Opus 5 * WebServer: drop the hand-rolled millis() wrap branch getAdaptiveInterval() special-cased the wrap by hand: if (currentTime >= lastActivityTime) timeSinceActivity = currentTime - lastActivityTime; else timeSinceActivity = (UINT32_MAX - lastActivityTime) + currentTime + 1; Those two expressions are the same number - unsigned subtraction already computes the difference modulo 2^32 - so this is not a bug, just eight lines reimplementing what Throttle does. It also reads like a site that has thought about the wrap and settled it, which makes it a bad example to copy. Two isWithinTimespanMs() calls against the stored activity stamp, matching ethApiServer's shape for the same adaptive-interval decision. The stamps move to Time::getMillis() so the writes and the reads share a clock. Co-Authored-By: Claude Opus 5 * MeshPacketQueue: only order elapsed times once both deadlines have passed The late-packet eviction I rewrote compared how long ago each deadline passed: backElapsed < (uint32_t)(now - p->tx_after) That is only an ordering when both deadlines are in the past. An incoming packet whose tx_after is still in the future subtracts to a near-2^32 elapsed, which reads as the most overdue packet in the queue rather than the least - so a full queue would drop the overdue packet it was about to transmit in favour of one that is not ready yet. The comparison it replaced, `backPacket->tx_after > p->tx_after`, got this right away from the wrap; I lost it in the conversion. Classify before ordering: p->tx_after must be unset, or passed, before its elapsed time means anything. Two expired deadlines still order by which is further overdue, which is what the branch is for. Caught by CodeRabbit on #11483. test/test_meshpacket_queue/ pins the branch: the future-dated arrival that started this, both directions of the both-expired ordering, the undelayed arrival, and all of it again with the deadlines and `now` on opposite sides of the wrap. maxLen is 1 so the suite reaches the branch without dragging in CompareMeshPacketFunc and a NodeDB. Co-Authored-By: Claude Opus 5 * ExpressLRSFiveWay: treat "no key pressed yet" as no activity keyDownStart is 0 until the first press of a boot, and the fast-poll window read that as a press at time zero: 100ms polling for the first 20s of uptime with no activity at all, re-triggering once per millis() wrap. The arithmetic this replaced (`now < keyDownStart + 20000`) did the same, so it is not a regression - but the sentinel is exactly what the conventions say to test before the elapsed comparison, and "has there been recent key activity" has an honest answer here. 250ms is the documented floor for not missing presses, so an idle node simply starts there and moves to 100ms on the first press. Also trims the wrap-cases comment in test_gps_update_scheduling to the two-line house limit. Both from CodeRabbit review on #11483. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- src/MessageStore.cpp | 25 ++- src/MessageStore.h | 2 +- src/gps/GPSUpdateScheduling.cpp | 24 +-- src/gps/GPSUpdateScheduling.h | 1 + src/graphics/draw/MessageRenderer.cpp | 3 +- src/input/ExpressLRSFiveWay.cpp | 17 +- src/mesh/MeshPacketQueue.cpp | 13 +- src/mesh/RadioLibInterface.cpp | 9 +- src/mesh/http/WebServer.cpp | 18 +- test/test_gps_update_scheduling/test_main.cpp | 104 ++++++++++- test/test_meshpacket_queue/test_main.cpp | 164 ++++++++++++++++++ 11 files changed, 326 insertions(+), 54 deletions(-) create mode 100644 test/test_meshpacket_queue/test_main.cpp diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 913a40c45..cca57acb0 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -5,6 +5,8 @@ #include "NodeDB.h" #include "SPILock.h" #include "SafeFile.h" +#include "Throttle.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include "memory/MemAudit.h" #include // memcpy @@ -86,7 +88,9 @@ static inline void assignTimestamp(StoredMessage &sm) sm.timestamp = nowSecs; sm.isBootRelative = false; } else { - sm.timestamp = millis() / 1000; + // Uptime seconds, not millis()/1000: a stamp taken before the 32-bit wrap otherwise reads as + // newer than "now" afterwards, and upgradeBootRelativeTimestamps() then declines to heal it. + sm.timestamp = Time::getUptimeSecs(); sm.isBootRelative = true; } } @@ -134,18 +138,13 @@ static inline uint32_t autosaveIntervalMs() return sec * 1000UL; } -static inline bool reachedMs(uint32_t now, uint32_t target) -{ - return (int32_t)(now - target) >= 0; -} - // Mark new messages in RAM that need to be saved later static inline void markMessageStoreUnsaved() { g_messageStoreHasUnsavedChanges = true; if (g_lastAutoSaveMs == 0) { - g_lastAutoSaveMs = millis(); + g_lastAutoSaveMs = Time::getMillis(); } } @@ -155,14 +154,14 @@ static inline void autosaveTick(MessageStore *store) if (!store) return; - uint32_t now = millis(); + uint32_t now = Time::getMillis(); if (g_lastAutoSaveMs == 0) { g_lastAutoSaveMs = now; return; } - if (!reachedMs(now, g_lastAutoSaveMs + autosaveIntervalMs())) + if (Throttle::isWithinTimespanMs(g_lastAutoSaveMs, autosaveIntervalMs())) return; // Autosave interval reached, only save if there are unsaved messages. @@ -340,7 +339,7 @@ void MessageStore::saveToFlash() // Reset autosave state after any save g_messageStoreHasUnsavedChanges = false; - g_lastAutoSaveMs = millis(); + g_lastAutoSaveMs = Time::getMillis(); } void MessageStore::loadFromFlash() @@ -379,7 +378,7 @@ void MessageStore::loadFromFlash() #endif // Loading messages does not trigger an autosave g_messageStoreHasUnsavedChanges = false; - g_lastAutoSaveMs = millis(); + g_lastAutoSaveMs = Time::getMillis(); } #else @@ -410,7 +409,7 @@ void MessageStore::clearAllMessages() #if ENABLE_MESSAGE_PERSISTENCE g_messageStoreHasUnsavedChanges = false; - g_lastAutoSaveMs = millis(); + g_lastAutoSaveMs = Time::getMillis(); #endif } @@ -548,7 +547,7 @@ void MessageStore::upgradeBootRelativeTimestamps() if (nowSecs == 0) return; // Still no valid RTC - uint32_t bootNow = millis() / 1000; + uint32_t bootNow = Time::getUptimeSecs(); auto fix = [&](std::deque &dq) { for (auto &m : dq) { diff --git a/src/MessageStore.h b/src/MessageStore.h index 366c1a37d..dfc8673e7 100644 --- a/src/MessageStore.h +++ b/src/MessageStore.h @@ -67,7 +67,7 @@ struct StoredMessage { uint8_t channelIndex; // Channel index used uint32_t dest; // Destination node (broadcast or direct) MessageType type; // Derived from dest (explicit classification) - bool isBootRelative; // true = millis()/1000 fallback; false = epoch/RTC absolute + bool isBootRelative; // true = Time::getUptimeSecs() fallback; false = epoch/RTC absolute AckStatus ackStatus; // Delivery status (only meaningful for our own sent messages) // Text storage metadata - rebuilt from flash at boot diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index fe2c3ae78..7f37e100c 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -1,6 +1,7 @@ #include "GPSUpdateScheduling.h" #include "Default.h" +#include "UptimeClock.h" // Sampled from the original `2750 * seconds^1.22` curve. Interpolation tracks it within 0.6% for // inputs >=10s and 1.7% below that; the 1s/2s/3s points keep the convex first segment from @@ -30,14 +31,16 @@ uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs) // Mark the time when searching for GPS position begins void GPSUpdateScheduling::informSearching() { - searchStartedMs = millis(); + searching = true; + searchStartedMs = Time::getMillis(); } // Mark the time when searching for GPS is complete, // then update the predicted lock-time void GPSUpdateScheduling::informGotLock() { - searchEndedMs = millis(); + searching = false; + searchEndedMs = Time::getMillis(); LOG_DEBUG("Took %us to get lock", (searchEndedMs - searchStartedMs) / 1000); updateLockTimePrediction(); consecutiveFailures = 0; // Drop back to fast cadence as soon as we acquire any fix @@ -49,7 +52,8 @@ void GPSUpdateScheduling::informGotLock() // down() to fall into GPS_IDLE, leaving the chip awake on subsequent indoor cycles. void GPSUpdateScheduling::informSearchFailed() { - searchEndedMs = millis(); + searching = false; + searchEndedMs = Time::getMillis(); consecutiveFailures++; LOG_DEBUG("GPS search ended without fix after %us (consecutive failures: %u)", (searchEndedMs - searchStartedMs) / 1000, consecutiveFailures); @@ -59,6 +63,7 @@ void GPSUpdateScheduling::informSearchFailed() // When re-enabling GPS with user button. void GPSUpdateScheduling::reset() { + searching = false; searchStartedMs = 0; searchEndedMs = 0; searchCount = 0; @@ -70,7 +75,7 @@ void GPSUpdateScheduling::reset() // Used by GPS hardware directly, to enter timed hardware sleep uint32_t GPSUpdateScheduling::msUntilNextSearch() { - uint32_t now = millis(); + uint32_t now = Time::getMillis(); // Target interval (seconds), between GPS updates uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval, default_gps_update_interval); @@ -105,13 +110,12 @@ uint32_t GPSUpdateScheduling::msUntilNextSearch() // Used to abort a search in progress, if it runs unacceptably long uint32_t GPSUpdateScheduling::elapsedSearchMs() { - // If searching - if (searchStartedMs > searchEndedMs) - return millis() - searchStartedMs; + // Recorded, not inferred from searchStartedMs > searchEndedMs: ordering two stamps inverts + // across the 32-bit wrap, and the inform*() calls already know which state we are in. + if (!searching) + return 0; // Not searching. We shouldn't really consume this value - // If not searching - 0ms. We shouldn't really consume this value - else - return 0; + return Time::getMillis() - searchStartedMs; } // Is it now time to begin searching for a GPS position? diff --git a/src/gps/GPSUpdateScheduling.h b/src/gps/GPSUpdateScheduling.h index d7609d704..d7e11ad1a 100644 --- a/src/gps/GPSUpdateScheduling.h +++ b/src/gps/GPSUpdateScheduling.h @@ -25,6 +25,7 @@ class GPSUpdateScheduling private: void updateLockTimePrediction(); // Called from informGotLock + bool searching = false; // Set by the inform*() calls; never inferred from stamp ordering uint32_t searchStartedMs = 0; uint32_t searchEndedMs = 0; uint32_t searchCount = 0; diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 05a283b8f..1b2372917 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -6,6 +6,7 @@ #include "MessageStore.h" #include "NodeDB.h" #include "UIRenderer.h" +#include "UptimeClock.h" #include "gps/RTC.h" #include "graphics/EmoteRenderer.h" #include "graphics/Screen.h" @@ -571,7 +572,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 } } else if (m.timestamp > 0 && nowSecs == 0) { // RTC not valid: only trust boot-relative if same boot - uint32_t bootNow = millis() / 1000; + uint32_t bootNow = Time::getUptimeSecs(); if (m.isBootRelative && m.timestamp <= bootNow) { seconds = bootNow - m.timestamp; invalidTime = false; diff --git a/src/input/ExpressLRSFiveWay.cpp b/src/input/ExpressLRSFiveWay.cpp index 01712ad2a..e9efeda52 100644 --- a/src/input/ExpressLRSFiveWay.cpp +++ b/src/input/ExpressLRSFiveWay.cpp @@ -1,5 +1,6 @@ #include "ExpressLRSFiveWay.h" #include "Throttle.h" +#include "UptimeClock.h" #ifdef INPUTBROKER_EXPRESSLRSFIVEWAY_TYPE @@ -79,7 +80,7 @@ void ExpressLRSFiveWay::update(int *keyValue, bool *keyLongPressed) if (keyInProcess == NO_PRESS) { // New key down if (newKey != NO_PRESS) { - keyDownStart = millis(); + keyDownStart = Time::getMillis(); // DBGLN("down=%u", newKey); } } else { @@ -114,11 +115,10 @@ void ExpressLRSFiveWay::update(int *keyValue, bool *keyLongPressed) // Meshtastic: runs at regular intervals int32_t ExpressLRSFiveWay::runOnce() { - uint32_t now = millis(); - // Dismiss any alert frames after 2 seconds // Feedback for GPS toggle / adhoc ping - if (alerting && now > alertingSinceMs + 2000) { + // `alerting` is the armed flag, so alertingSinceMs never reaches the comparison unarmed. + if (alerting && Throttle::hasElapsed(alertingSinceMs, 2000)) { alerting = false; screen->endAlert(); } @@ -131,8 +131,9 @@ int32_t ExpressLRSFiveWay::runOnce() // Do something about this key press determineAction((KeyType)keyValue, longPressed ? LONG : SHORT); - // If there has been recent key activity, poll the joystick slightly more frequently - if (now < keyDownStart + (20 * 1000UL)) // Within last 20 seconds + // If there has been recent key activity, poll the joystick slightly more frequently. keyDownStart + // is 0 until the first press of a boot, which is no activity rather than activity at time zero. + if (keyDownStart != 0 && Throttle::isWithinTimespanMs(keyDownStart, 20 * 1000UL)) // Within last 20 seconds return 100; // Otherwise, poll slightly less often @@ -203,7 +204,7 @@ void ExpressLRSFiveWay::toggleGPS() gps->toggleGpsMode(); screen->startAlert("GPS Toggled"); alerting = true; - alertingSinceMs = millis(); + alertingSinceMs = Time::getMillis(); } #endif } @@ -226,7 +227,7 @@ void ExpressLRSFiveWay::sendAdhocPing() }); alerting = true; - alertingSinceMs = millis(); + alertingSinceMs = Time::getMillis(); } // Shutdown the node (enter deep-sleep) diff --git a/src/mesh/MeshPacketQueue.cpp b/src/mesh/MeshPacketQueue.cpp index 4aad40c69..58e9cf0bf 100644 --- a/src/mesh/MeshPacketQueue.cpp +++ b/src/mesh/MeshPacketQueue.cpp @@ -1,5 +1,7 @@ #include "MeshPacketQueue.h" #include "NodeDB.h" +#include "Throttle.h" +#include "UptimeClock.h" #include "configuration.h" #include @@ -186,9 +188,14 @@ bool MeshPacketQueue::replaceLowerPriorityPacket(meshtastic_MeshPacket *p) if (backPacket->tx_after) { // Check if there's a late packet at the queue end - auto now = millis(); - if (backPacket->tx_after < now && (!p->tx_after || backPacket->tx_after > p->tx_after)) { - int32_t dt = (int32_t)(backPacket->tx_after - now); + const uint32_t now = Time::getMillis(); + // Elapsed times only order two deadlines that have both passed: a future one subtracts to a + // near-2^32 elapsed and would read as the most overdue packet in the queue. + const uint32_t backElapsed = now - backPacket->tx_after; + const bool newGoesFirst = + !p->tx_after || (Throttle::deadlinePassedAt(now, p->tx_after) && backElapsed < (uint32_t)(now - p->tx_after)); + if (Throttle::deadlinePassedAt(now, backPacket->tx_after) && newGoesFirst) { + int32_t dt = -(int32_t)backElapsed; if (p->tx_after) { LOG_WARN("Dropping late packet 0x%08x with TX delay %dms to make room in the TX queue for packet 0x%08x with " "TX delay %ums", diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index a826a5131..195a5738a 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -4,6 +4,7 @@ #include "PowerMon.h" #include "SPILock.h" #include "Throttle.h" +#include "UptimeClock.h" #include "configuration.h" #include "error.h" #include "main.h" @@ -436,10 +437,12 @@ void RadioLibInterface::onNotify(uint32_t notification) } else { meshtastic_MeshPacket *txp = txQueue.getFront(); assert(txp); - long delay_remaining = txp->tx_after ? txp->tx_after - millis() : 0; - if (delay_remaining > 0) { + const uint32_t now = Time::getMillis(); + // Not `long remaining = tx_after - millis()`: that uint32_t subtraction widens to + // ~4.29e9 where long is 64-bit (portduino), rescheduling a due packet ~49.7 days out. + if (txp->tx_after && !Throttle::deadlinePassedAt(now, txp->tx_after)) { // There's still some delay pending on this packet, so resume waiting for it to elapse - notifyLater(delay_remaining, TRANSMIT_DELAY_COMPLETED, txTimerOverwrite); + notifyLater(txp->tx_after - now, TRANSMIT_DELAY_COMPLETED, txTimerOverwrite); #if !MESHTASTIC_EXCLUDE_BEACON } else if (MeshBeaconModule::beaconTxConfigInvalid(txp)) { // The beacon's target radio config is invalid (bad preset/region, or an diff --git a/src/mesh/http/WebServer.cpp b/src/mesh/http/WebServer.cpp index 84ea8fea4..fd1be5378 100644 --- a/src/mesh/http/WebServer.cpp +++ b/src/mesh/http/WebServer.cpp @@ -1,6 +1,7 @@ #include "configuration.h" #if !MESHTASTIC_EXCLUDE_WEBSERVER #include "NodeDB.h" +#include "UptimeClock.h" #include "graphics/Screen.h" #include "main.h" #include "mesh/http/WebServer.h" @@ -191,28 +192,19 @@ WebServerThread::WebServerThread() : concurrency::OSThread("WebServer") if (!config.network.wifi_enabled && !config.network.eth_enabled) { disable(); } - lastActivityTime = millis(); + lastActivityTime = Time::getMillis(); } void WebServerThread::markActivity() { - lastActivityTime = millis(); + lastActivityTime = Time::getMillis(); } int32_t WebServerThread::getAdaptiveInterval() { - uint32_t currentTime = millis(); - uint32_t timeSinceActivity; - - if (currentTime >= lastActivityTime) { - timeSinceActivity = currentTime - lastActivityTime; - } else { - timeSinceActivity = (UINT32_MAX - lastActivityTime) + currentTime + 1; - } - - if (timeSinceActivity < ACTIVE_THRESHOLD_MS) { + if (Throttle::isWithinTimespanMs(lastActivityTime, ACTIVE_THRESHOLD_MS)) { return ACTIVE_INTERVAL_MS; - } else if (timeSinceActivity < MEDIUM_THRESHOLD_MS) { + } else if (Throttle::isWithinTimespanMs(lastActivityTime, MEDIUM_THRESHOLD_MS)) { return MEDIUM_INTERVAL_MS; } else { return IDLE_INTERVAL_MS; diff --git a/test/test_gps_update_scheduling/test_main.cpp b/test/test_gps_update_scheduling/test_main.cpp index 00c01c0f6..72efe8904 100644 --- a/test/test_gps_update_scheduling/test_main.cpp +++ b/test/test_gps_update_scheduling/test_main.cpp @@ -1,12 +1,19 @@ #include "Arduino.h" #include "TestUtil.h" +#include "UptimeClock.h" #include "gps/GPSUpdateScheduling.h" #include #include #include -void setUp(void) {} -void tearDown(void) {} +void setUp(void) +{ + Time::setTestMillis(0); +} +void tearDown(void) +{ + Time::useRealClock(); +} // Confirms gpsHardsleepThresholdMs()'s pow()-free lookup table tracks the original // `2750 * pow(seconds, 1.22)` curve closely. @@ -74,6 +81,92 @@ static void test_clamp_boundary(void) TEST_ASSERT_EQUAL_UINT32(gpsHardsleepThresholdMs(900), gpsHardsleepThresholdMs(901)); } +// elapsedSearchMs() across the 32-bit millis() wrap. Ordering the two raw stamps, as it used to, +// reports an idle receiver as searching or a searching one as idle, and searchedTooLong() acts on it. + +// A search that has not started yet reads as idle, not as a search of length millis(). +static void test_elapsed_is_zero_before_any_search(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(90 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); +} + +static void test_elapsed_tracks_the_clock_while_searching(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(10 * 1000); + s.informSearching(); + Time::advanceTestMillis(7 * 1000); + TEST_ASSERT_EQUAL_UINT32(7 * 1000, s.elapsedSearchMs()); +} + +static void test_elapsed_is_zero_once_the_search_ends(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(10 * 1000); + s.informSearching(); + Time::advanceTestMillis(7 * 1000); + s.informGotLock(); + Time::advanceTestMillis(60 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); + + s.informSearching(); + Time::advanceTestMillis(3 * 1000); + s.informSearchFailed(); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); +} + +// Start before the wrap, still searching after it: elapsed must be the real 10s, not ~49.7 days. +static void test_elapsed_is_exact_across_the_wrap(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(0xFFFFF000u); + s.informSearching(); + Time::advanceTestMillis(0x1000u + 6 * 1000); // 4.096s to the wrap, then 6s past it + TEST_ASSERT_EQUAL_UINT32(0x1000u + 6 * 1000, s.elapsedSearchMs()); +} + +// The regression: started before the wrap, ended after it, so searchStartedMs > searchEndedMs. +// The receiver is idle and elapsed must say so. +static void test_search_ending_after_the_wrap_reads_as_idle(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(0xFFFFF000u); + s.informSearching(); + Time::advanceTestMillis(0x1000u + 2 * 1000); + s.informGotLock(); + // The stamps really are inverted: the search ended at a smaller millis() than it started at. + TEST_ASSERT_LESS_THAN_UINT32(0xFFFFF000u, Time::getMillis()); + Time::advanceTestMillis(30 * 60 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); +} + +// The mirror image: the previous search ended before the wrap, this one started after it, so +// searchStartedMs < searchEndedMs while a search is genuinely in progress. +static void test_search_starting_after_the_wrap_reads_as_searching(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(0xFFFFF000u); + s.informSearching(); + Time::advanceTestMillis(1000); + s.informGotLock(); + Time::advanceTestMillis(0x1000u); // over the wrap + s.informSearching(); + Time::advanceTestMillis(12 * 1000); + TEST_ASSERT_EQUAL_UINT32(12 * 1000, s.elapsedSearchMs()); +} + +static void test_reset_clears_the_search_state(void) +{ + GPSUpdateScheduling s; + Time::setTestMillis(10 * 1000); + s.informSearching(); + Time::advanceTestMillis(5 * 1000); + s.reset(); + TEST_ASSERT_EQUAL_UINT32(0, s.elapsedSearchMs()); +} + void setup() { delay(10); @@ -85,6 +178,13 @@ void setup() RUN_TEST(test_exact_at_table_breakpoints); RUN_TEST(test_clamps_above_table_range); RUN_TEST(test_clamp_boundary); + RUN_TEST(test_elapsed_is_zero_before_any_search); + RUN_TEST(test_elapsed_tracks_the_clock_while_searching); + RUN_TEST(test_elapsed_is_zero_once_the_search_ends); + RUN_TEST(test_elapsed_is_exact_across_the_wrap); + RUN_TEST(test_search_ending_after_the_wrap_reads_as_idle); + RUN_TEST(test_search_starting_after_the_wrap_reads_as_searching); + RUN_TEST(test_reset_clears_the_search_state); exit(UNITY_END()); } diff --git a/test/test_meshpacket_queue/test_main.cpp b/test/test_meshpacket_queue/test_main.cpp new file mode 100644 index 000000000..37709c004 --- /dev/null +++ b/test/test_meshpacket_queue/test_main.cpp @@ -0,0 +1,164 @@ +// Unit tests for MeshPacketQueue::replaceLowerPriorityPacket()'s late-packet branch - the one that +// evicts an overdue packet from a full queue to make room for a new arrival. +// +// tx_after is an absolute millis() deadline, so every decision here has to subtract before comparing +// or it inverts across the 32-bit wrap. The subtlety the cases below pin is that an *elapsed* time +// only orders two deadlines that have both passed: a deadline still in the future subtracts to a +// near-2^32 elapsed, which reads as the most overdue packet in the queue rather than the least. +// +// maxLen is 1 throughout. That is enough to reach the branch (any enqueue into a full queue goes +// through it) and it keeps CompareMeshPacketFunc out of the picture - std::upper_bound over an +// empty range never invokes the comparator, so the suite needs no NodeDB. + +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "configuration.h" +#include "mesh/MeshPacketQueue.h" +#include "mesh/MeshTypes.h" +#include +#include + +namespace +{ + +// A packet that is only ever a queue occupant: id and tx_after are all the branch reads. +meshtastic_MeshPacket *makePacket(uint32_t id, uint32_t txAfter) +{ + meshtastic_MeshPacket *p = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(p); + p->id = id; + p->tx_after = txAfter; + p->priority = meshtastic_MeshPacket_Priority_DEFAULT; + return p; +} + +// Drains whatever is still queued back to the pool, so a failing case cannot starve a later one. +void drain(MeshPacketQueue &q) +{ + while (meshtastic_MeshPacket *p = q.dequeue()) + packetPool.release(p); +} + +} // namespace + +void setUp(void) +{ + Time::setTestMillis(0); +} +void tearDown(void) +{ + Time::useRealClock(); +} + +// The regression: the incoming packet is not due yet, so it must not displace an overdue one. +// `now - p->tx_after` underflows to ~49.7 days of "elapsed", which an unguarded comparison reads as +// the more urgent packet. +static void test_future_incoming_deadline_does_not_evict_an_overdue_packet(void) +{ + Time::setTestMillis(1000); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x1001, 900); // 100ms overdue + meshtastic_MeshPacket *fresh = makePacket(0x1002, 1100); // 100ms in the future + TEST_ASSERT_TRUE(q.enqueue(back)); + + TEST_ASSERT_FALSE(q.enqueue(fresh)); + TEST_ASSERT_EQUAL_HEX32(0x1001, q.getFront()->id); + + packetPool.release(fresh); + drain(q); +} + +// The ordering the branch does want: both deadlines have passed and the arrival is the more overdue +// of the two, so the queued packet gives up its slot. +static void test_more_overdue_incoming_packet_evicts_the_late_back_packet(void) +{ + Time::setTestMillis(1000); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x2001, 900); // 100ms overdue + meshtastic_MeshPacket *fresh = makePacket(0x2002, 800); // 200ms overdue + TEST_ASSERT_TRUE(q.enqueue(back)); + + TEST_ASSERT_TRUE(q.enqueue(fresh)); // back is released by the queue + TEST_ASSERT_EQUAL_HEX32(0x2002, q.getFront()->id); + + drain(q); +} + +// The other half of that ordering: a less overdue arrival leaves the queue alone. +static void test_less_overdue_incoming_packet_is_rejected(void) +{ + Time::setTestMillis(1000); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x3001, 800); // 200ms overdue + meshtastic_MeshPacket *fresh = makePacket(0x3002, 900); // 100ms overdue + TEST_ASSERT_TRUE(q.enqueue(back)); + + TEST_ASSERT_FALSE(q.enqueue(fresh)); + TEST_ASSERT_EQUAL_HEX32(0x3001, q.getFront()->id); + + packetPool.release(fresh); + drain(q); +} + +// An arrival with no TX delay at all always wins the slot from an overdue packet. +static void test_undelayed_incoming_packet_evicts_the_late_back_packet(void) +{ + Time::setTestMillis(1000); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x4001, 900); + meshtastic_MeshPacket *fresh = makePacket(0x4002, 0); // no tx_after + TEST_ASSERT_TRUE(q.enqueue(back)); + + TEST_ASSERT_TRUE(q.enqueue(fresh)); + TEST_ASSERT_EQUAL_HEX32(0x4002, q.getFront()->id); + + drain(q); +} + +// Both deadlines were set before the wrap and `now` is after it, so every raw comparison in the +// branch inverts. The decisions must come out the same as they do away from the boundary. +static void test_decisions_survive_the_millis_wrap(void) +{ + // 0xFFFFFF00 and 0xFFFFFE00 are 256ms and 512ms before the wrap; now is 256ms after it. + Time::setTestMillis(0x00000100); + MeshPacketQueue q(1); + + meshtastic_MeshPacket *back = makePacket(0x5001, 0xFFFFFF00); // 512ms overdue + meshtastic_MeshPacket *older = makePacket(0x5002, 0xFFFFFE00); // 768ms overdue + TEST_ASSERT_TRUE(q.enqueue(back)); + TEST_ASSERT_TRUE(q.enqueue(older)); + TEST_ASSERT_EQUAL_HEX32(0x5002, q.getFront()->id); + drain(q); + + // ...and a not-yet-due arrival still loses, with the deadline on the far side of the wrap. + MeshPacketQueue q2(1); + meshtastic_MeshPacket *back2 = makePacket(0x5003, 0xFFFFFF00); // 512ms overdue + meshtastic_MeshPacket *fresh = makePacket(0x5004, 0x00000300); // 512ms in the future + TEST_ASSERT_TRUE(q2.enqueue(back2)); + + TEST_ASSERT_FALSE(q2.enqueue(fresh)); + TEST_ASSERT_EQUAL_HEX32(0x5003, q2.getFront()->id); + + packetPool.release(fresh); + drain(q2); +} + +void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_future_incoming_deadline_does_not_evict_an_overdue_packet); + RUN_TEST(test_more_overdue_incoming_packet_evicts_the_late_back_packet); + RUN_TEST(test_less_overdue_incoming_packet_is_rejected); + RUN_TEST(test_undelayed_incoming_packet_evicts_the_late_back_packet); + RUN_TEST(test_decisions_survive_the_millis_wrap); + exit(UNITY_END()); +} + +void loop() {} From 3e71c679c1da1e36815547ed44c9b66aabbda985 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 13 Aug 2026 13:17:53 -0400 Subject: [PATCH 053/109] Actions: Add PIO caching to build-debian-src workflow (#11465) Prevent a few more transient fails --- .github/workflows/build_debian_src.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/build_debian_src.yml b/.github/workflows/build_debian_src.yml index 066727cff..be3050cba 100644 --- a/.github/workflows/build_debian_src.yml +++ b/.github/workflows/build_debian_src.yml @@ -21,6 +21,10 @@ permissions: jobs: build-debian-src: runs-on: ubuntu-24.04 + # Only pushes to the default branch (develop) populate the cache; PR / merge_group runs + # restore it but never save, so they stop filling up the repo's Actions cache storage. + env: + SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }} steps: - name: Checkout code uses: actions/checkout@v7 @@ -58,6 +62,14 @@ jobs: BUILD_LOCATION: ${{ inputs.build_location }} id: version + - name: Restore PlatformIO cache + id: pio-cache + uses: actions/cache/restore@v6 + with: + path: meshtasticd/pio/core/.cache + key: | + pio-deb-src-${{ hashFiles('meshtasticd/platformio.ini', 'meshtasticd/variants/native/portduino.ini', 'meshtasticd/variants/native/portduino/platformio.ini') }} + - name: Fetch libdeps, package debian source working-directory: meshtasticd run: debian/ci_pack_sdeb.sh @@ -66,6 +78,18 @@ jobs: GPG_KEY_ID: ${{ steps.gpg.outputs.keyid || '' }} PKG_VERSION: ${{ steps.version.outputs.deb }} + - name: Extract cache from pio.tar + if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true' + run: tar -C meshtasticd -xf meshtasticd/pio.tar pio/core/.cache + + - name: Save PlatformIO cache + if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: meshtasticd/pio/core/.cache + key: | + pio-deb-src-${{ hashFiles('meshtasticd/platformio.ini', 'meshtasticd/variants/native/portduino.ini', 'meshtasticd/variants/native/portduino/platformio.ini') }} + - name: Store binaries as an artifact uses: actions/upload-artifact@v7 with: From b565a07a834eb42b79e49634efbde1e49e13b473 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 13 Aug 2026 12:21:16 -0500 Subject: [PATCH 054/109] Remove proprietary Bosch BSEC blob; open in-tree IAQ estimator for BME680 (#11381) * Remove proprietary Bosch BSEC blob; open in-tree IAQ estimator for BME680 BSEC2 cost ~37-39 KB flash and ~4-5 KB static RAM on ~190 of ~240 build targets, linked whether or not a BME680 was attached, and was a no-source proprietary archive inside GPLv3 release binaries. The firmware consumed exactly one BSEC-exclusive output: the IAQ value. - New BME680IaqEstimator: clean-room log-domain baseline tracker (humidity-compensated gas resistance vs a rise-fast/decay-slow ceiling, 0-500 scale matching the existing UI bands), pure math, unit-tested on native (test_bme680_iaq, 15 tests incl. a deep-sleep reboot simulation). Warm-up/burn-in progress persists to /prefs/bme680.dat via SafeFile so one-sample-per-wake SENSOR nodes converge across reboots; stale /prefs/bsec.dat is removed once. - BME680Sensor: single-path rewrite on Adafruit_BME680 with async once-per-minute sampling (~20x lower heater duty than BSEC LP mode), a hard 2-minute publish-freshness bound (a dead sensor stops reporting instead of freezing its last reading on the wire), and suppression of bogus gas_resistance=0 points from heater-unstable cycles. - platformio.ini: environmental_extra_common/_extra/_no_bsec collapsed into one section; Bosch BSEC2 + BME68x deps deleted; per-variant BSEC link-path hacks and the TEMPORARY promicro lib_ignore removed. nrf52_promicro_diy_tcxo regains BME680 support at 36 KB clear of the warm-store cap; rak4631 lands at 75 KB clear. - EnvironmentTelemetry: iaq rendering gates on has_iaq (a genuine IAQ of 0 now displays); stale BSEC comments rewritten. - rak4631 size budgets tightened (113000->108000 RAM, 786000->746000 flash) to lock in the reclaimed headroom. - bin/bme680_iaq_replay.cpp: host-side replay harness for tuning the estimator against captured BSEC traces (mean abs error + band agreement), no reflashing needed. Measured (develop -> this branch): rak4631 -38.8 KB flash / -4.9 KB RAM; heltec-v3 -36.4 KB / -4.0 KB; tlora-v2-1-1_6 +1.3 KB (its IAQ approximation had been dead code since #9663 due to an inverted isfinite check and now actually runs). Note: gas_resistance stays kOhm on the wire for fleet compatibility; the proto comment claiming MOhm gets a separate meshtastic/protobufs docs PR. * Address CodeRabbit review feedback - Use Throttle::isWithinTimespanMs for all elapsed-time predicates in BME680Sensor per coding guidelines (deadline math for the async reading completion stays raw, as it targets an absolute timestamp) - Make the state file name members static constexpr - Replay tool: cast uint16_t before %u (default argument promotion), report malformed input lines instead of silently skipping, and fail non-zero on stream read errors * Address CodeRabbit nitpicks - Replace the local clampf helper with std::clamp (meshUtils.h's clamp drags in Arduino.h, which would break the estimator's standalone host build that the replay harness depends on) - Trim the replay tool's file header to a two-line summary; the full build, capture, and tuning workflow moves to docs/bme680_iaq_replay.md --- bin/bme680_iaq_replay.cpp | 100 +++++ bin/ram_budgets.json | 4 +- docs/bme680_iaq_replay.md | 54 +++ platformio.ini | 23 +- .../Telemetry/EnvironmentTelemetry.cpp | 13 +- .../Telemetry/Sensor/BME680IaqEstimator.cpp | 94 +++++ .../Telemetry/Sensor/BME680IaqEstimator.h | 104 ++++++ src/modules/Telemetry/Sensor/BME680Sensor.cpp | 305 ++++++++------- src/modules/Telemetry/Sensor/BME680Sensor.h | 83 +++-- test/test_bme680_iaq/test_main.cpp | 352 ++++++++++++++++++ variants/esp32/esp32.ini | 6 +- variants/esp32p4/esp32p4.ini | 1 - .../ELECROW-ThinkNode-M3/platformio.ini | 1 - .../nrf52_promicro_diy_tcxo/platformio.ini | 12 - variants/nrf52840/muzi_base/platformio.ini | 1 - 15 files changed, 927 insertions(+), 226 deletions(-) create mode 100644 bin/bme680_iaq_replay.cpp create mode 100644 docs/bme680_iaq_replay.md create mode 100644 src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp create mode 100644 src/modules/Telemetry/Sensor/BME680IaqEstimator.h create mode 100644 test/test_bme680_iaq/test_main.cpp diff --git a/bin/bme680_iaq_replay.cpp b/bin/bme680_iaq_replay.cpp new file mode 100644 index 000000000..62d0a5286 --- /dev/null +++ b/bin/bme680_iaq_replay.cpp @@ -0,0 +1,100 @@ +// Replays a captured BME680 CSV trace (gas_ohms,rh[,bsec_iaq]) through +// BME680IaqEstimator for offline tuning. See docs/bme680_iaq_replay.md. + +#include "modules/Telemetry/Sensor/BME680IaqEstimator.h" + +#include +#include + +namespace +{ +// Same buckets the device UI uses (EnvironmentTelemetry drawFrame) +int band(int iaq) +{ + if (iaq <= 25) + return 0; // Excellent + if (iaq <= 50) + return 1; // Good + if (iaq <= 100) + return 2; // Moderate + if (iaq <= 150) + return 3; // Poor + if (iaq <= 200) + return 4; // Unhealthy + if (iaq <= 300) + return 5; // Very Unhealthy + return 6; // Hazardous +} +} // namespace + +int main(int argc, char **argv) +{ + FILE *in = stdin; + if (argc > 1) { + in = fopen(argv[1], "r"); + if (!in) { + fprintf(stderr, "cannot open %s\n", argv[1]); + return 1; + } + } + + BME680IaqEstimator est; + char line[256]; + long lineNo = 0, n = 0, skipped = 0, produced = 0, compared = 0, bandHits = 0; + double absErrSum = 0; + + printf("n,gas_ohms,rh,est_iaq,bsec_iaq\n"); + while (fgets(line, sizeof(line), in)) { + lineNo++; + if (line[0] == '#' || line[0] == '\n') + continue; + float gas, rh, bsec = NAN; + int fields = sscanf(line, "%f,%f,%f", &gas, &rh, &bsec); + if (fields < 2) { + // Tolerate one header row silently; anything else malformed is + // reported so a damaged trace can't produce a quiet, biased summary + if (lineNo > 1) { + skipped++; + fprintf(stderr, "skipping malformed line %ld: %s", lineNo, line); + } + continue; + } + n++; + uint16_t iaq; + bool got = est.update(gas, rh, &iaq); + bool haveBsec = fields >= 3 && std::isfinite(bsec); + + printf("%ld,%.0f,%.2f,", n, gas, rh); + if (got) + printf("%u", (unsigned)iaq); + if (haveBsec) + printf(",%.0f\n", bsec); + else + printf(",\n"); + + if (got) { + produced++; + if (haveBsec) { + compared++; + absErrSum += std::fabs((double)iaq - (double)bsec); + if (band(iaq) == band((int)std::lround(bsec))) + bandHits++; + } + } + } + if (ferror(in)) { + fprintf(stderr, "input read error at line %ld\n", lineNo); + if (in != stdin) + fclose(in); + return 1; + } + + fprintf(stderr, "samples: %ld, estimator outputs: %ld, malformed lines skipped: %ld\n", n, produced, skipped); + if (compared) { + fprintf(stderr, "vs BSEC (%ld comparable): mean abs error %.1f IAQ points, band agreement %.1f%%\n", compared, + absErrSum / compared, 100.0 * bandHits / compared); + } + if (in != stdin) + fclose(in); + return 0; +} diff --git a/bin/ram_budgets.json b/bin/ram_budgets.json index b48903a0b..a7e10125e 100644 --- a/bin/ram_budgets.json +++ b/bin/ram_budgets.json @@ -18,7 +18,7 @@ "description." ], "rak4631": { - "ram_bytes": 113000, - "flash_bytes": 786000 + "ram_bytes": 108000, + "flash_bytes": 746000 } } diff --git a/docs/bme680_iaq_replay.md b/docs/bme680_iaq_replay.md new file mode 100644 index 000000000..5fc8d96df --- /dev/null +++ b/docs/bme680_iaq_replay.md @@ -0,0 +1,54 @@ +# BME680 IAQ replay harness + +`bin/bme680_iaq_replay.cpp` replays a captured sensor trace through the in-tree +`BME680IaqEstimator` on a dev machine, for tuning the estimator's constants +against recorded Bosch BSEC output. The estimator is pure math with no platform +dependencies, so a trace replays in milliseconds - edit the constants in +`src/modules/Telemetry/Sensor/BME680IaqEstimator.h`, recompile, rerun. + +## Build + +From the repo root: + +```bash +c++ -std=c++17 -O2 -I src -o /tmp/iaq_replay \ + bin/bme680_iaq_replay.cpp src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp +``` + +## Input + +CSV on stdin or as a file argument, one sample per line: + +```text +gas_ohms,relative_humidity[,bsec_iaq] +``` + +Lines starting with `#` are ignored; a single non-numeric header row is +tolerated; any other malformed line is reported on stderr and skipped. + +## Capturing a trace + +On a firmware build that still links BSEC (any release tag before the BSEC +removal), add one log line to `BME680Sensor::getMetrics` in the BSEC branch: + +```cpp +LOG_INFO("IAQCSV,%.0f,%.2f,%.0f", bme680.getData(BSEC_OUTPUT_RAW_GAS).signal, + bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY).signal, + bme680.getData(BSEC_OUTPUT_IAQ).signal); +``` + +then extract the columns from the serial log: + +```bash +grep -o 'IAQCSV,.*' serial.log | cut -d, -f2- > trace.csv +``` + +BSEC's `RAW_GAS` and heat-compensated humidity are exactly the estimator's +inputs, so one physical sensor feeds both algorithms identically. + +## Output + +Per-sample CSV `n,gas_ohms,rh,est_iaq,bsec_iaq` on stdout (empty `est_iaq` +during the estimator's warm-up/burn-in window), plus a stderr summary with the +mean absolute error and UI-band agreement against the `bsec_iaq` column, using +the same 0-500 band thresholds the device screen applies. diff --git a/platformio.ini b/platformio.ini index 2c7973ecf..7e05f10f0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -230,8 +230,11 @@ lib_deps = # renovate: datasource=github-tags depName=Seeed_PM2_5_sensor_HM3301 packageName=meshtastic/Seeed_PM2_5_sensor_HM3301 https://github.com/meshtastic/Seeed_PM2_5_sensor_HM3301/archive/2704ca254c7e2136c52ac23198dd05f5ba1e2f04.zip -; Common environmental sensor libraries (not included in native / portduino) -[environmental_extra_common] +; Extra environmental sensor libraries (not included in native / portduino). +; BME680/BME688 IAQ comes from the in-tree open estimator (BME680IaqEstimator); +; the proprietary Bosch BSEC blob (measured ~37-39 KB flash + ~4-5 KB static +; RAM per image) is intentionally not linked anywhere. +[environmental_extra] lib_deps = # renovate: datasource=github-tags depName=Adafruit BMP3XX packageName=adafruit/Adafruit_BMP3XX https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip @@ -260,20 +263,6 @@ lib_deps = # renovate: datasource=custom.pio depName=Adafruit ADS1X15 packageName=adafruit/library/Adafruit ADS1X15 Library https://github.com/adafruit/Adafruit_ADS1X15/archive/refs/tags/2.6.2.zip # renovate: datasource=github-tags depName=Adafruit DS248x packageName=adafruit/Adafruit_DS248x - https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip - -; Environmental sensors with BSEC2 (Bosch proprietary IAQ) -[environmental_extra] -lib_deps = - ${environmental_extra_common.lib_deps} - # renovate: datasource=github-tags depName=Bosch BSEC2 packageName=boschsensortec/Bosch-BSEC2-Library - https://github.com/boschsensortec/Bosch-BSEC2-Library/archive/refs/tags/1.10.2610.zip - # renovate: datasource=github-tags depName=Bosch BME68x packageName=boschsensortec/Bosch-BME68x-Library - https://github.com/boschsensortec/Bosch-BME68x-Library/archive/refs/tags/v1.3.40408.zip - -; Environmental sensors without BSEC (saves ~3.5KB DRAM for original ESP32 targets) -[environmental_extra_no_bsec] -lib_deps = - ${environmental_extra_common.lib_deps} + https://github.com/adafruit/Adafruit_DS248x/archive/refs/tags/1.2.0.zip # renovate: datasource=github-tags depName=Adafruit_BME680 packageName=adafruit/Adafruit_BME680 https://github.com/adafruit/Adafruit_BME680/archive/refs/tags/2.0.6.zip diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index aa985c909..aae103e24 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -54,7 +54,7 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/LTR390UVSensor.h" #endif -#if __has_include() || __has_include() +#if __has_include() #include "Sensor/BME680Sensor.h" #endif @@ -306,7 +306,7 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::LTR390UV); #endif -#if __has_include() || __has_include() +#if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::BME_680); #endif #if __has_include() @@ -457,7 +457,8 @@ int32_t EnvironmentTelemetryModule::runOnce() if (sleepOnNextExecution) { // Honor the pre-sleep grace period armed in sendTelemetry(): OSThread reschedules with // this return value, which would otherwise override setIntervalFromNow() with the sensor - // polling interval (35 ms for BSEC2) and trigger deep sleep while the TX is still on air + // polling interval (sub-second while a BME680 reading is in flight) and trigger deep sleep + // while the TX is still on air return FIVE_SECONDS_MS; } return min(sendToPhoneIntervalMs, result); @@ -520,7 +521,7 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt const auto &m = telemetry.variant.environment_metrics; // Check if any telemetry field has valid data - bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || m.iaq != 0 || m.voltage != 0 || + bool hasAny = m.has_temperature || m.has_relative_humidity || m.barometric_pressure != 0 || m.has_iaq || m.voltage != 0 || m.current != 0 || m.lux != 0 || m.white_lux != 0 || m.weight != 0 || m.distance != 0 || m.radiation != 0; if (!hasAny) { @@ -555,7 +556,7 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt entries.push_back("Hum: " + String(m.relative_humidity, 0) + "%"); if (m.barometric_pressure != 0) entries.push_back("Prss: " + String(m.barometric_pressure, 0) + " hPa"); - if (m.iaq != 0) { + if (m.has_iaq) { String aqi = "IAQ: " + String(m.iaq); const char *bannerMsg = nullptr; // Default: no banner @@ -844,7 +845,7 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) } // Arm the pre-sleep sequence even when no valid reading was available this cycle (e.g. a - // BSEC2 call timing violation): a power-saving SENSOR node must still return to deep sleep, + // failed sensor read): a power-saving SENSOR node must still return to deep sleep, // otherwise it stays awake until the next telemetry interval and drains its battery if (!phoneOnly && isPowerSavingSensor()) { if (!validTelemetry) diff --git a/src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp b/src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp new file mode 100644 index 000000000..89a792df6 --- /dev/null +++ b/src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp @@ -0,0 +1,94 @@ +#include "BME680IaqEstimator.h" + +// std::clamp rather than meshUtils.h's clamp: that header drags in Arduino.h, +// and this file must stay compilable standalone on a dev host (see the replay +// harness in bin/bme680_iaq_replay.cpp) +#include +#include +#include + +bool BME680IaqEstimator::update(float gasOhms, float relativeHumidity, uint16_t *iaqOut) +{ + if (!(isfinite(gasOhms) && gasOhms > 0.0f)) + return false; + + // A failed humidity read must not poison the baseline: fall back to the + // reference, which makes both compensation terms no-ops + float rh = isfinite(relativeHumidity) ? std::clamp(relativeHumidity, 0.0f, 100.0f) : RH_REF; + + if (warmupRemaining > 0) { + warmupRemaining--; + return false; + } + + float x = logf(gasOhms) + KH * (rh - RH_REF); + x = std::clamp(x, LN_FLOOR - LN_RANGE, LN_CEIL_MAX); + + if (!seeded) { + lnCeiling = std::clamp(x, LN_FLOOR, LN_CEIL_MAX); + seeded = true; + } else { + float alpha = (x > lnCeiling) ? ALPHA_UP : ALPHA_DOWN; + lnCeiling = std::clamp(lnCeiling + alpha * (x - lnCeiling), LN_FLOOR, LN_CEIL_MAX); + } + + if (sampleCount < UINT32_MAX) + sampleCount++; + if (sampleCount < BURN_IN_SAMPLES) + return false; + + float below = lnCeiling - x; + if (below < 0.0f) + below = 0.0f; + float gasScore = std::clamp(below / LN_RANGE, 0.0f, 1.0f) * 500.0f; + + // Comfort-band penalty: only outside the band, so ordinary indoor humidity + // can't keep IAQ away from the "Excellent" band + float humDeviation = rh < RH_COMFORT_MIN ? RH_COMFORT_MIN - rh : (rh > RH_COMFORT_MAX ? rh - RH_COMFORT_MAX : 0.0f); + float humScore = std::clamp(humDeviation / RH_DEV_NORM, 0.0f, 1.0f) * 500.0f; + + *iaqOut = (uint16_t)lroundf(std::clamp(gasScore + HUM_WEIGHT * humScore, 0.0f, 500.0f)); + return true; +} + +uint32_t BME680IaqEstimator::computeHash(const BME680IaqState &s) +{ + uint32_t words[5]; + memcpy(words, &s, sizeof(words)); + return words[0] ^ words[1] ^ words[2] ^ words[3] ^ words[4]; +} + +void BME680IaqEstimator::serialize(BME680IaqState *out, uint32_t nowSecs) const +{ + memset(out, 0, sizeof(*out)); + out->magic = MAGIC; + out->version = VERSION; + out->warmupRemaining = (uint8_t)warmupRemaining; + out->lnCeiling = lnCeiling; + out->savedAtSecs = nowSecs; + out->sampleCount = sampleCount; + out->xorHash = computeHash(*out); +} + +bool BME680IaqEstimator::restore(const BME680IaqState &in, uint32_t nowSecs) +{ + if (in.magic != MAGIC || in.version != VERSION) + return false; + if (in.xorHash != computeHash(in)) + return false; + // The ceiling only exists once a sample has been accepted (sampleCount > 0); + // pure warm-up progress is persisted with lnCeiling still at 0 + bool hasBaseline = in.sampleCount > 0; + if (hasBaseline && !(isfinite(in.lnCeiling) && in.lnCeiling >= LN_FLOOR && in.lnCeiling <= LN_CEIL_MAX)) + return false; + // Staleness is only judgeable when the state was stamped with a valid RTC + // and we have one now; a week-old baseline says nothing about today's air + if (in.savedAtSecs != 0 && nowSecs != 0 && nowSecs >= in.savedAtSecs && (nowSecs - in.savedAtSecs) > STATE_MAX_AGE_SECS) + return false; + + lnCeiling = in.lnCeiling; + sampleCount = in.sampleCount; + warmupRemaining = in.warmupRemaining <= WARMUP_DISCARD ? in.warmupRemaining : WARMUP_DISCARD; + seeded = hasBaseline; + return true; +} diff --git a/src/modules/Telemetry/Sensor/BME680IaqEstimator.h b/src/modules/Telemetry/Sensor/BME680IaqEstimator.h new file mode 100644 index 000000000..87f19243c --- /dev/null +++ b/src/modules/Telemetry/Sensor/BME680IaqEstimator.h @@ -0,0 +1,104 @@ +#pragma once + +#include + +/** + * Persisted estimator state, written to /prefs/bme680.dat via SafeFile. + * Fixed 24-byte little-endian layout; xorHash covers the five preceding words + * as a semantic guard on top of SafeFile's write-path hash. + */ +struct BME680IaqState { + uint32_t magic; + uint8_t version; + uint8_t warmupRemaining; + uint8_t reserved[2]; + float lnCeiling; + uint32_t savedAtSecs; // RTC epoch at save; 0 if no valid RTC + uint32_t sampleCount; + uint32_t xorHash; +}; + +static_assert(sizeof(BME680IaqState) == 24, "BME680IaqState layout must stay fixed for on-disk compatibility"); + +/** + * Clean-room IAQ estimator for the BME680/BME688 gas sensor (replaces the + * proprietary Bosch BSEC library). + * + * VOC exposure lowers the sensor's gas resistance. We track a rolling ceiling + * of humidity-compensated log-resistance ("cleanest air seen recently") and + * score each sample by its log-distance below that ceiling, mapped onto the + * 0-500 scale the UI already bands (<=25 Excellent ... >300 Hazardous). + * + * Warm-up and burn-in progress are part of the persisted state: a deep-sleep + * SENSOR node that takes one sample per wake (RAM wiped in between) still + * converges by restoring and re-serializing across reboots. + * + * Pure math on purpose: no Arduino, filesystem, or clock dependencies, so the + * whole thing is unit-testable on the native host (test_bme680_iaq). + */ +class BME680IaqEstimator +{ + public: + static constexpr uint32_t MAGIC = 0x42494151; // 'BIAQ' + static constexpr uint8_t VERSION = 1; + + // Tunables, centralized for the hardware-soak stage. Physical rationale: + // KH: gas resistance falls roughly exp(-0.035 * %RH); compensate to a 40 %RH reference + // ALPHA_UP/DOWN: ceiling rises fast toward cleaner air, decays with a ~12 h time + // constant at one sample per minute so pollution episodes don't become "normal" + // LN_FLOOR: baseline can't sit below ln(5 kOhm), the heavily-polluted end of the range + // LN_CEIL_MAX: sanity bound only -- fresh/very clean sensors legitimately read + // 1-13 MOhm (Bosch specs to 50 MOhm), so this sits far above at ln(~100 MOhm) + // LN_RANGE: gas at 1/15 of the baseline maps to IAQ 500 + static constexpr float KH = 0.035f; + static constexpr float ALPHA_UP = 0.25f; + static constexpr float ALPHA_DOWN = 1.0f / 720.0f; + static constexpr float LN_FLOOR = 8.517193f; // ln(5000) + static constexpr float LN_CEIL_MAX = 18.4f; // ln(~1e8) + static constexpr float LN_RANGE = 2.7080502f; // ln(15) + static constexpr float HUM_WEIGHT = 0.15f; + // RH_REF: the KH compensation reference, and the fallback for failed humidity reads + // RH_COMFORT_MIN/MAX: no humidity penalty inside this band + // RH_DEV_NORM: deviation that earns the full penalty (== 100 - RH_COMFORT_MAX; the dry + // side's maximum deviation is only RH_COMFORT_MIN, so it intentionally caps at 75%) + static constexpr float RH_REF = 40.0f; + static constexpr float RH_COMFORT_MIN = 30.0f; + static constexpr float RH_COMFORT_MAX = 60.0f; + static constexpr float RH_DEV_NORM = 40.0f; + static constexpr uint32_t WARMUP_DISCARD = 3; // first-ever samples, while the heater element settles + static constexpr uint32_t BURN_IN_SAMPLES = 30; // no output until the baseline has this much history + static constexpr uint32_t STATE_MAX_AGE_SECS = 7 * 24 * 60 * 60; // a week-old baseline says nothing about today's air + + /** + * Feed one sample. Returns true and writes *iaqOut (0-500) once the + * estimator has enough history; returns false during warm-up/burn-in or + * for invalid readings. + */ + bool update(float gasOhms, float relativeHumidity, uint16_t *iaqOut); + + /// Burn-in complete: output is available + bool ready() const { return sampleCount >= BURN_IN_SAMPLES; } + + // Progress accessors, used by the sensor to decide when persisting is worthwhile + uint32_t samplesFed() const { return sampleCount; } + uint32_t warmupLeft() const { return warmupRemaining; } + + void serialize(BME680IaqState *out, uint32_t nowSecs) const; + + /** + * Adopt persisted state, including warm-up/burn-in progress (warm-up is + * NOT re-armed: the persisted counters are the source of truth). Returns + * false and leaves the estimator untouched on magic, version, hash, or + * range mismatch, or if the state is older than STATE_MAX_AGE_SECS (only + * checkable when both timestamps are valid). + */ + bool restore(const BME680IaqState &in, uint32_t nowSecs); + + private: + static uint32_t computeHash(const BME680IaqState &s); + + float lnCeiling = 0.0f; + uint32_t sampleCount = 0; // samples fed to the baseline (excludes warm-up discards) + uint32_t warmupRemaining = WARMUP_DISCARD; + bool seeded = false; +}; diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.cpp b/src/modules/Telemetry/Sensor/BME680Sensor.cpp index 107601267..9162a9321 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.cpp +++ b/src/modules/Telemetry/Sensor/BME680Sensor.cpp @@ -1,58 +1,25 @@ #include "configuration.h" -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && (__has_include() || __has_include()) +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() #include "../mesh/generated/meshtastic/telemetry.pb.h" #include "BME680Sensor.h" #include "FSCommon.h" #include "SPILock.h" +#include "SafeFile.h" #include "TelemetrySensor.h" #include "UptimeClock.h" +#include "gps/RTC.h" #include "mesh/Throttle.h" -#if __has_include() -#include -#endif +#include BME680Sensor::BME680Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_BME680, "BME680") {} -#if __has_include() -int32_t BME680Sensor::runOnce() -{ - if (!bme680.run()) { - checkStatus("runTrigger"); - } - return 35; -} -#endif - bool BME680Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) { status = 0; -#if __has_include() - if (!bme680.begin(dev->address.address, *bus)) - checkStatus("begin"); - - if (bme680.status == BSEC_OK) { - status = 1; - if (!bme680.setConfig(bsec_config)) { - checkStatus("setConfig"); - status = 0; - } - loadState(); - if (!bme680.updateSubscription(sensorList, ARRAY_LEN(sensorList), BSEC_SAMPLE_RATE_LP)) { - checkStatus("updateSubscription"); - status = 0; - } - LOG_INFO("Init sensor: %s with the BSEC Library version %d.%d.%d.%d ", sensorName, bme680.version.major, - bme680.version.minor, bme680.version.major_bugfix, bme680.version.minor_bugfix); - } - - if (status == 0) - LOG_DEBUG("BME680Sensor::runOnce: bme680.status %d", bme680.status); - -#else bme680 = makeBME680(bus); if (!bme680->begin(dev->address.address)) { @@ -60,154 +27,204 @@ bool BME680Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) return status; } - status = 1; + // Acquisition profile, stated explicitly (these match the library defaults): + // the heater setting determines power draw, ~0.25% duty at one sample/min + bme680->setTemperatureOversampling(BME680_OS_8X); + bme680->setHumidityOversampling(BME680_OS_2X); + bme680->setPressureOversampling(BME680_OS_4X); + bme680->setIIRFilterSize(BME680_FILTER_SIZE_3); + bme680->setGasHeater(320, 150); // 320 degC for 150 ms -#endif + status = 1; + loadState(); + LOG_INFO("Init sensor: %s (open IAQ estimator)", sensorName); initI2CSensor(); return status; } +int32_t BME680Sensor::runOnce() +{ + uint32_t now = Time::getMillis(); + + if (readingInFlight) { + if (!Throttle::deadlinePassedAt(now, readingDoneAtMs)) + return readingDoneAtMs - now; + captureSample(); + return SAMPLE_INTERVAL_MS; + } + + if (haveSample && Throttle::isWithinTimespanMs(lastSampleMs, SAMPLE_INTERVAL_MS)) + return SAMPLE_INTERVAL_MS - (now - lastSampleMs); + + uint32_t doneAt = bme680->beginReading(); + if (doneAt == 0) { + LOG_WARN("%s beginReading() failed", sensorName); + return SAMPLE_INTERVAL_MS; + } + readingInFlight = true; + readingDoneAtMs = doneAt; + return Throttle::deadlinePassedAt(now, doneAt) ? 1 : (int32_t)(doneAt - now); +} + +/// Complete the reading (in flight or synchronous), feed the estimator, refresh the cache +void BME680Sensor::captureSample() +{ + readingInFlight = false; + // endReading() completes the in-flight conversion, or starts and finishes + // a fresh one when none is pending (performReading() is an alias for it in + // Adafruit_BME680; a failed first call resets the conversion, so the second + // call is a genuine one-shot retry). Worst case each call waits ~2x the + // remaining TPHG cycle, so a synchronous read costs a few hundred ms. + if (!bme680->endReading() && !bme680->performReading()) { + LOG_WARN("%s reading failed", sensorName); + return; + } + + lastTemperature = bme680->temperature; + lastHumidity = bme680->humidity; + lastPressureHPa = bme680->pressure / 100.0F; + lastGasOhms = (float)bme680->gas_resistance; + haveSample = true; + lastSampleMs = Time::getMillis(); + + uint16_t iaq; + if (iaqEstimator.update(lastGasOhms, lastHumidity, &iaq)) { + lastIaq = iaq; + lastIaqValid = true; + lastIaqMs = lastSampleMs; + } else if (isfinite(lastGasOhms) && lastGasOhms > 0.0f) { + // Valid gas sample but the estimator has no output yet (warm-up/burn-in) + lastIaqValid = false; + } else if (lastIaqValid && !Throttle::isWithinTimespanMs(lastIaqMs, IAQ_CARRY_MS)) { + // Heater-unstable cycles (gas reported as 0) may ride on the previous + // IAQ briefly, but a persistently gasless sensor stops reporting IAQ + lastIaqValid = false; + } + + maybeSaveState(); +} + bool BME680Sensor::getMetrics(meshtastic_Telemetry *measurement) { -#if __has_include() - if (bme680.getData(BSEC_OUTPUT_RAW_PRESSURE).signal == 0) + if (!haveSample || !Throttle::isWithinTimespanMs(lastSampleMs, SAMPLE_FRESH_MS)) + captureSample(); + // A failed refresh must not freeze the last reading on the wire: publish + // only while the cache is genuinely fresh + if (!haveSample || !Throttle::isWithinTimespanMs(lastSampleMs, SAMPLE_FRESH_MS)) return false; measurement->variant.environment_metrics.has_temperature = true; measurement->variant.environment_metrics.has_relative_humidity = true; measurement->variant.environment_metrics.has_barometric_pressure = true; - measurement->variant.environment_metrics.has_gas_resistance = true; - measurement->variant.environment_metrics.has_iaq = true; - measurement->variant.environment_metrics.temperature = bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE).signal; - measurement->variant.environment_metrics.relative_humidity = - bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY).signal; - measurement->variant.environment_metrics.barometric_pressure = bme680.getData(BSEC_OUTPUT_RAW_PRESSURE).signal; - measurement->variant.environment_metrics.gas_resistance = bme680.getData(BSEC_OUTPUT_RAW_GAS).signal / 1000.0; - // Check if we need to save state to filesystem (every STATE_SAVE_PERIOD ms) - measurement->variant.environment_metrics.iaq = bme680.getData(BSEC_OUTPUT_IAQ).signal; - updateState(); -#else - if (!bme680->performReading()) { - LOG_ERROR("BME680Sensor::getMetrics: performReading failed"); - return false; + measurement->variant.environment_metrics.temperature = lastTemperature; + measurement->variant.environment_metrics.relative_humidity = lastHumidity; + measurement->variant.environment_metrics.barometric_pressure = lastPressureHPa; + + // A heater-unstable cycle reports gas_resistance 0; suppress the field + // rather than broadcasting a bogus 0 kOhm point + if (isfinite(lastGasOhms) && lastGasOhms > 0.0f) { + measurement->variant.environment_metrics.has_gas_resistance = true; + // Fleet convention is kOhm on the wire (despite the proto comment saying MOhm) + measurement->variant.environment_metrics.gas_resistance = lastGasOhms / 1000.0f; } - measurement->variant.environment_metrics.has_temperature = true; - measurement->variant.environment_metrics.has_relative_humidity = true; - measurement->variant.environment_metrics.has_barometric_pressure = true; - measurement->variant.environment_metrics.has_gas_resistance = true; - - measurement->variant.environment_metrics.temperature = bme680->readTemperature(); - measurement->variant.environment_metrics.relative_humidity = bme680->readHumidity(); - measurement->variant.environment_metrics.barometric_pressure = bme680->readPressure() / 100.0F; - - float gasRaw = bme680->readGas(); - measurement->variant.environment_metrics.gas_resistance = gasRaw / 1000.0; - - // IAQ approximation: humidity-compensated logarithmic mapping of gas resistance - // Gas sensor resistance drops with humidity; compensate to a 40% RH reference baseline - // Map compensated gas resistance (Ohms) to IAQ 0-500 using log-linear interpolation - // Clean air reference ~400 kOhm, polluted reference ~5 kOhm - if (gasRaw > 0.0f && !isfinite(gasRaw)) { - - static constexpr float LOG_UPPER = 12.899219f; // log(400k) - static constexpr float LOG_RANGE_INV = 1.0f / (12.899219f - 8.517193f); // 1 / (log(400k) - log(5k)) + if (lastIaqValid) { measurement->variant.environment_metrics.has_iaq = true; - measurement->variant.environment_metrics.iaq = (uint16_t)(fminf( - fmaxf(((LOG_UPPER - - logf(fmaxf(gasRaw * expf(0.035f * (measurement->variant.environment_metrics.relative_humidity - 40.0f)), - 1.0f))) * - LOG_RANGE_INV) * - 500.0f, - 0.0f), - 500.0f)); + measurement->variant.environment_metrics.iaq = lastIaq; } -#endif return true; } -#if __has_include() void BME680Sensor::loadState() { #ifdef FSCom + BME680IaqState state; + bool haveBlob = false; + spiLock->lock(); - auto file = FSCom.open(bsecConfigFileName, FILE_O_READ); + auto file = FSCom.open(stateFileName, FILE_O_READ); if (file) { - file.read((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE); + haveBlob = file.read((uint8_t *)&state, sizeof(state)) == sizeof(state); file.close(); - bme680.setState(bsecState); - LOG_INFO("%s: state read from %s", sensorName, bsecConfigFileName); - } else { - LOG_INFO("No %s state found (File: %s)", sensorName, bsecConfigFileName); } + // One-time cleanup of the proprietary-BSEC calibration blob from older firmware + if (FSCom.exists(legacyBsecStateFileName) && FSCom.remove(legacyBsecStateFileName)) + LOG_INFO("%s removed legacy state file %s", sensorName, legacyBsecStateFileName); spiLock->unlock(); + + if (!haveBlob) { + LOG_INFO("No %s state found (File: %s)", sensorName, stateFileName); + return; + } + if (iaqEstimator.restore(state, getValidTime(RTCQuality::RTCQualityDevice))) { + lastPersistedSampleCount = iaqEstimator.samplesFed(); + lastPersistedWarmup = iaqEstimator.warmupLeft(); + lastSaveEpochSecs = state.savedAtSecs; + LOG_INFO("%s IAQ state restored from %s (%u samples)", sensorName, stateFileName, iaqEstimator.samplesFed()); + } else { + LOG_INFO("%s IAQ state in %s rejected (stale or invalid), starting fresh", sensorName, stateFileName); + } #else LOG_ERROR("Filesystem not implemented"); #endif } -void BME680Sensor::updateState() +void BME680Sensor::maybeSaveState() +{ + if (!iaqEstimator.ready()) { + // Persist warm-up/burn-in progress whenever it advances, so a + // deep-sleeping SENSOR node (one sample per wake, RAM wiped between) + // still converges. Bounded to ~33 writes over the sensor's lifetime. + if (iaqEstimator.samplesFed() != lastPersistedSampleCount || iaqEstimator.warmupLeft() != lastPersistedWarmup) + saveState(); + return; + } + + uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice); + if (nowSecs != 0 && lastSaveEpochSecs != 0) { + // RTC available: gate on wall-clock age so short deep-sleep wakes don't + // rewrite flash every time + if (nowSecs >= lastSaveEpochSecs && (nowSecs - lastSaveEpochSecs) < STATE_SAVE_PERIOD_SECS) + return; + } else { + // No RTC: gate on the persisted sample count (it survives reboots, so + // deep-sleeping RTC-less nodes still refresh their baseline every + // ~STATE_SAVE_PERIOD_MS worth of samples) with an uptime cadence as a + // secondary trigger for always-on nodes + if (iaqEstimator.samplesFed() - lastPersistedSampleCount < STATE_SAVE_PERIOD_MS / SAMPLE_INTERVAL_MS && + !Throttle::hasElapsed(lastStateSaveMs, STATE_SAVE_PERIOD_MS)) + return; + } + saveState(); +} + +void BME680Sensor::saveState() { #ifdef FSCom - spiLock->lock(); - bool update = false; - if (stateUpdateCounter == 0) { - /* First state update when IAQ accuracy is >= 3 */ - accuracy = bme680.getData(BSEC_OUTPUT_IAQ).accuracy; - if (accuracy >= 2) { - LOG_DEBUG("%s state update IAQ accuracy %u >= 2", sensorName, accuracy); - update = true; - stateUpdateCounter++; - } else { - LOG_DEBUG("%s not updated, IAQ accuracy is %u < 2", sensorName, accuracy); - } - } else { - /* Update every STATE_SAVE_PERIOD minutes */ - // Interval since the last save; counter * period overflows uint32 past ~198 saves. - if (Throttle::hasElapsed(lastStateSaveMs, STATE_SAVE_PERIOD)) { - LOG_DEBUG("%s state update every %d minutes", sensorName, STATE_SAVE_PERIOD / 60000); - update = true; - stateUpdateCounter++; - } - } + BME680IaqState state; + uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice); + iaqEstimator.serialize(&state, nowSecs); - if (update) { - bme680.getState(bsecState); - if (FSCom.exists(bsecConfigFileName) && !FSCom.remove(bsecConfigFileName)) { - LOG_WARN("Can't remove old state file"); - } - auto file = FSCom.open(bsecConfigFileName, FILE_O_WRITE); - if (file) { - LOG_INFO("%s: state write to %s", sensorName, bsecConfigFileName); - file.write((uint8_t *)&bsecState, BSEC_MAX_STATE_BLOB_SIZE); - file.flush(); - file.close(); - // Checkpoint on success only, so a failed write is retried at the next interval. - lastStateSaveMs = Time::getMillis(); - } else { - LOG_INFO("Can't write %s state (File: %s)", sensorName, bsecConfigFileName); - } + // SafeFile takes the SPI lock itself; fullAtomic keeps the old state file + // in place until the verified replacement is renamed over it, so a power + // loss mid-save can't lose the banked burn-in progress (the blob is 24 + // bytes, so the atomic path costs nothing) + auto file = SafeFile(stateFileName, true); + file.write((uint8_t *)&state, sizeof(state)); + if (file.close()) { + lastPersistedSampleCount = iaqEstimator.samplesFed(); + lastPersistedWarmup = iaqEstimator.warmupLeft(); + lastSaveEpochSecs = nowSecs; + lastStateSaveMs = Time::getMillis(); + LOG_DEBUG("%s state write to %s", sensorName, stateFileName); + } else { + LOG_WARN("Can't write %s state (File: %s)", sensorName, stateFileName); } - spiLock->unlock(); #else LOG_ERROR("Filesystem not implemented"); #endif } -void BME680Sensor::checkStatus(const char *functionName) -{ - if (bme680.status < BSEC_OK) - LOG_ERROR("%s BSEC2 code: %d", functionName, bme680.status); - else if (bme680.status > BSEC_OK) - LOG_WARN("%s BSEC2 code: %d", functionName, bme680.status); - - if (bme680.sensor.status < BME68X_OK) - LOG_ERROR("%s BME68X code: %d", functionName, bme680.sensor.status); - else if (bme680.sensor.status > BME68X_OK) - LOG_WARN("%s BME68X code: %d", functionName, bme680.sensor.status); -} -#endif - #endif diff --git a/src/modules/Telemetry/Sensor/BME680Sensor.h b/src/modules/Telemetry/Sensor/BME680Sensor.h index b8c0bd810..a10ea1fef 100644 --- a/src/modules/Telemetry/Sensor/BME680Sensor.h +++ b/src/modules/Telemetry/Sensor/BME680Sensor.h @@ -1,66 +1,71 @@ #include "configuration.h" -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && (__has_include() || __has_include()) +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() #include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "BME680IaqEstimator.h" #include "TelemetrySensor.h" -#if __has_include() -#include -#include -#else #include #include -#endif -#define STATE_SAVE_PERIOD UINT32_C(360 * 60 * 1000) // That's 6 hours worth of millis() - -#if __has_include() -const uint8_t bsec_config[] = { -#include "config/bme680/bme680_iaq_33v_3s_4d/bsec_iaq.txt" -}; -#endif class BME680Sensor : public TelemetrySensor { private: -#if __has_include() - Bsec2 bme680; -#else using BME680Ptr = std::unique_ptr; static BME680Ptr makeBME680(TwoWire *bus) { return BME680Ptr(new Adafruit_BME680(bus)); } BME680Ptr bme680; -#endif + BME680IaqEstimator iaqEstimator; - protected: -#if __has_include() - const char *bsecConfigFileName = "/prefs/bsec.dat"; - uint8_t bsecState[BSEC_MAX_STATE_BLOB_SIZE] = {0}; - uint8_t accuracy = 0; - uint16_t stateUpdateCounter = 0; - uint32_t lastStateSaveMs = 0; // when the state blob was last written, for the save interval - bsecSensor sensorList[9] = {BSEC_OUTPUT_IAQ, - BSEC_OUTPUT_RAW_TEMPERATURE, - BSEC_OUTPUT_RAW_PRESSURE, - BSEC_OUTPUT_RAW_HUMIDITY, - BSEC_OUTPUT_RAW_GAS, - BSEC_OUTPUT_STABILIZATION_STATUS, - BSEC_OUTPUT_RUN_IN_STATUS, - BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, - BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY}; + static constexpr uint32_t SAMPLE_INTERVAL_MS = 60 * 1000; + // getMetrics() publishes the cached async sample only while it is this + // fresh; a failed refresh past this age drops the BME680 fields from the + // packet rather than freezing the last reading on the wire + static constexpr uint32_t SAMPLE_FRESH_MS = 2 * 60 * 1000; + // A heater-unstable cycle reports gas_resistance 0; carry the previous IAQ + // through such blips, but not forever + static constexpr uint32_t IAQ_CARRY_MS = 10 * 60 * 1000; + static constexpr uint32_t STATE_SAVE_PERIOD_MS = 6 * 60 * 60 * 1000; + static constexpr uint32_t STATE_SAVE_PERIOD_SECS = STATE_SAVE_PERIOD_MS / 1000; + + static constexpr const char *stateFileName = "/prefs/bme680.dat"; + static constexpr const char *legacyBsecStateFileName = "/prefs/bsec.dat"; // left behind by pre-open-IAQ firmware + + // Async sampling state (driven from runOnce) + bool readingInFlight = false; + uint32_t readingDoneAtMs = 0; + + // Cached last sample + bool haveSample = false; + uint32_t lastSampleMs = 0; + float lastTemperature = 0; + float lastHumidity = 0; + float lastPressureHPa = 0; + float lastGasOhms = 0; + uint16_t lastIaq = 0; + bool lastIaqValid = false; + uint32_t lastIaqMs = 0; + + // Persistence bookkeeping: burn-in progress is saved whenever it advances + // (bounded to ~33 writes lifetime), steady-state saves are RTC-gated so a + // deep-sleeping node doesn't rewrite flash on every wake + uint32_t lastPersistedSampleCount = UINT32_MAX; + uint32_t lastPersistedWarmup = UINT32_MAX; + uint32_t lastSaveEpochSecs = 0; + uint32_t lastStateSaveMs = 0; + + void captureSample(); void loadState(); - void updateState(); - void checkStatus(const char *functionName); -#endif + void maybeSaveState(); + void saveState(); public: BME680Sensor(); -#if __has_include() virtual int32_t runOnce() override; -#endif virtual bool getMetrics(meshtastic_Telemetry *measurement) override; virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; }; -#endif \ No newline at end of file +#endif diff --git a/test/test_bme680_iaq/test_main.cpp b/test/test_bme680_iaq/test_main.cpp new file mode 100644 index 000000000..d4844e872 --- /dev/null +++ b/test/test_bme680_iaq/test_main.cpp @@ -0,0 +1,352 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "modules/Telemetry/Sensor/BME680IaqEstimator.h" +#include +#include +#include + +// The estimator is pure math with no platform dependencies, so this suite has +// no feature guard: it runs everywhere the native tests run. + +namespace +{ +constexpr float CLEAN_GAS = 400000.0f; // ~clean-air gas resistance in Ohms +constexpr float REF_RH = 40.0f; + +// Total update() calls before the first IAQ value can appear: the warm-up +// discards plus the burn-in history requirement +constexpr uint32_t CALLS_TO_READY = BME680IaqEstimator::WARMUP_DISCARD + BME680IaqEstimator::BURN_IN_SAMPLES; + +/// Feed constant clean air until the estimator reports; returns the first IAQ +uint16_t makeReady(BME680IaqEstimator &est, float gasOhms = CLEAN_GAS, float rh = REF_RH) +{ + uint16_t iaq = 0xFFFF; + for (uint32_t i = 0; i < CALLS_TO_READY; i++) { + bool got = est.update(gasOhms, rh, &iaq); + TEST_ASSERT_EQUAL_MESSAGE(i == CALLS_TO_READY - 1, got, "IAQ must appear exactly when burn-in completes"); + } + return iaq; +} + +/// On-disk hash contract (xor of the five words preceding xorHash), replicated +/// so corruption tests can forge otherwise-consistent state +uint32_t stateHash(const BME680IaqState &s) +{ + uint32_t words[5]; + memcpy(words, &s, sizeof(words)); + return words[0] ^ words[1] ^ words[2] ^ words[3] ^ words[4]; +} +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Input validation --- + +void test_rejects_invalid_gas() +{ + BME680IaqEstimator est; + uint16_t iaq; + TEST_ASSERT_FALSE(est.update(0.0f, REF_RH, &iaq)); + TEST_ASSERT_FALSE(est.update(-5000.0f, REF_RH, &iaq)); + TEST_ASSERT_FALSE(est.update(NAN, REF_RH, &iaq)); + TEST_ASSERT_FALSE(est.update(INFINITY, REF_RH, &iaq)); + // Invalid samples must not consume warm-up or burn-in progress + makeReady(est); +} + +void test_invalid_humidity_is_neutral() +{ + BME680IaqEstimator est; + makeReady(est); + uint16_t iaq = 0xFFFF; + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, NAN, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); + // The fallback must not have moved the ceiling: a subsequent valid sample + // at the reference RH must still score 0 (catches a wrong fallback value, + // which would poison the baseline upward via ALPHA_UP) + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +// --- Warm-up / burn-in gating --- + +void test_no_output_until_burn_in() +{ + BME680IaqEstimator est; + uint16_t iaq = 0xFFFF; + for (uint32_t i = 0; i < CALLS_TO_READY - 1; i++) + TEST_ASSERT_FALSE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_FALSE(est.ready()); + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_TRUE(est.ready()); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +// --- Scoring --- + +void test_clean_air_scores_zero() +{ + BME680IaqEstimator est; + TEST_ASSERT_EQUAL_UINT16(0, makeReady(est)); +} + +void test_band_mapping_from_baseline_ratio() +{ + // Gas dropping to 1/N of the clean baseline should land in the UI band + // the design targets: 1.31x ~Good, 1.7x ~Moderate/Poor edge, 3x ~beep + // threshold, 15x+ pegged at 500 + struct { + float ratio; + uint16_t expected; + uint16_t tolerance; + } cases[] = { + {1.31f, 50, 6}, {1.7f, 98, 7}, {3.0f, 203, 8}, {15.0f, 499, 2}, {100.0f, 500, 1}, + }; + for (auto &c : cases) { + BME680IaqEstimator est; + makeReady(est); + uint16_t iaq = 0; + TEST_ASSERT_TRUE(est.update(CLEAN_GAS / c.ratio, REF_RH, &iaq)); + char msg[64]; + snprintf(msg, sizeof(msg), "ratio %.2f -> iaq %u", (double)c.ratio, iaq); + TEST_ASSERT_UINT_WITHIN_MESSAGE(c.tolerance, c.expected, iaq, msg); + } +} + +void test_band_mapping_holds_for_high_resistance_sensors() +{ + // Fresh/very clean sensors legitimately read in the MOhm range; the + // sanity clamp must not compress events there (regression: LN_CEIL_MAX + // was once ln(~730k), blinding the estimator above that) + BME680IaqEstimator est; + TEST_ASSERT_EQUAL_UINT16(0, makeReady(est, 5000000.0f)); + uint16_t iaq = 0; + TEST_ASSERT_TRUE(est.update(5000000.0f / 3.0f, REF_RH, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 203, iaq); +} + +void test_floor_clamps_bound_extreme_pollution() +{ + // Baseline seeded from heavily polluted air is clamped up to LN_FLOOR... + BME680IaqEstimator est; + uint16_t iaq = 0xFFFF; + for (uint32_t i = 0; i < CALLS_TO_READY; i++) + est.update(1000.0f, REF_RH, &iaq); + // ...so 1 kOhm scores as polluted relative to that floor, not as "normal" + TEST_ASSERT_TRUE(est.update(1000.0f, REF_RH, &iaq)); + TEST_ASSERT_UINT_WITHIN(10, 297, iaq); // (ln(5000) - ln(1000)) / ln(15) * 500 = (8.517 - 6.908) / 2.708 * 500 + // gas at the floor itself reads clean + TEST_ASSERT_TRUE(est.update(5000.0f, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); + // absurdly low readings rail at exactly 500 via the sample clamp + TEST_ASSERT_TRUE(est.update(1.0f, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(500, iaq); +} + +void test_humidity_comfort_penalty() +{ + // Present the same compensated log-resistance at 80 %RH: gas score stays + // ~0, and only the outside-the-30-60-deadband humidity penalty remains + BME680IaqEstimator est; + makeReady(est); + float gasAt80 = CLEAN_GAS * expf(-BME680IaqEstimator::KH * (80.0f - REF_RH)); + uint16_t iaq = 0xFFFF; + TEST_ASSERT_TRUE(est.update(gasAt80, 80.0f, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 38, iaq); // 0.15 * (20/40 * 500) = 37.5 + + // The dry side of the deadband penalizes symmetrically + BME680IaqEstimator estDry; + makeReady(estDry); + float gasAt10 = CLEAN_GAS * expf(-BME680IaqEstimator::KH * (10.0f - REF_RH)); + TEST_ASSERT_TRUE(estDry.update(gasAt10, 10.0f, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 38, iaq); + + // Inside the deadband there is no penalty at all + BME680IaqEstimator est2; + makeReady(est2); + float gasAt55 = CLEAN_GAS * expf(-BME680IaqEstimator::KH * (55.0f - REF_RH)); + TEST_ASSERT_TRUE(est2.update(gasAt55, 55.0f, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +// --- Baseline dynamics --- + +void test_baseline_resists_sustained_pollution() +{ + BME680IaqEstimator est; + makeReady(est); + uint16_t iaq = 0; + for (int i = 0; i < 10; i++) { + TEST_ASSERT_TRUE(est.update(100000.0f, REF_RH, &iaq)); + TEST_ASSERT_GREATER_THAN_UINT(200, iaq); // ln(4) -> ~256, must stay "bad" + } + // Back to clean air: the ceiling barely decayed, so the score snaps to 0 + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +void test_baseline_rises_fast_toward_cleaner_air() +{ + BME680IaqEstimator est; + makeReady(est, 300000.0f); + uint16_t iaq = 0xFFFF; + // Cleaner air scores 0 immediately and re-baselines within ~20 samples + for (int i = 0; i < 20; i++) { + TEST_ASSERT_TRUE(est.update(CLEAN_GAS, REF_RH, &iaq)); + TEST_ASSERT_EQUAL_UINT16(0, iaq); + } + // The old air now reads as polluted relative to the new baseline + TEST_ASSERT_TRUE(est.update(300000.0f, REF_RH, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 53, iaq); // ln(400/300)/ln(15) * 500 +} + +// --- Persistence --- + +void test_serialize_restore_roundtrip() +{ + BME680IaqEstimator est; + makeReady(est); + BME680IaqState state; + est.serialize(&state, 1000000); + TEST_ASSERT_EQUAL_UINT32(BME680IaqEstimator::MAGIC, state.magic); + TEST_ASSERT_EQUAL_UINT32(stateHash(state), state.xorHash); + TEST_ASSERT_EQUAL_UINT8(0, state.warmupRemaining); + + // Warm-up progress travels with the state: a restored estimator reports + // on its very first sample (essential for one-sample-per-wake nodes) + BME680IaqEstimator restored; + TEST_ASSERT_TRUE(restored.restore(state, 1000000 + 3600)); + uint16_t iaq = 0; + TEST_ASSERT_TRUE(restored.update(CLEAN_GAS / 3.0f, REF_RH, &iaq)); + TEST_ASSERT_UINT_WITHIN(8, 203, iaq); +} + +void test_restore_mid_burn_in_continues_progress() +{ + BME680IaqEstimator est; + uint16_t iaq; + for (uint32_t i = 0; i < BME680IaqEstimator::WARMUP_DISCARD + 5; i++) + est.update(CLEAN_GAS, REF_RH, &iaq); + BME680IaqState state; + est.serialize(&state, 0); + + BME680IaqEstimator restored; + TEST_ASSERT_TRUE(restored.restore(state, 0)); + int producedAt = -1; + for (int i = 1; i <= 40; i++) { + if (restored.update(CLEAN_GAS, REF_RH, &iaq)) { + producedAt = i; + break; + } + } + // 5 of 30 burn-in samples were banked before the "reboot" + TEST_ASSERT_EQUAL_INT(BME680IaqEstimator::BURN_IN_SAMPLES - 5, producedAt); +} + +void test_deep_sleep_node_converges_across_reboots() +{ + // Simulate a power-saving SENSOR role: one sample per wake, RAM wiped + // between wakes, state restored+persisted each cycle. Must produce IAQ + // after exactly warm-up + burn-in wakes, not never. + BME680IaqState state; + bool haveState = false; + uint16_t iaq = 0xFFFF; + int producedAt = -1; + for (int wake = 1; wake <= 50; wake++) { + BME680IaqEstimator est; + if (haveState) + TEST_ASSERT_TRUE_MESSAGE(est.restore(state, 0), "persisted progress must restore on every wake"); + if (est.update(CLEAN_GAS, REF_RH, &iaq)) { + producedAt = wake; + break; + } + est.serialize(&state, 0); + haveState = true; + } + TEST_ASSERT_EQUAL_INT((int)CALLS_TO_READY, producedAt); + TEST_ASSERT_EQUAL_UINT16(0, iaq); +} + +void test_restore_rejects_corruption() +{ + BME680IaqEstimator est; + makeReady(est); + BME680IaqState good; + est.serialize(&good, 1000000); + BME680IaqEstimator target; + + BME680IaqState bad = good; + bad.magic ^= 1; + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); + + bad = good; + bad.version = BME680IaqEstimator::VERSION + 1; + bad.xorHash = stateHash(bad); + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); + + bad = good; + bad.xorHash ^= 0xDEADBEEF; + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); + + // Consistent hash but implausible ceiling (the ceiling check only applies + // once samples have been accepted) + bad = good; + bad.lnCeiling = 20.0f; + bad.xorHash = stateHash(bad); + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); + + bad = good; + bad.lnCeiling = NAN; + bad.xorHash = stateHash(bad); + TEST_ASSERT_FALSE(target.restore(bad, 1000000)); +} + +void test_restore_staleness() +{ + BME680IaqEstimator est; + makeReady(est); + BME680IaqState state; + est.serialize(&state, 1000000); + + BME680IaqEstimator target; + TEST_ASSERT_FALSE(target.restore(state, 1000000 + BME680IaqEstimator::STATE_MAX_AGE_SECS + 1)); + TEST_ASSERT_TRUE(target.restore(state, 1000000 + BME680IaqEstimator::STATE_MAX_AGE_SECS - 1)); + + // Unknown age (no RTC at save time or now) is accepted rather than discarded + est.serialize(&state, 0); + BME680IaqEstimator target2; + TEST_ASSERT_TRUE(target2.restore(state, 2000000)); + est.serialize(&state, 1000000); + BME680IaqEstimator target3; + TEST_ASSERT_TRUE(target3.restore(state, 0)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== BME680 IAQ estimator ===\n"); + RUN_TEST(test_rejects_invalid_gas); + RUN_TEST(test_invalid_humidity_is_neutral); + RUN_TEST(test_no_output_until_burn_in); + RUN_TEST(test_clean_air_scores_zero); + RUN_TEST(test_band_mapping_from_baseline_ratio); + RUN_TEST(test_band_mapping_holds_for_high_resistance_sensors); + RUN_TEST(test_floor_clamps_bound_extreme_pollution); + RUN_TEST(test_humidity_comfort_penalty); + RUN_TEST(test_baseline_resists_sustained_pollution); + RUN_TEST(test_baseline_rises_fast_toward_cleaner_air); + RUN_TEST(test_serialize_restore_roundtrip); + RUN_TEST(test_restore_mid_burn_in_continues_progress); + RUN_TEST(test_deep_sleep_node_converges_across_reboots); + RUN_TEST(test_restore_staleness); + RUN_TEST(test_restore_rejects_corruption); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index 40d43dad9..1986c1a9a 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -44,15 +44,15 @@ custom_sdkconfig = CONFIG_BT_NIMBLE_ENABLED=y CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP=y -; Override lib_deps to use environmental_extra_no_bsec instead of environmental_extra -; BSEC library uses ~3.5KB DRAM which causes overflow on original ESP32 targets +; Overrides esp32_common's lib_deps: adds networking_extra and omits +; esp32_https_server (mesh/http is excluded from this target's build_src_filter) lib_deps = ${arduino_base.lib_deps} ${networking_base.lib_deps} ${networking_extra.lib_deps} ${radiolib_base.lib_deps} ${environmental_base.lib_deps} - ${environmental_extra_no_bsec.lib_deps} + ${environmental_extra.lib_deps} # TODO renovate https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 8a284162d..a435fdfa7 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -93,7 +93,6 @@ lib_ignore = ${esp32_common.lib_ignore} libpax esp8266-oled-ssd1306 - bsec2 esp32_idf5_https_server esp_driver_cam esp_http_server diff --git a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini index 1b36d2da9..2b6b9aabf 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini @@ -18,7 +18,6 @@ build_flags = -DELECROW_ThinkNode_M3 -DGPS_POWER_TOGGLE -D CONFIG_NFCT_PINS_AS_GPIOS=1 - -L "${platformio.libdeps_dir}/${this.__env__}/bsec2/src/cortex-m4/fpv4-sp-d16-hard" build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/ELECROW-ThinkNode-M3> lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index dfbd91876..a72b8c61e 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -20,18 +20,6 @@ build_flags = ${nrf52840_base.build_flags} build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> debug_tool = jlink -; TEMPORARY: drop BSEC2 + its BME68x driver. This image is ~2.3 KB OVER the 0xEA000 -; warm-store cap and has been failing the nrf52_warm_region guard on develop since -; 2026-08-05. Unlike the RAK boards there is no Ethernet stack to reclaim here -- nrf52_base -; already filters mesh/eth, mesh/api and mesh/wifi, and HAS_ETHERNET defaults to 0 -- so the -; sensor library is what has to go. BME680Sensor is gated on __has_include(), so -; ignoring the libraries compiles it out. Revert once the environmental sensor roster is -; opt-in per board rather than linked into every target. -lib_ignore = - ${nrf52_base.lib_ignore} - bsec2 - BME68x Sensor library - ; NRF52 ProMicro w/ E-Ink display [env:nrf52_promicro_diy-inkhud] board_level = extra diff --git a/variants/nrf52840/muzi_base/platformio.ini b/variants/nrf52840/muzi_base/platformio.ini index 90c871c20..3a2494281 100644 --- a/variants/nrf52840/muzi_base/platformio.ini +++ b/variants/nrf52840/muzi_base/platformio.ini @@ -15,7 +15,6 @@ build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/muzi_base -D MUZI_BASE -D CONFIG_NFCT_PINS_AS_GPIOS=1 - -L "${platformio.libdeps_dir}/${this.__env__}/bsec2/src/cortex-m4/fpv4-sp-d16-hard" build_src_filter = ${nrf52840_base.build_src_filter} +<../variants/nrf52840/muzi_base> lib_deps = From 30e6e0ec8c6318fa8e41841b57089bb7e32c20b0 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 13 Aug 2026 13:21:53 -0400 Subject: [PATCH 055/109] Do not build nucleo_wl55jc upon PR (#11489) Honestly don't build it at all, it's a devkit. --- variants/stm32/nucleo_wl55jc/platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/stm32/nucleo_wl55jc/platformio.ini b/variants/stm32/nucleo_wl55jc/platformio.ini index 261f2a9a3..9ff211a19 100644 --- a/variants/stm32/nucleo_wl55jc/platformio.ini +++ b/variants/stm32/nucleo_wl55jc/platformio.ini @@ -3,7 +3,7 @@ [env:nucleo_wl55jc] extends = stm32_base board = nucleo_wl55jc -board_level = pr +board_level = extra board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem build_flags = ${stm32_base.build_flags} From faa2c8fc524ad3c46018e6d596409eb43aa3d02f Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 13 Aug 2026 12:24:00 -0500 Subject: [PATCH 056/109] fix(portduino): don't segfault writing the trace file (#11493) The TraceFile path took the first variadic argument as a char* and streamed it, which only held while the tree's sole LOG_TRACE sites were LOG_TRACE("%s", json). Trace-level lines without a string argument (e.g. "Filesystem files:" from fsInit) read a garbage pointer and crashed meshtasticd at boot whenever Logging.TraceFile was configured. Format the message instead. The buffer covers the worst-case packet JSON (233-byte payload escaped 6x plus metadata, ~1.7 KB); the trace file is written untruncated today, so it must not be sized below that. Fixes #11490 Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1 Co-authored-by: Claude --- src/RedirectablePrint.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index f0ebbcc20..66a266d96 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -302,13 +302,18 @@ void RedirectablePrint::log(const char *logLevel, const char *format, ...) // level trace is special, two possible ways to handle it. if (strcmp(logLevel, MESHTASTIC_LOG_LEVEL_TRACE) == 0) { if (portduino_config.traceFilename != "") { + // Format the message rather than assuming the first vararg is a string: not every + // LOG_TRACE call passes one, and reading a char* that isn't there segfaults. Sized for + // the worst-case packet JSON (233-byte payload escaped 6x, plus metadata ~= 1.7 KB). + char traceBuf[2048]; va_list arg; va_start(arg, format); + vsnprintf(traceBuf, sizeof(traceBuf), format, arg); + va_end(arg); try { - traceFile << va_arg(arg, char *) << std::endl; + traceFile << traceBuf << std::endl; } catch (const std::ios_base::failure &e) { } - va_end(arg); } if (portduino_config.logoutputlevel < level_trace && strcmp(logLevel, MESHTASTIC_LOG_LEVEL_TRACE) == 0) { return; From a4001430905cd1ba72b70d832f9eba4e9ba19a2c Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:55:53 -0700 Subject: [PATCH 057/109] fix: improve acknowledged unicast retry reliability (#11320) * Improve acknowledged unicast retry reliability * Fix merged next-hop routing tests --------- Co-authored-by: Ben Meadors --- src/mesh/NextHopRouter.cpp | 15 ++- src/mesh/NextHopRouter.h | 17 ++- src/mesh/ReliableRouter.cpp | 6 +- test/test_nexthop_routing/test_main.cpp | 142 ++++++++++++++++++++++++ test/test_packet_signing/test_main.cpp | 27 +++++ 5 files changed, 196 insertions(+), 11 deletions(-) diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 3be7a1ba6..5b4511120 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -57,12 +57,18 @@ PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmission { packet = p; this->numRetransmissions = numRetransmissions - 1; // We subtract one, because we assume the user just did the first send + this->initialNumRetransmissions = this->numRetransmissions; } /** * Send a packet */ ErrorCode NextHopRouter::send(meshtastic_MeshPacket *p) +{ + return sendWithNextHop(p, true); +} + +ErrorCode NextHopRouter::sendWithNextHop(meshtastic_MeshPacket *p, bool trackRetransmission) { // Add any messages _we_ send to the seen message list (so we will ignore all retransmissions we see) p->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); // First set the relayer to us @@ -73,7 +79,8 @@ ErrorCode NextHopRouter::send(meshtastic_MeshPacket *p) // If it's from us, ReliableRouter already handles retransmissions if want_ack is set. If a next hop is set and hop limit is // not 0 or want_ack is set, start retransmissions - if ((!isFromUs(p) || !p->want_ack) && p->next_hop != NO_NEXT_HOP_PREFERENCE && (p->hop_limit > 0 || p->want_ack)) { + if (trackRetransmission && (!isFromUs(p) || !p->want_ack) && p->next_hop != NO_NEXT_HOP_PREFERENCE && + (p->hop_limit > 0 || p->want_ack)) { if (auto *copy = packetPool.allocCopy(*p)) startRetransmission(copy); // start retransmission for relayed packet } @@ -362,7 +369,7 @@ bool NextHopRouter::stopRetransmission(GlobalPacketId key) auto p = old->packet; /* Only when we already transmitted a packet via LoRa, we will cancel the packet in the Tx queue to avoid canceling a transmission if it was ACKed super fast via MQTT */ - if (old->numRetransmissions < NUM_RELIABLE_RETX - 1) { + if (old->numRetransmissions < old->initialNumRetransmissions) { // We only cancel it if we are the original sender or if we're not a router(_late) if (isFromUs(p) || roleAllowsCancelingFromTxQueue(p)) { // remove the 'original' (identified by originator and packet->id) from the txqueue and free it @@ -475,13 +482,13 @@ int32_t NextHopRouter::doRetransmissions() } } else { if (auto *copy = packetPool.allocCopy(*p.packet)) { - if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE) + if (sendWithNextHop(copy, false) == ERRNO_SHOULD_RELEASE) packetPool.release(copy); } } #else if (auto *copy = packetPool.allocCopy(*p.packet)) { - if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE) + if (sendWithNextHop(copy, false) == ERRNO_SHOULD_RELEASE) packetPool.release(copy); } #endif diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 3a19191fe..26cda830a 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -39,6 +39,9 @@ struct PendingPacket { /** Starts at NUM_RETRANSMISSIONS -1 and counts down. Once zero it will be removed from the list */ uint8_t numRetransmissions = 0; + /** Initial remaining retry count, used to detect whether a retry has fired. */ + uint8_t initialNumRetransmissions = 0; + PendingPacket() {} explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions); }; @@ -77,8 +80,8 @@ class GlobalPacketIdHashFunction Namely, in the PacketHistory, we keep track of (up to 3) relayers of a packet. When the ACK is delivered back to us via a node that also relayed the original packet, we use that node as next hop for the destination from then on. This makes sure that only when there’s a two-way connection, we assign a next hop. Both the ReliableRouter and NextHopRouter will do retransmissions (the - NextHopRouter only 1 time). For the final retry, if no one actually relayed the packet, it will reset the next hop in order to - fall back to the FloodingRouter again. Note that thus also intermediate hops will do a single retransmission if the intended + NextHopRouter only a small number of times). For the final retry, if no one actually relayed the packet, it will reset the next + hop in order to fall back to the FloodingRouter again. Intermediate hops also do bounded retransmissions if the intended next-hop didn’t relay, in order to fix changes in the middle of the route. */ class NextHopRouter : public FloodingRouter @@ -109,10 +112,12 @@ class NextHopRouter : public FloodingRouter return min(d, r); } - // The number of retransmissions intermediate nodes will do (actually 1 less than this) - constexpr static uint8_t NUM_INTERMEDIATE_RETX = 2; - // The number of retransmissions the original sender will do + // Total attempts for directed hop-level delivery, including the initial send. + constexpr static uint8_t NUM_INTERMEDIATE_RETX = 3; + // Existing reliable broadcast budget, including the initial send. constexpr static uint8_t NUM_RELIABLE_RETX = 3; + // Total attempts for acknowledged unicast from the originating node. + constexpr static uint8_t NUM_RELIABLE_UNICAST_ATTEMPTS = 5; // M3: bounded RAM route-health table (reuse-oldest eviction, like PacketHistory) constexpr static uint8_t ROUTE_HEALTH_MAX = 32; // ~12B/slot -> ~384B @@ -155,6 +160,8 @@ class NextHopRouter : public FloodingRouter */ PendingPacket *startRetransmission(meshtastic_MeshPacket *p, uint8_t numReTx = NUM_INTERMEDIATE_RETX); + ErrorCode sendWithNextHop(meshtastic_MeshPacket *p, bool trackRetransmission); + // Return true if we're allowed to cancel a packet in the txQueue (so we may never transmit it even once) bool roleAllowsCancelingFromTxQueue(const meshtastic_MeshPacket *p); diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 72ef73eab..f823232c2 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -30,8 +30,10 @@ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p) auto copy = packetPool.allocCopy(*p); DEBUG_HEAP_AFTER("ReliableRouter::send", copy); - if (copy) - startRetransmission(copy, NUM_RELIABLE_RETX); + if (copy) { + const uint8_t totalAttempts = isBroadcast(p->to) ? NUM_RELIABLE_RETX : NUM_RELIABLE_UNICAST_ATTEMPTS; + startRetransmission(copy, totalAttempts); + } } /* If we have pending retransmissions, add the airtime of this packet to it, because during that time we cannot receive an diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index 60afed5ce..4dca5b5e4 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -106,6 +106,44 @@ class NextHopRouterTestShim : public NextHopRouter using NextHopRouter::relayOpaquePacket; using Router::shouldDecrementHopLimit; // protected in Router + PendingPacket *trackForTest(const meshtastic_MeshPacket &packet, uint8_t totalAttempts) + { + auto *copy = packetPool.allocCopy(packet); + TEST_ASSERT_NOT_NULL(copy); + return startRetransmission(copy, totalAttempts); + } + + PendingPacket *trackWithDefaultBudgetForTest(const meshtastic_MeshPacket &packet) + { + auto *copy = packetPool.allocCopy(packet); + TEST_ASSERT_NOT_NULL(copy); + return startRetransmission(copy); + } + + bool stopForTest(NodeNum from, PacketId id) { return stopRetransmission(from, id); } + + meshtastic_MeshPacket *pendingPacketForTest(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + return entry ? entry->packet : nullptr; + } + + void fireNextRetryForTest(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + entry->nextTxMsec = 0; + doRetransmissions(); + } + + void markOneRetryFiredForTest(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + TEST_ASSERT_GREATER_THAN_UINT8(0, entry->numRetransmissions); + --entry->numRetransmissions; + } + bool filterViaFlooding(const meshtastic_MeshPacket *p) { return FloodingRouter::shouldFilterReceived(p); } bool filterViaNextHop(const meshtastic_MeshPacket *p) { return NextHopRouter::shouldFilterReceived(p); } @@ -132,6 +170,7 @@ class MockRadioInterface : public RadioInterface sendCount++; lastHopLimit = p->hop_limit; lastHopStart = p->hop_start; + sentNextHops.push_back(p->next_hop); if (declineAll || p->to == NODENUM_BROADCAST_NO_LORA) return ERRNO_SHOULD_RELEASE; @@ -146,10 +185,18 @@ class MockRadioInterface : public RadioInterface return 0; } + bool cancelSending(NodeNum, PacketId) override + { + cancelCount++; + return true; + } + int sendCount = 0; + uint32_t cancelCount = 0; bool declineAll = false; uint8_t lastHopLimit = 0; uint8_t lastHopStart = 0; + std::vector sentNextHops; }; class CaptureRadioInterface : public RadioInterface @@ -773,6 +820,92 @@ void test_reliableAckStopsNormalPendingTransmission(void) TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); } +void test_pending_does_not_cancel_radio_queue_before_first_retry(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(0x33333333); + p.from = kLocalNode; + p.id = 0x51000001; + shim->trackForTest(p, 5); + + TEST_ASSERT_TRUE(shim->stopForTest(kLocalNode, p.id)); + TEST_ASSERT_EQUAL_UINT32(0, mockIface->cancelCount); +} + +void test_pending_cancels_radio_queue_after_first_retry_for_any_budget(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(0x33333333); + p.from = kLocalNode; + p.id = 0x51000002; + shim->trackForTest(p, 5); + shim->markOneRetryFiredForTest(kLocalNode, p.id); + + TEST_ASSERT_TRUE(shim->stopForTest(kLocalNode, p.id)); + TEST_ASSERT_EQUAL_UINT32(1, mockIface->cancelCount); +} + +void test_directed_hop_tracks_three_total_attempts(void) +{ + installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(0x33333333); + p.id = 0x51530003; + + PendingPacket *entry = shim->trackWithDefaultBudgetForTest(p); + TEST_ASSERT_NOT_NULL(entry); + TEST_ASSERT_EQUAL_UINT8(3, entry->initialNumRetransmissions + 1); + TEST_ASSERT_TRUE(shim->stopForTest(p.from, p.id)); +} + +void test_intermediate_three_attempts_preserve_record_and_flood_last(void) +{ + MockRadioInterface *mockIface = installMockIface(); + constexpr NodeNum dest = 0x33333333; + mockNodeDB->addNode(dest, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, 0xAB); + mockNodeDB->addNode(0x000007AB, 0, true, 60); + + meshtastic_MeshPacket p = makeRebroadcastCandidate(dest); + p.id = 0x51530004; + p.next_hop = 0xAB; + PendingPacket *entry = shim->trackWithDefaultBudgetForTest(p); + TEST_ASSERT_NOT_NULL(entry); + meshtastic_MeshPacket *trackedPacket = entry->packet; + + shim->fireNextRetryForTest(p.from, p.id); + TEST_ASSERT_EQUAL_UINT32(1, mockIface->sentNextHops.size()); +#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED + TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockIface->sentNextHops[0]); +#else + TEST_ASSERT_EQUAL_HEX8(0xAB, mockIface->sentNextHops[0]); +#endif + TEST_ASSERT_EQUAL_PTR(trackedPacket, shim->pendingPacketForTest(p.from, p.id)); + + shim->fireNextRetryForTest(p.from, p.id); + TEST_ASSERT_EQUAL_UINT32(2, mockIface->sentNextHops.size()); + TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockIface->sentNextHops[1]); + TEST_ASSERT_TRUE(shim->stopForTest(p.from, p.id)); +} + +void test_early_flood_preserves_fresh_verified_route(void) +{ + MockRadioInterface *mockIface = installMockIface(); + constexpr NodeNum dest = 0x33333333; + mockNodeDB->addNode(dest, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, 0xAB); + mockNodeDB->addNode(0x000007AB, 0, true, 60); + shim->noteRouteLearned(dest, 0xAB, millis()); + + meshtastic_MeshPacket p = makeRebroadcastCandidate(dest); + p.id = 0x51530005; + p.next_hop = 0xAB; + TEST_ASSERT_NOT_NULL(shim->trackWithDefaultBudgetForTest(p)); + + shim->fireNextRetryForTest(p.from, p.id); + TEST_ASSERT_EQUAL_UINT32(1, mockIface->sentNextHops.size()); + TEST_ASSERT_EQUAL_HEX8(0xAB, mockIface->sentNextHops[0]); + TEST_ASSERT_TRUE(shim->stopForTest(p.from, p.id)); +} + +// Control: proves the NO_LORA case below turns on the `to` field alone. void test_rebroadcast_normal_broadcast_is_relayed(void) { MockRadioInterface *mockIface = installMockIface(); @@ -846,6 +979,8 @@ void test_event_mode_hop_behavior(void) void setup() { initializeTestEnvironment(); + AirTime testAirTime; + airTime = &testAirTime; UNITY_BEGIN(); airTimeFixture = std::make_unique(); @@ -913,6 +1048,13 @@ void setup() RUN_TEST(test_eventPolicy_seededRetrySuppressesTxUntilGateOff); RUN_TEST(test_reliableAckStopsNormalPendingTransmission); + printf("\n=== pending retransmission bookkeeping ===\n"); + RUN_TEST(test_pending_does_not_cancel_radio_queue_before_first_retry); + RUN_TEST(test_pending_cancels_radio_queue_after_first_retry_for_any_budget); + RUN_TEST(test_directed_hop_tracks_three_total_attempts); + RUN_TEST(test_intermediate_three_attempts_preserve_record_and_flood_last); + RUN_TEST(test_early_flood_preserves_fresh_verified_route); + printf("\n=== rebroadcast of NODENUM_BROADCAST_NO_LORA ===\n"); RUN_TEST(test_rebroadcast_normal_broadcast_is_relayed); RUN_TEST(test_rebroadcast_no_lora_broadcast_is_not_relayed); diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 3b5c70ad3..d7453e29a 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -174,6 +174,11 @@ class AuthPipelineRouter : public ReliableRouter PendingPacket *entry = findPendingPacket(from, id); return entry ? entry->nextTxMsec : 0; } + uint8_t pendingTotalAttempts(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + return entry ? entry->initialNumRetransmissions + 1 : 0; + } size_t pendingCount() const { return pending.size(); } void clearPending() { @@ -1548,6 +1553,26 @@ void test_C14_duty_cycle_limited_reliable_send_remains_pending(void) initRegion(); } +void test_C15_reliable_unicast_tracks_five_total_attempts(void) +{ + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + p.id = 0x51530001; + p.want_ack = true; + + TEST_ASSERT_EQUAL(ERRNO_OK, pipelineRouter->send(packetPool.allocCopy(p))); + TEST_ASSERT_EQUAL_UINT8(5, pipelineRouter->pendingTotalAttempts(LOCAL_NODE, p.id)); +} + +void test_C16_reliable_broadcast_keeps_three_total_attempts(void) +{ + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD); + p.id = 0x51530002; + p.want_ack = true; + + TEST_ASSERT_EQUAL(ERRNO_OK, pipelineRouter->send(packetPool.allocCopy(p))); + TEST_ASSERT_EQUAL_UINT8(3, pipelineRouter->pendingTotalAttempts(LOCAL_NODE, p.id)); +} + // 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) @@ -2123,6 +2148,8 @@ void setup() 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); + RUN_TEST(test_C15_reliable_unicast_tracks_five_total_attempts); + RUN_TEST(test_C16_reliable_broadcast_keeps_three_total_attempts); 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 778041ec064e56bbb802fe823363ff97cb05bf42 Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:57:07 +0200 Subject: [PATCH 058/109] fix esp32 time sync (RTC / NTP) (#11494) --- src/gps/RTC.cpp | 15 ++++++++++++++- src/mesh/wifi/WiFiAPClient.cpp | 8 ++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 5c18cca62..93e59e31e 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -140,6 +140,9 @@ RTCSetResult readFromRTC() RTCQuality oldQuality = currentQuality; timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) + settimeofday(&tv, NULL); +#endif currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); } @@ -186,6 +189,9 @@ RTCSetResult readFromRTC() RTCQuality oldQuality = currentQuality; timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) + settimeofday(&tv, NULL); +#endif currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); } @@ -222,6 +228,9 @@ RTCSetResult readFromRTC() RTCQuality oldQuality = currentQuality; timeStartMs64 = now; zeroOffsetSecs = tv.tv_sec; +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) + settimeofday(&tv, NULL); +#endif currentQuality = RTCQualityDevice; onTimeSourceQualityChanged(oldQuality, currentQuality); } @@ -389,7 +398,11 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd if (stm32wlRtcAvailable()) { STM32RTC::getInstance().setEpoch(tv->tv_sec); } -#elif defined(ARCH_ESP32) || defined(ARCH_RP2040) +#endif + // Keep the POSIX system clock in sync on platforms that support it so that + // any code using time() (e.g. the device-ui thread) sees the correct wall time + // even when a hardware RTC chip is also present and handled above. +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) settimeofday(tv, NULL); #endif diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index c7fc1b25f..8bb80cd96 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -294,7 +294,7 @@ static int32_t reconnectWiFi() #ifndef DISABLE_NTP if (WiFi.isConnected() && (!Throttle::isWithinTimespanMs(lastrun_ntp, 43200000) || (lastrun_ntp == 0))) { // every 12 hours LOG_DEBUG("Update NTP time from %s", config.network.ntp_server); - if (timeClient.update()) { + if (timeClient.forceUpdate()) { LOG_DEBUG("NTP success - set RTCQualityNTP if needed"); struct timeval tv; @@ -316,7 +316,11 @@ static int32_t reconnectWiFi() return 1000; // check once per second } else { onNetworkConnected(); // will only do anything once (guarded by APStartupComplete) - return 300000; // every 5 minutes +#ifndef DISABLE_NTP + if (lastrun_ntp == 0) + return 5000; // NTP not yet synced, retry sooner +#endif + return 300000; // every 5 minutes } } From 119cd261be3f8303fc4cd79ee981c3bce95641e4 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 13 Aug 2026 19:42:56 +0000 Subject: [PATCH 059/109] Cache docker image layers in Registry (#11495) --- .github/workflows/docker_build.yml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 8c16e75aa..aeb621f68 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -82,13 +82,20 @@ jobs: plat: ${{ inputs.platform }} run: echo "cleaned_platform=${plat}" | sed 's/\//_/g' >> $GITHUB_OUTPUT - - name: Docker login + - name: DockerHub login if: ${{ inputs.push }} uses: docker/login-action@v4 with: username: meshtastic password: ${{ secrets.DOCKER_FIRMWARE_TOKEN }} + - name: GHCR login + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker tag id: meta uses: docker/metadata-action@v6 @@ -98,6 +105,19 @@ jobs: GHA-${{ steps.version.outputs.long }}-${{ inputs.distro }}-${{ steps.sanitize_platform.outputs.cleaned_platform }} flavor: latest=false + - name: Docker setup caching + id: docker-cache + env: + BASE_REF: ${{ github.event.merge_group.base_ref || github.event.pull_request.base.ref || github.ref_name }} + run: | + base=$(echo "${BASE_REF#refs/heads/}" | sed 's/\//_/g') + ref=ghcr.io/${{ github.repository }}-cache:${base}-${{ inputs.distro }}-${{ steps.sanitize_platform.outputs.cleaned_platform }} + echo "cache_from=type=registry,ref=${ref}" >> $GITHUB_OUTPUT + case "${GITHUB_EVENT_NAME}" in + merge_group|pull_request) ;; + *) echo "cache_to=type=registry,ref=${ref},mode=max,ignore-error=true" >> $GITHUB_OUTPUT ;; + esac + - name: Docker build and push uses: docker/build-push-action@v7 id: docker_variant @@ -110,6 +130,6 @@ jobs: platforms: ${{ inputs.platform }} build-args: | PIO_ENV=${{ inputs.pio_env }} - # Disabled for now: Cache image layers in GitHub Actions cache to speed up subsequent builds. - # cache-from: type=gha - # cache-to: type=gha,mode=max + # Cache image layers in GitHub Container Registry to speed up subsequent builds. + cache-from: ${{ steps.docker-cache.outputs.cache_from }} + cache-to: ${{ steps.docker-cache.outputs.cache_to || '' }} From a00675e00c3445927d5e24ce820bc40bde34aaaf Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:36:06 +0200 Subject: [PATCH 060/109] unset can have what it likes (#11496) --- src/mesh/MeshRadio.h | 13 ++++ src/mesh/RadioInterface.cpp | 13 ++++ test/test_admin_radio/test_main.cpp | 113 +++++++++++++++++++++++++--- 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index e5b54d6a2..624d96623 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -39,6 +39,11 @@ struct RegionProfile { */ extern float getEffectiveDutyCycle(); +// True if `preset` appears in at least one region's preset list, i.e. it is a real preset +// some region offers rather than a fabricated or long-retired enum value. Defined in +// RadioInterface.cpp, where the region table lives. +extern bool isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset preset); + extern const RegionProfile PROFILE_STD; extern const RegionProfile PROFILE_EU868; extern const RegionProfile PROFILE_UNDEF; @@ -71,6 +76,14 @@ struct RegionInfo { if (profile->presets[i] == preset) return true; } + // UNSET is "no region chosen yet", not a regulatory domain: the radio is held silent + // either way (see the region==UNSET gates in RadioLibInterface::send/handleReceive), + // so there is nothing here to enforce. Rejecting would instead destroy a preset the + // user already picked - the clamp rewrites it to LONG_FAST, and that clamp runs on + // every boot and on every set_config while the region is unset. Accept any preset a + // real region offers; fabricated values still fail and are clamped as before. + if (code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + return isKnownModemPreset(preset); return false; } size_t getNumPresets() const diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 5c757cd84..36a35ac6b 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -672,6 +672,19 @@ const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code) return r; } +bool isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + // Walks profile->presets directly rather than RegionInfo::supportsPreset(), which calls + // back here for the UNSET entry. UNSET terminates the table, so it is checked last. + for (const RegionInfo *r = regions;; r++) { + for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END; i++) + if (r->profile->presets[i] == preset) + return true; + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + return false; + } +} + void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map) { map = meshtastic_LoRaRegionPresetMap_init_zero; diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index f5237b055..ebdad8827 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -607,21 +607,43 @@ static void test_validateConfigLora_bogusPresetRejected() TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); } -static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast() +static void test_validateConfigLora_unsetRegionAcceptsAnyRealPreset() { - // UNSET uses PROFILE_UNDEF which has only LONG_FAST + // UNSET is "no region chosen yet", not a regulatory domain, so it must not invalidate + // a preset the user already picked - whichever region that preset belongs to. meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; cfg.use_preset = true; - cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; - TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_FAST should be valid for UNSET"); + const meshtastic_Config_LoRaConfig_ModemPreset realPresets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST, meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_TINY_FAST, + }; + for (auto preset : realPresets) { + cfg.modem_preset = preset; + char msg[64]; + snprintf(msg, sizeof(msg), "preset %d should be valid for UNSET", (int)preset); + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), msg); + } - cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_FAST should be invalid for UNSET"); + // A value no region offers is still invalid, so the clamp can repair it. + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "bogus preset should be invalid for UNSET"); +} - cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; - TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for UNSET"); +static void test_isKnownModemPreset_matchesRegionTable() +{ + // Every preset some region offers is "known"... + TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST)); + TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO)); + TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW)); + TEST_ASSERT_TRUE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW)); + + // ...and nothing else is, including the retired VERY_LONG_SLOW enum value. + TEST_ASSERT_FALSE(isKnownModemPreset(meshtastic_Config_LoRaConfig_ModemPreset_VERY_LONG_SLOW)); + TEST_ASSERT_FALSE(isKnownModemPreset((meshtastic_Config_LoRaConfig_ModemPreset)99)); } static void test_validateConfigLora_allPresetsValidForLORA24() @@ -706,7 +728,7 @@ static void test_clampConfigLora_customBwValidLeftUnchanged() static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast() { - // UNSET uses PROFILE_UNDEF with only LONG_FAST; any other preset should clamp to it + // UNSET's default preset is LONG_FAST; a value no region offers clamps to it meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; cfg.use_preset = true; @@ -717,6 +739,21 @@ static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast() TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); } +static void test_clampConfigLora_unsetRegionKeepsRealPreset() +{ + // The boot-time clamp (NodeDB::loadFromDisk) runs on every boot. While the region is + // unset it must leave a real preset alone rather than rewriting it to LONG_FAST. + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, cfg.modem_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, cfg.region); +} + static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault() { // LORA_24 uses PROFILE_STD; a bogus preset should clamp to LONG_FAST (first in PRESETS_STD) @@ -1436,6 +1473,14 @@ static void test_regionInfo_supportsPreset() const RegionInfo *eu866 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866); TEST_ASSERT_TRUE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW)); TEST_ASSERT_FALSE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST)); + + // UNSET enforces nothing (the radio is silent regardless), so it supports every real + // preset - not just the LONG_FAST its own profile advertises as the default. + const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST)); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO)); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST)); + TEST_ASSERT_FALSE(unset->supportsPreset((meshtastic_Config_LoRaConfig_ModemPreset)99)); } static void test_checkConfigRegion_quietCheckReportsReason() @@ -1501,6 +1546,50 @@ static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejecte TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); } +static void test_handleSetConfig_presetChosenBeforeRegionSurvives() +{ + // A fresh device: the user picks a preset in the app before choosing a region. The + // unset region must not clamp that choice back to LONG_FAST. + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_UNSET, true, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); +} + +static void test_handleSetConfig_unsettingRegionKeepsPreset() +{ + // Clearing the region is a valid request in its own right. It must take effect (and + // disable tx) without discarding the config because the preset outlives the region. + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + config.lora.tx_enabled = true; + initRegion(); + + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_UNSET, true, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + c.payload_variant.lora.tx_enabled = true; + + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, config.lora.modem_preset); + TEST_ASSERT_FALSE_MESSAGE(config.lora.tx_enabled, "unsetting the region must disable tx"); + + // Restore the region table pointer for subsequent tests + initRegion(); +} + // ----------------------------------------------------------------------- // Channel-configuration warning + coalescing tests // @@ -1919,7 +2008,8 @@ void setup() RUN_TEST(test_validateConfigLora_customBandwidthFitsUS); RUN_TEST(test_validateConfigLora_customBandwidthFitsEU868); RUN_TEST(test_validateConfigLora_bogusPresetRejected); - RUN_TEST(test_validateConfigLora_unsetRegionOnlyAcceptsLongFast); + RUN_TEST(test_validateConfigLora_unsetRegionAcceptsAnyRealPreset); + RUN_TEST(test_isKnownModemPreset_matchesRegionTable); RUN_TEST(test_validateConfigLora_allPresetsValidForLORA24); // clampConfigLora() @@ -1928,6 +2018,7 @@ void setup() RUN_TEST(test_clampConfigLora_customBwTooWideClampedToDefaultBw); RUN_TEST(test_clampConfigLora_customBwValidLeftUnchanged); RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast); + RUN_TEST(test_clampConfigLora_unsetRegionKeepsRealPreset); RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault); // Region-locked preset swap @@ -1977,6 +2068,8 @@ void setup() RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner); RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion); RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected); + RUN_TEST(test_handleSetConfig_presetChosenBeforeRegionSurvives); + RUN_TEST(test_handleSetConfig_unsettingRegionKeepsPreset); // Channel-configuration warning + coalescing RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage); From 34680833b88b37bbcffca0b31dffe45f29e9d35c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 14 Aug 2026 00:51:40 +0000 Subject: [PATCH 061/109] fix(test): make the native-windows test suite build and run (#11482) * fix(test): make the native-windows test suite build and run pio test -e native-windows failed every suite at the build stage. Five independent causes, all Windows-only: - TestUtil.cpp called lstat(), which MinGW-w64 does not provide. The state-checkpoint walk added in #11322 is fenced with ARCH_PORTDUINO, which native-windows also satisfies, so all 53 suites failed to compile. Route it through a stat() shim on _WIN32. - test_default, test_http_content_handler, test_meshpacket_serializer and test_serial define no setUp/tearDown and relied on the weak defaults PlatformIO emits in unity_config.c. GCC lowers a weak definition on PE-COFF to a weak external, leaving the symbol undefined, so it does not satisfy unity.c's reference and the link fails. Define them explicitly, as the other 49 suites already do. - test_mqtt included , absent on MinGW, for htonl(). Use winsock2.h there. - test_gps_update_scheduling uses TEST_ASSERT_DOUBLE_WITHIN. Unity omits double support unless UNITY_INCLUDE_DOUBLE is defined, so the assertion compiled to an unconditional failure. Define it for the env. - test_getfiles_rejects_overlong_path is excluded on _WIN32. Overrunning the 228-byte file_name needs at least 229 bytes below the portduino root, and that root is already ~34 bytes, so every qualifying path passes the 260-byte MAX_PATH: the nested mkdir() fails, the file is never created, and getFiles() has nothing to drop. No component layout satisfies both limits. Each of the seven suites that failed on Windows was verified individually after the change. test_fscommon_getfiles still fails in a full run, for a cause outside this change: rmDir() does not remove directories on Windows, so empty dirs left by an earlier run survive setUp() and make getFiles() report a depth truncation. That is a pre-existing FSCommon bug, reported separately. No Linux or macOS behaviour changes: every guard is _WIN32-only except UNITY_INCLUDE_DOUBLE, which is scoped to env:native-windows. * fix(test): define UNITY_INCLUDE_DOUBLE for every native env The flag was scoped to env:native-windows, but the gap is not Windows-specific. Verified on Debian with gcc against the Linux env's own Unity 2.6.1 and PlatformIO's generated native unity_config: UNITY_INCLUDE_DOUBLE : NOT defined UNITY_EXCLUDE_DOUBLE : defined test_double_within:FAIL: Unity Double Precision Disabled UNITY_INCLUDE_DOUBLE appears nowhere in the repo, the ini files, the workflow, or PlatformIO's unity runner, which adds only UNITY_INCLUDE_CONFIG_H. So TEST_ASSERT_DOUBLE_* is an always-failing stub on Linux and macOS too, not only on Windows. Moved to portduino_base.build_flags_common, which every native env resolves: native, native-tft, native-fb, native-tft-debug, coverage, coverage-event-policy, native-macos, native-windows and native-wasm. This does change Linux and macOS: TEST_ASSERT_DOUBLE_* becomes a real comparison instead of a stub. test_gps_update_scheduling is the only suite using those macros and its arithmetic is integer-based and bit-identical across platforms, so it should pass wherever it runs. Note it currently reports PASSED on CI in 0.03s while emitting no Unity output at all, so those assertions appear never to execute there; that is tracked separately and is not addressed here. --- test/TestUtil.cpp | 16 +++++++++++++++- test/test_default/test_main.cpp | 4 ++++ test/test_fscommon_getfiles/test_main.cpp | 6 ++++++ test/test_http_content_handler/test_main.cpp | 4 ++++ .../test_serializer.cpp | 4 ++++ test/test_mqtt/MQTT.cpp | 6 ++++++ test/test_serial/SerialModule.cpp | 5 +++++ variants/native/portduino.ini | 3 +++ 8 files changed, 47 insertions(+), 1 deletion(-) diff --git a/test/TestUtil.cpp b/test/TestUtil.cpp index f9e14373d..58cd34c15 100644 --- a/test/TestUtil.cpp +++ b/test/TestUtil.cpp @@ -63,6 +63,20 @@ void testStateCheckpoint(const char *, const char *) {} namespace { +/// MinGW-w64 has no lstat(): Windows has no POSIX symlink stat, and nothing in a test sandbox +/// creates a symlink, so stat() sees the same thing for every entry walk() can reach. +#ifdef _WIN32 +inline int lstatCompat(const char *path, struct stat *st) +{ + return stat(path, st); +} +#else +inline int lstatCompat(const char *path, struct stat *st) +{ + return lstat(path, st); +} +#endif + /// Content fingerprint, used only to answer "did this file change?". FNV-1a rather than a real /// digest because the answer is a boolean and the files are a few KB of protobuf; nothing here /// records a hash as an expected value, which is what would make this a snapshot test. @@ -96,7 +110,7 @@ void walk(const std::string &root, const std::string &rel, std::mapd_name) : rel + "/" + e->d_name; const std::string childPath = root + "/" + childRel; struct stat st; - if (lstat(childPath.c_str(), &st) != 0) + if (lstatCompat(childPath.c_str(), &st) != 0) continue; if (S_ISDIR(st.st_mode)) walk(root, childRel, out); diff --git a/test/test_default/test_main.cpp b/test/test_default/test_main.cpp index ee4fc1627..36c06e977 100644 --- a/test/test_default/test_main.cpp +++ b/test/test_default/test_main.cpp @@ -277,6 +277,10 @@ void test_trafficType_overflowSaturates() TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), res); } +// Required by Unity: PlatformIO's weak defaults do not link on MinGW (PE-COFF weak externals). +void setUp(void) {} +void tearDown(void) {} + void setup() { // Small delay to match other test mains diff --git a/test/test_fscommon_getfiles/test_main.cpp b/test/test_fscommon_getfiles/test_main.cpp index 943bc43a7..eaa776d1b 100644 --- a/test/test_fscommon_getfiles/test_main.cpp +++ b/test/test_fscommon_getfiles/test_main.cpp @@ -112,6 +112,9 @@ void test_getfiles_depth_limit(void) // 4. A path that will not fit meshtastic_FileInfo::file_name is dropped, not truncated into the // manifest, and the drop is reported. +// Not built on Windows: any path long enough to overrun the 228-byte file_name also exceeds the +// 260-byte MAX_PATH, so the tree is never created and there is nothing to drop. +#ifndef _WIN32 void test_getfiles_rejects_overlong_path(void) { // file_name is 228 bytes; build a nested path that overruns it while each component stays @@ -148,6 +151,7 @@ void test_getfiles_rejects_overlong_path(void) *strrchr(dir, '/') = '\0'; } } +#endif // 5. pathEndsWithDot() - no entry in the manifest may end in '.', which is how the walk filters the // "." and ".." pseudo-entries some backends return. @@ -231,7 +235,9 @@ void setup() RUN_TEST(test_getfiles_respects_max_count); RUN_TEST(test_getfiles_unlimited_when_under_cap); RUN_TEST(test_getfiles_depth_limit); +#ifndef _WIN32 RUN_TEST(test_getfiles_rejects_overlong_path); +#endif RUN_TEST(test_getfiles_skips_dot_entries); RUN_TEST(test_getfiles_reports_sizes); RUN_TEST(test_getfiles_missing_dir_is_empty); diff --git a/test/test_http_content_handler/test_main.cpp b/test/test_http_content_handler/test_main.cpp index 3b628a2b2..c5b5d32a1 100644 --- a/test/test_http_content_handler/test_main.cpp +++ b/test/test_http_content_handler/test_main.cpp @@ -8,6 +8,10 @@ static void test_placeholder() } extern "C" { +// Required by Unity: PlatformIO's weak defaults do not link on MinGW (PE-COFF weak externals). +void setUp(void) {} +void tearDown(void) {} + void setup() { initializeTestEnvironment(); diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp index 82e79f8e1..db863ca3c 100644 --- a/test/test_meshpacket_serializer/test_serializer.cpp +++ b/test/test_meshpacket_serializer/test_serializer.cpp @@ -23,6 +23,10 @@ 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(); +// Required by Unity: PlatformIO's weak defaults do not link on MinGW (PE-COFF weak externals). +void setUp(void) {} +void tearDown(void) {} + void setup() { UNITY_BEGIN(); diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 3c4f1ab6a..b67cf31ab 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -17,7 +17,13 @@ #include #include +// htonl() for remoteIP() below. MinGW has no ; the byte-order helpers live in +// winsock2.h, which must precede any the Arduino shims pull in. +#ifdef _WIN32 +#include +#else #include +#endif #include #include diff --git a/test/test_serial/SerialModule.cpp b/test/test_serial/SerialModule.cpp index 6539d0ad3..48808db85 100644 --- a/test/test_serial/SerialModule.cpp +++ b/test/test_serial/SerialModule.cpp @@ -2,6 +2,11 @@ #include "TestUtil.h" #include +// Required by Unity: PlatformIO's weak defaults do not link on MinGW (PE-COFF weak externals). +// Outside the guard below so both the portduino and the stub setup() get them. +void setUp(void) {} +void tearDown(void) {} + #ifdef ARCH_PORTDUINO #include "configuration.h" diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 5997cf1fb..7787adc9c 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -57,6 +57,9 @@ build_flags_common = -std=gnu17 -std=gnu++17 -DMAX_TFT_COLOR_REGIONS=64 + ; Unity omits double support unless asked, compiling TEST_ASSERT_DOUBLE_* into an + ; unconditional "Unity Double Precision Disabled" failure (test_gps_update_scheduling). + -DUNITY_INCLUDE_DOUBLE build_flags = ${portduino_base.build_flags_common} From b42f59a940f88cd6a40c2d3003a2c38d01338f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 14 Aug 2026 08:34:57 +0000 Subject: [PATCH 062/109] fix(t-impulse-plus): enable 1200bps touch for nrfutil upload (#11499) Board lacked use_1200bps_touch, so uploads targeted the running application CDC instead of the bootloader. Adds wait_for_upload_port and the 0x239A:0x00DA bootloader hwid. --- boards/t-impulse-plus.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/boards/t-impulse-plus.json b/boards/t-impulse-plus.json index 83b289b42..511e308d2 100644 --- a/boards/t-impulse-plus.json +++ b/boards/t-impulse-plus.json @@ -7,7 +7,10 @@ "cpu": "cortex-m4", "extra_flags": "-DARDUINO_NRF52840_T_IMPULSE_PLUS -DNRF52840_XXAA", "f_cpu": "64000000L", - "hwids": [["0x239A", "0x8029"]], + "hwids": [ + ["0x239A", "0x8029"], + ["0x239A", "0x00DA"] + ], "usb_product": "T-Impulse-Plus-nRF52840", "mcu": "nrf52840", "variant": "t-impulse-plus", @@ -37,6 +40,8 @@ "maximum_ram_size": 248832, "maximum_size": 815104, "require_upload_port": true, + "wait_for_upload_port": true, + "use_1200bps_touch": true, "speed": 115200, "protocol": "nrfutil", "protocols": [ From 9bf80c2fc69e69db3dc5ae1918d8913c5d410b1d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:37:33 +0000 Subject: [PATCH 063/109] Update meshtastic/device-ui digest to e1de01e (#11497) 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 7e05f10f0..185c8a9ea 100644 --- a/platformio.ini +++ b/platformio.ini @@ -137,7 +137,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/7bfabe5e9ba468b4f82a15bae69e6b068ca124f0.zip + https://github.com/meshtastic/device-ui/archive/e1de01e0b3c4a6b149c00e95d59cfb0cca7ad49e.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From cd18f382f1be90a8809c67627fd0f4a045c14b82 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:08:55 +0000 Subject: [PATCH 064/109] chore(deps): update adafruit sh110x to v2.1.15 (#11464) 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 185c8a9ea..6e0d20d46 100644 --- a/platformio.ini +++ b/platformio.ini @@ -164,7 +164,7 @@ lib_deps = # renovate: datasource=github-tags depName=Adafruit DPS310 packageName=adafruit/Adafruit_DPS310 https://github.com/adafruit/Adafruit_DPS310/archive/refs/tags/1.1.6.zip # renovate: datasource=github-tags depName=Adafruit SH110x packageName=adafruit/Adafruit_SH110x - https://github.com/adafruit/Adafruit_SH110x/archive/refs/tags/2.1.14.zip + https://github.com/adafruit/Adafruit_SH110x/archive/2.1.15.zip # renovate: datasource=github-tags depName=Adafruit MCP9808 packageName=adafruit/Adafruit_MCP9808_Library https://github.com/adafruit/Adafruit_MCP9808_Library/archive/refs/tags/2.0.2.zip # renovate: datasource=github-tags depName=Adafruit INA260 packageName=adafruit/Adafruit_INA260 From 4d524320b5321616a0195802ecda34c573c13352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 14 Aug 2026 09:09:03 +0000 Subject: [PATCH 065/109] Add agent guideline: documentation belongs in the docs repo (#11492) Mirrored in AGENTS.md, .github/copilot-instructions.md and CLAUDE.md, with a matching CodeRabbit path instruction for **/*.md. --- .coderabbit.yaml | 13 +++++++++++++ .github/copilot-instructions.md | 1 + AGENTS.md | 1 + CLAUDE.md | 4 ++++ 4 files changed, 19 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index a193662cb..cdcd43f3a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -31,3 +31,16 @@ reviews: instructions: > meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging. Ensure configurations include metadata found in other configs. + - path: "**/*.md" + instructions: > + Documentation does not live in this repo; it lives in + https://github.com/meshtastic/meshtastic. Flag any NEW .md file that documents a + feature, configuration surface, API, wire format, or design, and ask for it to be + opened against the docs repo instead. Flag any attempt to recreate a docs/ + directory: it was deleted in #11488 and must not come back. Flag write-ups left in + the tree - investigation notes, mitigation plans, migration checklists, "how we got + here" narrative, summaries of what a change did - that content belongs in the PR + description and commit message. Documentation that does belong upstream must read + as a technical manual, not a novel: what it does, the settings in user terms, the + API or protocol a client speaks. No debugging journey, no rationale essays, no + changelog prose. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d8c5fed32..3d20ca974 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -338,6 +338,7 @@ firmware/ - Use `assert()` for invariants that should never fail - C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.) - **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. +- **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. - **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. - `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`. diff --git a/AGENTS.md b/AGENTS.md index 5c67d124d..66a8ca684 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor - **Never edit or commit files under `src/mesh/generated/`.** They are regenerated from the [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) repo by the `update_protobufs.yml` workflow (entry point: `bin/regen-protos.sh`). Local edits will be overwritten and create merge conflicts. If a `.proto` change is needed, open a PR against the protobufs repo first, then let the workflow re-sync this repo. - **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings. - **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. +- **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. - **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison). - `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown. - `Throttle::hasElapsed(lastMs, intervalMs)` - its complement, true once the interval has passed (inclusive `>=`). Prefer this to spelling `!isWithinTimespanMs(...)`. diff --git a/CLAUDE.md b/CLAUDE.md index 325fb7100..a7dbf6991 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,3 +22,7 @@ **Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions, the build system, CI/CD, the native C++ test suite, and the MCP Server & Hardware Test Harness. Read it top-to-bottom before starting any non-trivial change. This file (`CLAUDE.md`) is a short pointer for Claude Code sessions. Slash commands live in `.claude/commands/`. + +## House rule: documentation does not live in this repo + +This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. From a661fd8cd4e8b54b72e10dd8de1dd8795239847c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 14 Aug 2026 09:12:31 +0000 Subject: [PATCH 066/109] fixes #11466 (#11487) * fixes #11466 * Keep locally-addressed routing feedback out of the phone echo filter allocForSending stamps ACK/NAK packets with from == our nodenum and sendLocal defaults to RX_SRC_RADIO, so the loopback gate never applies. Filtering on isFromUs alone dropped implicit rebroadcast ACKs, duty-cycle and NO_INTERFACE NAKs, and PhoneAPI rate-limit errors on their way to the client. Add coverage through the real RoutingModule, which the mocked one used by the rest of the suite cannot exercise, and correct the test seam comment. * Clean up the temporary RoutingModule in tearDown() A failed Unity assertion longjmps out of the test, so the in-test delete never ran and the module stayed registered in MeshModule::modules for every later test. Track it at file scope, as realNeighborInfoModule already is. --- src/mesh/MeshService.cpp | 8 ++ src/mesh/MeshService.h | 3 + test/test_mesh_module/test_main.cpp | 120 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 591245db9..0667e0b13 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -114,6 +114,14 @@ int MeshService::handleFromRadio(const meshtastic_MeshPacket *mp) } } + // Our own packet heard back off the mesh, which the duplicate cache only suppresses best-effort. + // Clients can't tell an echo from genuine ingress, so it surfaces as an incoming message. Packets + // addressed to us are locally-generated feedback (implicit ACK, NAK, routing error), not an echo. + if (isFromUs(mp) && !isToUs(mp)) { + LOG_DEBUG("Skip phone echo of our own packet 0x%08x", mp->id); + return 0; + } + printPacket("Forwarding to phone", mp); if (auto *toPhone = packetPool.allocCopy(*mp)) sendToPhone(toPhone); diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index 8ddc6e434..7adcdb7c6 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -222,6 +222,9 @@ class MeshService /// needs to keep the packet around it makes a copy int handleFromRadio(const meshtastic_MeshPacket *p); friend class RoutingModule; +#ifdef PIO_UNIT_TESTING + friend class MeshServicePhoneDeliveryTest; +#endif }; extern MeshService *service; diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index a39988064..9cc0d1811 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -265,6 +265,7 @@ static MockMeshService *mockService; static MockRouter *mockRouter; static MockRoutingModule *mockRoutingModule; static NeighborInfoModule *realNeighborInfoModule; +static RoutingModule *realRoutingModule; static std::vector dispatchModules; template static T *registerDispatchModule(T *module) @@ -273,6 +274,14 @@ template static T *registerDispatchModule(T *module) return module; } +// Swap the mocked RoutingModule for a real one. tearDown() owns the cleanup because a failed +// assertion longjmps out of the test, which would otherwise leave it registered in MeshModule::modules. +static void installRealRoutingModule() +{ + realRoutingModule = new RoutingModule(); + routingModule = realRoutingModule; +} + static meshtastic_MeshPacket makeRequest(meshtastic_PortNum port) { meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; @@ -335,6 +344,7 @@ void setUp(void) mockRoutingModule = new MockRoutingModule(); routingModule = mockRoutingModule; + realRoutingModule = nullptr; testModule = new TestModule(); memset(&testPacket, 0, sizeof(testPacket)); @@ -355,6 +365,9 @@ void tearDown(void) delete testModule; testModule = nullptr; + delete realRoutingModule; + realRoutingModule = nullptr; + delete mockRoutingModule; mockRoutingModule = nullptr; routingModule = nullptr; @@ -606,6 +619,108 @@ static void test_localReplyToSelf_isDeliveredToPhone() TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); // nothing went toward the radio } +// handleFromRadio() is private to MeshService, which befriends RoutingModule and, under +// PIO_UNIT_TESTING, this seam. +class MeshServicePhoneDeliveryTest +{ + public: + static void deliver(const meshtastic_MeshPacket &p) { service->handleFromRadio(&p); } +}; + +static void test_handleFromRadio_remotePacketReachesPhone() +{ + meshtastic_MeshPacket rx = meshtastic_MeshPacket_init_zero; + rx.from = REMOTE_NODE; + rx.to = NODENUM_BROADCAST; + rx.id = 0x0BADF00D; + rx.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + rx.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + + MeshServicePhoneDeliveryTest::deliver(rx); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(0x0BADF00D, toPhone->id); + mockService->releaseToPool(toPhone); + TEST_ASSERT_NULL(mockService->getForPhone()); +} + +// A packet we originated, coming back around, must not be echoed to the client that sent it. +static void test_handleFromRadio_ownPacketIsNotEchoedToPhone() +{ + meshtastic_MeshPacket ours = meshtastic_MeshPacket_init_zero; + ours.from = LOCAL_NODE; + ours.to = NODENUM_BROADCAST; + ours.id = 0x5E1F0001; + ours.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + ours.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + + MeshServicePhoneDeliveryTest::deliver(ours); + TEST_ASSERT_NULL(mockService->getForPhone()); + + // Same for the from==0 spelling handleToRadio stamps on phone-originated packets. + ours.from = 0; + ours.id = 0x5E1F0002; + MeshServicePhoneDeliveryTest::deliver(ours); + TEST_ASSERT_NULL(mockService->getForPhone()); +} + +// A packet from us *addressed to us* is locally-generated feedback, not an echo, and must still be +// delivered - suppressing it would silently drop every ACK/NAK the client relies on. +static void test_handleFromRadio_ownPacketAddressedToUsReachesPhone() +{ + meshtastic_MeshPacket ack = meshtastic_MeshPacket_init_zero; + ack.from = LOCAL_NODE; + ack.to = LOCAL_NODE; + ack.id = 0x5E1F0003; + ack.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + ack.decoded.portnum = meshtastic_PortNum_ROUTING_APP; + ack.decoded.request_id = 0x0C0FFEE0; + + MeshServicePhoneDeliveryTest::deliver(ack); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(0x0C0FFEE0, toPhone->decoded.request_id); + mockService->releaseToPool(toPhone); + TEST_ASSERT_NULL(mockService->getForPhone()); +} + +// sendAckNak stamps from == our nodenum and to == us, and sendLocal defaults to RX_SRC_RADIO, so the +// loopback gate never applies and only handleFromRadio's filter gates the implicit ACK / NAK path. +static void test_localAckNak_reachesPhoneViaRealRoutingModule() +{ + installRealRoutingModule(); + + realRoutingModule->sendAckNak(meshtastic_Routing_Error_NONE, LOCAL_NODE, 0xFEEDBEEF, 0); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, toPhone->decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0xFEEDBEEF, toPhone->decoded.request_id); + TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->to); + TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->from); + mockService->releaseToPool(toPhone); +} + +// The mirror of the above: a broadcast we originated, heard back off the mesh, must not reach the +// phone even though it travels the same RoutingModule path. +static void test_ownBroadcastEcho_isDroppedByRealRoutingModule() +{ + installRealRoutingModule(); + + meshtastic_MeshPacket echo = meshtastic_MeshPacket_init_zero; + echo.from = LOCAL_NODE; + echo.to = NODENUM_BROADCAST; + echo.id = 0x5E1F0004; + echo.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + echo.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + + MeshModule::callModules(echo, RX_SRC_RADIO); + + TEST_ASSERT_NULL(mockService->getForPhone()); +} + // Full loop: a phone-originated want_response request (from == 0, RX_SRC_USER) dispatched // through the real router must produce a module reply that reaches the phone queue. static void test_phoneRequest_replyReachesPhone() @@ -736,6 +851,11 @@ void setup() RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket); RUN_TEST(test_dispatch_realNeighborInfoCannotShadowTelemetryOwner); RUN_TEST(test_localReplyToSelf_isDeliveredToPhone); + RUN_TEST(test_handleFromRadio_remotePacketReachesPhone); + RUN_TEST(test_handleFromRadio_ownPacketIsNotEchoedToPhone); + RUN_TEST(test_handleFromRadio_ownPacketAddressedToUsReachesPhone); + RUN_TEST(test_localAckNak_reachesPhoneViaRealRoutingModule); + RUN_TEST(test_ownBroadcastEcho_isDroppedByRealRoutingModule); RUN_TEST(test_phoneRequest_replyReachesPhone); RUN_TEST(test_nestedLocalSend_isDeferred_notReentrant); RUN_TEST(test_deferredChain_drainsBreadthFirst); From 5e54262fe129d8d9e9054956c78dd30f7d6a3f94 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 14 Aug 2026 09:33:39 +0000 Subject: [PATCH 067/109] refactor(io): unique_ptr ownership for motion sensors and I2C keyboard (#11458) * refactor(io): unique_ptr ownership for motion sensors and I2C keyboard - AccelerometerThread / MagnetometerThread: the owned MotionSensor becomes unique_ptr, removing the manual delete/null bookkeeping in clean(). Deletion behavior is unchanged (MotionSensor's destructor is virtual). - KbI2cBase: the TCA keyboard was a reference member bound to an anonymous heap allocation - ownership was invisible and nothing could ever free it. It becomes unique_ptr with an out-of-line destructor (the base type is only forward-declared in the header). - GeoCoord::pointAtDistance returned shared_ptr with no shared ownership anywhere (and no callers); return by value instead. No behavior change. * fix(io): make TCA8418KeyboardBase destructor public for unique_ptr ownership * refactor(gps): delete dead pointAtDistance instead of converting it Per review: zero callers in this repo or device-ui, and the math was wrong at both ends (rangeMetersToRadians multiplies meters by 1852, treating meters as nautical miles). Remove it, its now-unused helper, and the include the old shared_ptr signature pulled in. --- src/gps/GeoCoord.cpp | 35 -------------------------------- src/gps/GeoCoord.h | 5 ----- src/input/TCA8418KeyboardBase.h | 5 +++-- src/input/kbI2cBase.cpp | 28 +++++++++++++------------ src/input/kbI2cBase.h | 7 ++++++- src/motion/AccelerometerThread.h | 29 +++++++++++++------------- src/motion/MagnetometerThread.h | 9 ++++---- 7 files changed, 44 insertions(+), 74 deletions(-) diff --git a/src/gps/GeoCoord.cpp b/src/gps/GeoCoord.cpp index 1fc60c304..4f3496681 100644 --- a/src/gps/GeoCoord.cpp +++ b/src/gps/GeoCoord.cpp @@ -521,41 +521,6 @@ float GeoCoord::bearing(double lat1, double lon1, double lat2, double lon2) return atan2(y, x); } -/** - * Ported from http://www.edwilliams.org/avform147.htm#Intro - * @brief Convert from meters to range in radians on a great circle - * @param range_meters - * The range in meters - * @return range in radians on a great circle - */ -float GeoCoord::rangeMetersToRadians(double range_meters) -{ - // 1 nm is 1852 meters - double distance_nm = range_meters * 1852; - return (PI / (180 * 60)) * distance_nm; -} - -/** - * Create a new point based on the passed-in point - * Ported from http://www.edwilliams.org/avform147.htm#LL - * @param bearing - * The bearing in radians - * @param range_meters - * range in meters - * @return GeoCoord object of point at bearing and range from initial point - */ -std::shared_ptr GeoCoord::pointAtDistance(double bearing, double range_meters) -{ - double range_radians = rangeMetersToRadians(range_meters); - double lat1 = this->getLatitude() * 1e-7; - double lon1 = this->getLongitude() * 1e-7; - double lat = asin(sin(lat1) * cos(range_radians) + cos(lat1) * sin(range_radians) * cos(bearing)); - double dlon = atan2(sin(bearing) * sin(range_radians) * cos(lat1), cos(range_radians) - sin(lat1) * sin(lat)); - double lon = fmod(lon1 - dlon + PI, 2 * PI) - PI; - - return std::make_shared(double(lat), double(lon), this->getAltitude()); -} - /** * Convert bearing to degrees * @param bearing diff --git a/src/gps/GeoCoord.h b/src/gps/GeoCoord.h index 5afa78430..027f39f14 100644 --- a/src/gps/GeoCoord.h +++ b/src/gps/GeoCoord.h @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -103,7 +102,6 @@ class GeoCoord static void convertWGS84ToOSGB36(const double lat, const double lon, double &osgb_Latitude, double &osgb_Longitude); static float latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b); static float bearing(double lat1, double lon1, double lat2, double lon2); - static float rangeMetersToRadians(double range_meters); static unsigned int bearingToDegrees(const char *bearing); static const char *degreesToBearing(unsigned int degrees); @@ -112,9 +110,6 @@ class GeoCoord static double toRadians(double deg); static double toDegrees(double r); - // Point to point conversions - std::shared_ptr pointAtDistance(double bearing, double range); - // Lat lon alt getters int32_t getLatitude() const { return _latitude; } int32_t getLongitude() const { return _longitude; } diff --git a/src/input/TCA8418KeyboardBase.h b/src/input/TCA8418KeyboardBase.h index e608c6da5..caa9a4044 100644 --- a/src/input/TCA8418KeyboardBase.h +++ b/src/input/TCA8418KeyboardBase.h @@ -52,6 +52,9 @@ class TCA8418KeyboardBase virtual bool hasEvent(void) const; virtual char dequeueEvent(void); + // Public so owners (KbI2cBase's unique_ptr) can destroy through the base + virtual ~TCA8418KeyboardBase() {} + protected: enum KeyState { Init, Idle, Held, Busy }; @@ -132,8 +135,6 @@ class TCA8418KeyboardBase virtual void queueEvent(char); - virtual ~TCA8418KeyboardBase() {} - protected: // Set the size of the keypad matrix // All other rows and columns are set as inputs. diff --git a/src/input/kbI2cBase.cpp b/src/input/kbI2cBase.cpp index 88386c5c9..b78677460 100644 --- a/src/input/kbI2cBase.cpp +++ b/src/input/kbI2cBase.cpp @@ -21,20 +21,22 @@ extern uint8_t kb_model; KbI2cBase::KbI2cBase(const char *name) : concurrency::OSThread(name), #if defined(T_DECK_PRO) - TCAKeyboard(*(new TDeckProKeyboard())) + TCAKeyboard(new TDeckProKeyboard()) #elif defined(T_LORA_PAGER) - TCAKeyboard(*(new TLoraPagerKeyboard())) + TCAKeyboard(new TLoraPagerKeyboard()) #elif defined(M5STACK_CARDPUTER_ADV) - TCAKeyboard(*(new CardputerKeyboard())) + TCAKeyboard(new CardputerKeyboard()) #elif defined(HACKADAY_COMMUNICATOR) - TCAKeyboard(*(new HackadayCommunicatorKeyboard())) + TCAKeyboard(new HackadayCommunicatorKeyboard()) #else - TCAKeyboard(*(new TCA8418Keyboard())) + TCAKeyboard(new TCA8418Keyboard()) #endif { this->_originName = name; } +KbI2cBase::~KbI2cBase() = default; + uint8_t read_from_14004(TwoWire *i2cBus, uint8_t reg, uint8_t *data, uint8_t length) { uint8_t readflag = 0; @@ -70,7 +72,7 @@ int32_t KbI2cBase::runOnce() MPRkeyboard.begin(MPR121_KB_ADDR, i2cBus); } if (cardkb_found.address == TCA8418_KB_ADDR) { - TCAKeyboard.begin(TCA8418_KB_ADDR, i2cBus); + TCAKeyboard->begin(TCA8418_KB_ADDR, i2cBus); } break; #endif @@ -85,7 +87,7 @@ int32_t KbI2cBase::runOnce() MPRkeyboard.begin(MPR121_KB_ADDR, &Wire); } if (cardkb_found.address == TCA8418_KB_ADDR) { - TCAKeyboard.begin(TCA8418_KB_ADDR, &Wire); + TCAKeyboard->begin(TCA8418_KB_ADDR, &Wire); } break; case ScanI2C::NO_I2C: @@ -259,10 +261,10 @@ int32_t KbI2cBase::runOnce() break; } case 0x84: { // Adafruit TCA8418 - TCAKeyboard.trigger(); + TCAKeyboard->trigger(); InputEvent e = {}; - while (TCAKeyboard.hasEvent()) { - char nextEvent = TCAKeyboard.dequeueEvent(); + while (TCAKeyboard->hasEvent()) { + char nextEvent = TCAKeyboard->dequeueEvent(); e.inputEvent = INPUT_BROKER_ANYKEY; e.kbchar = 0x00; e.source = this->_originName; @@ -361,9 +363,9 @@ int32_t KbI2cBase::runOnce() // LOG_DEBUG("TCA8418 Notifying: %i Char: %c", e.inputEvent, e.kbchar); this->notifyObservers(&e); } - TCAKeyboard.trigger(); + TCAKeyboard->trigger(); } - TCAKeyboard.clearInt(); + TCAKeyboard->clearInt(); break; } case 0x02: { @@ -553,6 +555,6 @@ int32_t KbI2cBase::runOnce() void KbI2cBase::toggleBacklight(bool on) { #if defined(T_LORA_PAGER) - TCAKeyboard.setBacklight(on); + TCAKeyboard->setBacklight(on); #endif } diff --git a/src/input/kbI2cBase.h b/src/input/kbI2cBase.h index ae769dff8..a2f2e3cc0 100644 --- a/src/input/kbI2cBase.h +++ b/src/input/kbI2cBase.h @@ -6,12 +6,17 @@ #include "Wire.h" #include "concurrency/OSThread.h" +#include + class TCA8418KeyboardBase; class KbI2cBase : public Observable, public concurrency::OSThread { public: explicit KbI2cBase(const char *name); + // Out-of-line: TCA8418KeyboardBase is only forward-declared here, so the unique_ptr + // deleter must be instantiated in the .cpp where the type is complete + ~KbI2cBase(); void toggleBacklight(bool on); protected: @@ -24,6 +29,6 @@ class KbI2cBase : public Observable, public concurrency::OST BBQ10Keyboard Q10keyboard; MPR121Keyboard MPRkeyboard; - TCA8418KeyboardBase &TCAKeyboard; + std::unique_ptr TCAKeyboard; bool is_sym = false; }; \ No newline at end of file diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 571767715..0bcb504fb 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -21,6 +21,8 @@ #include "LSM6DS3Sensor.h" #include "MPU6050Sensor.h" #include "MotionSensor.h" + +#include #ifdef HAS_QMA6100P #include "QMA6100PSensor.h" #endif @@ -33,7 +35,7 @@ extern ScanI2C::DeviceAddress accelerometer_found; class AccelerometerThread : public concurrency::OSThread { private: - MotionSensor *sensor = nullptr; + std::unique_ptr sensor; bool isInitialised = false; public: @@ -93,56 +95,56 @@ class AccelerometerThread : public concurrency::OSThread switch (device.type) { #ifdef HAS_BMA423 case ScanI2C::DeviceType::BMA423: - sensor = new BMA423Sensor(device); + sensor.reset(new BMA423Sensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::MPU6050: - sensor = new MPU6050Sensor(device); + sensor.reset(new MPU6050Sensor(device)); break; #endif case ScanI2C::DeviceType::BMX160: - sensor = new BMX160Sensor(device); + sensor.reset(new BMX160Sensor(device)); break; #if __has_include() case ScanI2C::DeviceType::LIS3DH: case ScanI2C::DeviceType::SC7A20: - sensor = new LIS3DHSensor(device); + sensor.reset(new LIS3DHSensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::LSM6DS3: - sensor = new LSM6DS3Sensor(device); + sensor.reset(new LSM6DS3Sensor(device)); break; #endif #ifdef HAS_STK8XXX case ScanI2C::DeviceType::STK8BAXX: - sensor = new STK8XXXSensor(device); + sensor.reset(new STK8XXXSensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::ICM20948: - sensor = new ICM20948Sensor(device); + sensor.reset(new ICM20948Sensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::ICM42607P: - sensor = new ICM42607PSensor(device); + sensor.reset(new ICM42607PSensor(device)); break; #endif #if __has_include() case ScanI2C::DeviceType::BMM150: - sensor = new BMM150Sensor(device); + sensor.reset(new BMM150Sensor(device)); break; #endif #ifdef HAS_BMI270 case ScanI2C::DeviceType::BMI270: - sensor = new BMI270Sensor(device); + sensor.reset(new BMI270Sensor(device)); break; #endif #ifdef HAS_QMA6100P case ScanI2C::DeviceType::QMA6100P: - sensor = new QMA6100PSensor(device); + sensor.reset(new QMA6100PSensor(device)); break; #endif default: @@ -185,8 +187,7 @@ class AccelerometerThread : public concurrency::OSThread void clean() { isInitialised = false; - delete sensor; - sensor = nullptr; + sensor.reset(); } }; diff --git a/src/motion/MagnetometerThread.h b/src/motion/MagnetometerThread.h index cf632867d..8185f296a 100644 --- a/src/motion/MagnetometerThread.h +++ b/src/motion/MagnetometerThread.h @@ -10,12 +10,14 @@ #include "MMC5983MASensor.h" #include "MotionSensor.h" +#include + extern ScanI2C::DeviceAddress magnetometer_found; class MagnetometerThread : public concurrency::OSThread { private: - MotionSensor *sensor = nullptr; + std::unique_ptr sensor; ScanI2C::FoundDevice device; bool isInitialised = false; @@ -69,7 +71,7 @@ class MagnetometerThread : public concurrency::OSThread switch (device.type) { #if __has_include() case ScanI2C::DeviceType::MMC5983MA: - sensor = new MMC5983MASensor(device); + sensor.reset(new MMC5983MASensor(device)); break; #endif default: @@ -106,8 +108,7 @@ class MagnetometerThread : public concurrency::OSThread void clean() { isInitialised = false; - delete sensor; - sensor = nullptr; + sensor.reset(); } }; From 0ff10318adbc364e8dad0de31d051e3517ec3cce Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 14 Aug 2026 10:04:57 +0000 Subject: [PATCH 068/109] refactor(net): unique_ptr for connection-lifecycle objects (#11459) - WiFiServerAPI/ethServerAPI apiPort and ethApiServer's listener are create/destroy cycles that repeat across WiFi teardown and W5500 chip resets; the manual delete+null bookkeeping becomes reset(). (ethTlsApiServer's listener is left for a follow-up: that file is already touched by the partial-init fix PR and converting it here would conflict.) - ContentHandler::handleFormUpload held its body parser raw with delete on four separate exit paths of a per-request handler; any future early return was a silent leak. unique_ptr removes all four. - The portduino ch341Hal global becomes unique_ptr. The LoRa-error recovery loop's delete/null/new sequence was correct only by hand-preserved ordering; it becomes reset()/make_unique. RadioLibHAL keeps a non-owning raw pointer, as before. No behavior change. --- src/main.cpp | 9 ++++----- src/mesh/RadioInterface.cpp | 2 +- src/mesh/api/WiFiServerAPI.cpp | 9 +++------ src/mesh/api/ethServerAPI.cpp | 7 +++---- src/mesh/eth/ethApiServer.cpp | 10 ++++------ src/mesh/http/ContentHandler.cpp | 9 +++------ src/platform/portduino/PortduinoGlue.cpp | 10 +++++----- src/platform/portduino/PortduinoGlue.h | 3 ++- 8 files changed, 25 insertions(+), 34 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 6c13515af..6b192aa04 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1498,14 +1498,13 @@ void loop() LOG_ERROR("LoRa error detected, recovering"); router->addInterface(nullptr); if (portduino_config.lora_spi_dev == "ch341") { - if (ch341Hal != nullptr) { - delete ch341Hal; - ch341Hal = nullptr; + if (ch341Hal) { + ch341Hal.reset(); sleep(3); } try { - ch341Hal = new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, - portduino_config.lora_usb_pid); + ch341Hal = std::make_unique(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, + portduino_config.lora_usb_pid); } catch (std::exception &e) { std::cerr << e.what() << std::endl; std::cerr << "Could not initialize CH341 device!" << std::endl; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 36a35ac6b..58bd498c5 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -414,7 +414,7 @@ std::unique_ptr initLoRa() LOG_DEBUG("Activate %s radio on SPI port %s", portduino_config.loraModules[portduino_config.lora_module].c_str(), portduino_config.lora_spi_dev.c_str()); if (portduino_config.lora_spi_dev == "ch341") { - RadioLibHAL = ch341Hal; + RadioLibHAL = ch341Hal.get(); // non-owning: the ch341 HAL stays owned by the global unique_ptr } else { if (RadioLibHAL != nullptr) { delete RadioLibHAL; diff --git a/src/mesh/api/WiFiServerAPI.cpp b/src/mesh/api/WiFiServerAPI.cpp index 4d729f5c7..8b46a5725 100644 --- a/src/mesh/api/WiFiServerAPI.cpp +++ b/src/mesh/api/WiFiServerAPI.cpp @@ -4,23 +4,20 @@ #if HAS_WIFI #include "WiFiServerAPI.h" -static WiFiServerPort *apiPort; +static std::unique_ptr apiPort; void initApiServer(int port) { // Start API server on port 4403 if (!apiPort) { - apiPort = new WiFiServerPort(port); + apiPort = std::make_unique(port); LOG_INFO("API server listen on TCP port %d", port); apiPort->init(); } } void deInitApiServer() { - if (apiPort) { - delete apiPort; - apiPort = nullptr; - } + apiPort.reset(); } WiFiServerAPI::WiFiServerAPI(WiFiClient &_client) : ServerAPI(_client) diff --git a/src/mesh/api/ethServerAPI.cpp b/src/mesh/api/ethServerAPI.cpp index c75d53ff7..953c9921f 100644 --- a/src/mesh/api/ethServerAPI.cpp +++ b/src/mesh/api/ethServerAPI.cpp @@ -5,13 +5,13 @@ #include "ethServerAPI.h" -static ethServerPort *apiPort; +static std::unique_ptr apiPort; void initApiServer(int port) { // Start API server on port 4403 if (!apiPort) { - apiPort = new ethServerPort(port); + apiPort = std::make_unique(port); LOG_INFO("API server listening on TCP port %d", port); apiPort->init(); } @@ -21,8 +21,7 @@ void deInitApiServer() { if (apiPort) { LOG_INFO("Deinit API server"); - delete apiPort; - apiPort = nullptr; + apiPort.reset(); } } diff --git a/src/mesh/eth/ethApiServer.cpp b/src/mesh/eth/ethApiServer.cpp index c7f1df610..c27d97fe3 100644 --- a/src/mesh/eth/ethApiServer.cpp +++ b/src/mesh/eth/ethApiServer.cpp @@ -6,6 +6,7 @@ #include "ethApiHandlers.h" #include "ethApiServer.h" #include +#include #ifdef USE_ARDUINO_ETHERNET #include @@ -20,7 +21,7 @@ static constexpr int32_t ACTIVE_INTERVAL_MS = 20; static constexpr int32_t MEDIUM_INTERVAL_MS = 100; static constexpr int32_t IDLE_INTERVAL_MS = 500; -static EthernetServer *apiServer = nullptr; +static std::unique_ptr apiServer; // Adapter that exposes an EthernetClient through the transport-agnostic // IStreamReadWrite interface so the handlers in ethApiHandlers.cpp can drive @@ -86,7 +87,7 @@ void initEthApiServer() // Bind the listener (idempotent - deInitEthApiServer() drops apiServer on a // W5500 reset, and this rebinds it on the restart path). if (!apiServer) { - apiServer = new EthernetServer(ETH_API_PORT); + apiServer = std::make_unique(ETH_API_PORT); apiServer->begin(); LOG_INFO("ETH API: server listening on TCP port %d (phase 2.0, OSThread @ 20ms)", ETH_API_PORT); } @@ -103,10 +104,7 @@ void deInitEthApiServer() // A W5500 chip reset wipes the hardware socket table, so the listener is now // bound to a dead socket. Drop it (the worker stays alive and idles) so the // next initEthApiServer() from reconnectETH's restart path rebinds TCP/80. - if (apiServer) { - delete apiServer; - apiServer = nullptr; - } + apiServer.reset(); } #endif // HAS_ETHERNET && HAS_ETHERNET_API diff --git a/src/mesh/http/ContentHandler.cpp b/src/mesh/http/ContentHandler.cpp index d6c4904b7..ad0b9a84c 100644 --- a/src/mesh/http/ContentHandler.cpp +++ b/src/mesh/http/ContentHandler.cpp @@ -6,6 +6,7 @@ #include "main.h" #include "mesh/http/ContentHelper.h" #include "mesh/http/WebServer.h" +#include #if HAS_WIFI #include "mesh/wifi/WiFiAPClient.h" #endif @@ -484,7 +485,7 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) // Actually we do this only for documentary purposes, we know the form is going // to be multipart/form-data. LOG_DEBUG("Form Upload - Creating body parser reference"); - HTTPBodyParser *parser; + std::unique_ptr parser; std::string contentType = req->getHeader("Content-Type"); // The content type may have additional properties after a semicolon, for example: @@ -500,7 +501,7 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) // Now, we can decide based on the content type: if (contentType == "multipart/form-data") { LOG_DEBUG("Form Upload - multipart/form-data"); - parser = new HTTPMultipartBodyParser(req); + parser.reset(new HTTPMultipartBodyParser(req)); } else { LOG_DEBUG("Unknown POST Content-Type: %s", contentType.c_str()); return; @@ -536,7 +537,6 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) if (name != "file") { LOG_DEBUG("Skip unexpected field"); res->println("

No file found.

"); - delete parser; return; } @@ -544,7 +544,6 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) if (filename == "") { LOG_DEBUG("Skip unexpected field"); res->println("

No file found.

"); - delete parser; return; } @@ -575,7 +574,6 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) // enableLoopWDT(); - delete parser; return; } @@ -596,7 +594,6 @@ void handleFormUpload(HTTPRequest *req, HTTPResponse *res) res->println("

Did not write any file

"); } res->println(""); - delete parser; } void handleReport(HTTPRequest *req, HTTPResponse *res) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index df977a028..7ac778e3c 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -62,7 +62,7 @@ portduino_config_struct portduino_config; portduino_status_struct portduino_status; std::ofstream traceFile; std::ofstream JSONFile; -Ch341Hal *ch341Hal = nullptr; +std::unique_ptr ch341Hal; char *configPath = nullptr; char *optionMac = nullptr; bool verboseEnabled = false; @@ -325,8 +325,8 @@ void portduinoSetup() { extern void wasm_config_apply(); wasm_config_apply(); - ch341Hal = - new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, portduino_config.lora_usb_pid); + ch341Hal = std::make_unique(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, + portduino_config.lora_usb_pid); } return; #endif @@ -650,8 +650,8 @@ void portduinoSetup() uint8_t dmac[6] = {0}; if (portduino_config.lora_spi_dev == "ch341") { try { - ch341Hal = new Ch341Hal(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, - portduino_config.lora_usb_pid); + ch341Hal = std::make_unique(0, portduino_config.lora_usb_serial_num, portduino_config.lora_usb_vid, + portduino_config.lora_usb_pid); } catch (std::exception &e) { std::cerr << e.what() << std::endl; std::cerr << "Could not initialize CH341 device!" << std::endl; diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index a6797c292..207245567 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include #include #include @@ -64,7 +65,7 @@ struct pinMapping { extern std::ofstream traceFile; extern std::ofstream JSONFile; -extern Ch341Hal *ch341Hal; +extern std::unique_ptr ch341Hal; int initGPIOPin(int pinNum, const std::string &gpioChipname, int line); bool loadConfig(const char *configPath); static bool ends_with(std::string_view str, std::string_view suffix); From fb6a212b449e41166e504befe1deda9bea27de60 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 14 Aug 2026 10:05:16 +0000 Subject: [PATCH 069/109] fix(graphics): make on-screen keyboard lifecycle safe and RAII-managed (#11460) - VirtualKeyboard::handleLongPress VK_ESC invoked the onTextEntered member std::function directly, but that callback path reaches OnScreenKeyboardModule::stop(), which destroys the keyboard - and with it the std::function whose invocation is still on the stack. handlePress and submitText already deliberately copy-and-clear before invoking for exactly this reason (CannedMessageModule documents the same hazard); do the same here. - OnScreenKeyboardModule's keyboard becomes unique_ptr, replacing the delete-in-destructor / delete-then-new-in-start / delete-in-stop bookkeeping that runs on every keyboard open/close. The NotificationRenderer legacy hook receives a non-owning raw pointer, as before. --- src/graphics/VirtualKeyboard.cpp | 8 +++++++- src/modules/OnScreenKeyboardModule.cpp | 23 +++++------------------ src/modules/OnScreenKeyboardModule.h | 3 ++- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp index fd06e0def..bdc827a0a 100644 --- a/src/graphics/VirtualKeyboard.cpp +++ b/src/graphics/VirtualKeyboard.cpp @@ -666,7 +666,13 @@ void VirtualKeyboard::handleLongPress() break; case VK_ESC: if (onTextEntered) { - onTextEntered(""); + // Copy-and-clear before invoking, like handlePress/submitText: the callback can + // destroy this keyboard (OnScreenKeyboardModule::stop), so the member must not be + // the std::function still executing on the stack. + std::function callback = onTextEntered; + onTextEntered = nullptr; + inputText = ""; + callback(""); } break; default: diff --git a/src/modules/OnScreenKeyboardModule.cpp b/src/modules/OnScreenKeyboardModule.cpp index ae2707cfe..3a9d498ed 100644 --- a/src/modules/OnScreenKeyboardModule.cpp +++ b/src/modules/OnScreenKeyboardModule.cpp @@ -18,22 +18,12 @@ OnScreenKeyboardModule &OnScreenKeyboardModule::instance() return inst; } -OnScreenKeyboardModule::~OnScreenKeyboardModule() -{ - if (keyboard) { - delete keyboard; - keyboard = nullptr; - } -} +OnScreenKeyboardModule::~OnScreenKeyboardModule() = default; void OnScreenKeyboardModule::start(const char *header, const char *initialText, uint32_t durationMs, std::function cb) { - if (keyboard) { - delete keyboard; - keyboard = nullptr; - } - keyboard = new VirtualKeyboard(); + keyboard = std::make_unique(); callback = cb; if (header) keyboard->setHeader(header); @@ -50,7 +40,7 @@ void OnScreenKeyboardModule::start(const char *header, const char *initialText, }); // Maintain legacy compatibility hooks - NotificationRenderer::virtualKeyboard = keyboard; + NotificationRenderer::virtualKeyboard = keyboard.get(); NotificationRenderer::textInputCallback = callback; } @@ -58,10 +48,7 @@ void OnScreenKeyboardModule::stop(bool callEmptyCallback) { auto cb = callback; callback = nullptr; - if (keyboard) { - delete keyboard; - keyboard = nullptr; - } + keyboard.reset(); // Keep NotificationRenderer legacy pointers in sync NotificationRenderer::virtualKeyboard = nullptr; NotificationRenderer::textInputCallback = nullptr; @@ -74,7 +61,7 @@ void OnScreenKeyboardModule::handleInput(const InputEvent &event) if (!keyboard) return; - if (processVirtualKeyboardInput(event, keyboard)) + if (processVirtualKeyboardInput(event, keyboard.get())) return; if (event.inputEvent == INPUT_BROKER_CANCEL) diff --git a/src/modules/OnScreenKeyboardModule.h b/src/modules/OnScreenKeyboardModule.h index 40dc23fae..555da432f 100644 --- a/src/modules/OnScreenKeyboardModule.h +++ b/src/modules/OnScreenKeyboardModule.h @@ -7,6 +7,7 @@ #include "graphics/VirtualKeyboard.h" #include #include +#include #include namespace graphics @@ -34,7 +35,7 @@ class OnScreenKeyboardModule void onSubmit(const std::string &text); void onCancel(); - VirtualKeyboard *keyboard = nullptr; + std::unique_ptr keyboard; std::function callback; }; From 905482ccceba58af9c8fdb3b67008a756ecff779 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 14 Aug 2026 10:41:44 -0500 Subject: [PATCH 070/109] fix(serial): don't sleep forever with pending PhoneAPI output on UART consoles (#11500) * fix(serial): don't sleep forever with pending PhoneAPI output on UART consoles Since #11164 bounded the stream drain, a config dump can end a dispatch with output still queued. On UART-console ESP32 boards runOnce() then returns INT32_MAX with no RX pending, and neither rxInt() nor onNowHasData() fires for the remaining output, so the download wedges mid nodeinfo stream until the client happens to send a byte. Add StreamAPI::hasPendingOutput() (transport-retained frame or queued PhoneAPI data) and have SerialConsole::runOnce() short-poll (<=25ms) while it holds instead of sleeping INT32_MAX. The #11164 write budget is unchanged; idle sleep behavior with a drained queue is unchanged. The retained-frame probe also covers the ESP32-S2 USB-CDC branch, which takes the same INT32_MAX path. * test(serial): restore scratch NodeDB via tearDown, trim comments to house style A failed TEST_ASSERT longjmps out of a Unity test without running destructors, so RAII cannot restore the swapped nodeDB pointer; install the scratch NodeDB explicitly and restore/delete it in tearDown(), which runs after every test outcome. Also shorten the new comments to the two-line house limit. --- src/SerialConsole.cpp | 15 ++++++ src/SerialConsole.h | 2 + src/mesh/StreamAPI.cpp | 6 +++ src/mesh/StreamAPI.h | 6 +++ test/test_stream_api/test_main.cpp | 86 ++++++++++++++++++++++++++++-- 5 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index a406fcd0d..24141be28 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -125,6 +125,10 @@ int32_t SerialConsole::runOnce() int32_t delay = runOncePart(); #if defined(SERIAL_HAS_ON_RECEIVE) || defined(CONFIG_IDF_TARGET_ESP32S2) + // Nothing wakes the idle sleep for "TX space freed" or a bounded-drain remainder + // (#11164), so keep polling while the API holds undelivered output. + if (hasPendingOutput()) + return delay < 25 ? delay : 25; // 0 continues a budget slice; else short-poll TX drain return Port.available() ? delay : INT32_MAX; #elif defined(IS_USB_SERIAL) return HWCDC::isPlugged() ? delay : (1000 * 20); @@ -212,6 +216,17 @@ bool SerialConsole::finishPendingFrame() #endif } +/// Report a retained USB CDC frame awaiting TX space. +bool SerialConsole::hasRetainedFrame() +{ +#ifdef IS_USB_SERIAL + concurrency::LockGuard guard(&streamLock); + return !frameWriter.isIdle(); +#else + return false; +#endif +} + /// Protect the retained log buffer from being overwritten. bool SerialConsole::canEncodeLogRecord() { diff --git a/src/SerialConsole.h b/src/SerialConsole.h index eeed25644..466d6afa9 100644 --- a/src/SerialConsole.h +++ b/src/SerialConsole.h @@ -51,6 +51,8 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur /// Continue retained USB CDC output before PhoneAPI advances. virtual bool finishPendingFrame() override; + /// Report a retained USB CDC frame awaiting TX space. + virtual bool hasRetainedFrame() override; /// Return whether the dedicated log buffer can be safely overwritten. virtual bool canEncodeLogRecord() override; /// Write or retain one framed USB CDC message. diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index e20434042..412a9786a 100644 --- a/src/mesh/StreamAPI.cpp +++ b/src/mesh/StreamAPI.cpp @@ -33,6 +33,12 @@ int32_t StreamAPI::runOncePart(char *buf, uint16_t bufLen) return result; } +/// Report undelivered output so idle-sleep decisions keep the drain alive. +bool StreamAPI::hasPendingOutput() +{ + return canWrite && (hasRetainedFrame() || available()); +} + /** * Read any rx chars from the link and call handleRecStream */ diff --git a/src/mesh/StreamAPI.h b/src/mesh/StreamAPI.h index c91da4d02..7968972e1 100644 --- a/src/mesh/StreamAPI.h +++ b/src/mesh/StreamAPI.h @@ -57,6 +57,10 @@ class StreamAPI : public PhoneAPI virtual int32_t runOncePart(); virtual int32_t runOncePart(char *buf, uint16_t bufLen); + /// True while undelivered output remains (retained frame or queued PhoneAPI data); callers + /// woken only by RX activity must keep polling while set, as drains stop mid-dump (#11164). + bool hasPendingOutput(); + /// Check the current underlying physical link to see if the client is currently connected virtual bool checkIsConnected() override = 0; @@ -104,6 +108,8 @@ class StreamAPI : public PhoneAPI /// Complete retained transport output before dequeuing another PhoneAPI packet. virtual bool finishPendingFrame() { return true; } + /// Return whether the transport retains an incomplete frame awaiting TX space. + virtual bool hasRetainedFrame() { return false; } /// Return whether the dedicated log buffer is available for encoding. virtual bool canEncodeLogRecord() { return true; } /// Frame and write a payload, optionally using best-effort admission. diff --git a/test/test_stream_api/test_main.cpp b/test/test_stream_api/test_main.cpp index 994e82c3d..fdc87ab8e 100644 --- a/test/test_stream_api/test_main.cpp +++ b/test/test_stream_api/test_main.cpp @@ -147,6 +147,26 @@ class PhoneAPITestShim : public PhoneAPI bool checkIsConnected() override { return true; } }; +/// Exposes the hasPendingOutput() inputs used by idle-sleep gating. +class PendingOutputStreamAPI : public StreamAPI +{ + public: + /// Construct the shim over a scripted stream. + explicit PendingOutputStreamAPI(Stream *stream) : StreamAPI(stream) {} + + /// Keep connection-timeout handling inactive during tests. + bool checkIsConnected() override { return true; } + + /// Set the transport-writability gate normally controlled by first client contact. + void setCanWrite(bool value) { canWrite = value; } + + bool retainedFrame = false; + + protected: + /// Report the scripted retained-frame state. + bool hasRetainedFrame() override { return retainedFrame; } +}; + /// Exposes framed-log hooks and records best-effort writes. class LogHookStreamAPI : public StreamAPI { @@ -538,7 +558,7 @@ static void queuePendingTimePlaceholderPacket(NodeNum from, uint32_t placeholder service->sendToPhone(packetPool.allocCopy(pending)); } -static void startHandshake(PhoneAPITestShim &api) +static void startHandshake(PhoneAPI &api) { meshtastic_ToRadio request = meshtastic_ToRadio_init_zero; request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag; @@ -566,6 +586,58 @@ static bool drainHandshakeForPacketFrom(PhoneAPITestShim &api, NodeNum from, mes return false; } +// Scratch NodeDB for the config-dump stream; restored by tearDown() rather than RAII +// because a failed TEST_ASSERT longjmps out of the test without running destructors. +static NodeDB *scratchNodeDB = nullptr; +static NodeDB *savedNodeDB = nullptr; + +/// Install a scratch NodeDB; tearDown() restores the previous one after any test outcome. +static void installScratchNodeDB() +{ + savedNodeDB = nodeDB; + scratchNodeDB = new NodeDB(); + nodeDB = scratchNodeDB; +} + +// SerialConsole::runOnce gates its INT32_MAX idle sleep on hasPendingOutput(): pending while +// output is queued or retained (#11164 bounded drain), clear when drained or pre-contact. +static void test_stream_api_pending_output_tracks_queue_and_retained_frame(void) +{ + ScopedMeshService scopedService; + installScratchNodeDB(); + ScriptedStream stream; + PendingOutputStreamAPI api(&stream); + + // Nothing queued and no client yet: an idle console must be allowed to sleep. + TEST_ASSERT_FALSE(api.hasPendingOutput()); + + // A client that has not yet spoken (canWrite false) must not force polling, + // even with a full config dump queued behind the gate. + startHandshake(api); + api.setCanWrite(false); + TEST_ASSERT_FALSE(api.hasPendingOutput()); + + // Once writable, the queued dump is pending output until fully drained. + api.setCanWrite(true); + TEST_ASSERT_TRUE(api.hasPendingOutput()); + unsigned drained = 0; + for (unsigned i = 0; i < 512 && api.hasPendingOutput(); ++i) { + uint8_t responseBytes[meshtastic_FromRadio_size]; + if (api.getFromRadio(responseBytes) != 0) + drained++; + } + TEST_ASSERT_GREATER_THAN_UINT(0, drained); + TEST_ASSERT_FALSE_MESSAGE(api.hasPendingOutput(), "pending output must clear once the dump is drained"); + + // A transport-retained partial frame alone keeps the drain alive. + api.retainedFrame = true; + TEST_ASSERT_TRUE(api.hasPendingOutput()); + api.retainedFrame = false; + TEST_ASSERT_FALSE(api.hasPendingOutput()); + + api.close(); +} + /// 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 @@ -715,8 +787,15 @@ static void test_node_heard_during_first_uptime_second_gets_last_heard_backfille /// Unity per-test setup; fixtures are local to each test. void setUp(void) {} -/// Unity per-test teardown; fixtures clean themselves up. -void tearDown(void) {} +/// Unity per-test teardown; restores state that a failed assert's longjmp would leak. +void tearDown(void) +{ + if (scratchNodeDB) { + nodeDB = savedNodeDB; + delete scratchNodeDB; + scratchNodeDB = nullptr; + } +} /// Initialize the native environment and run the stream regression suite. void setup() @@ -735,6 +814,7 @@ 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_stream_api_pending_output_tracks_queue_and_retained_frame); 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); RUN_TEST(test_node_heard_before_time_gets_last_heard_backfilled); From f57ee0bd71c132b836abc7c9f7575fff74ed06f6 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 14 Aug 2026 12:15:31 -0500 Subject: [PATCH 071/109] fix(mesh): restore the implicit ACK for our own overheard PKI DMs (#11502) * fix(mesh): restore the implicit ACK for our own overheard PKI DMs A DM we originate is PKI-encrypted to the recipient, so when we overhear it being rebroadcast we cannot decrypt it. perhapsHandleReceived() classifies it DECODE_OPAQUE and returns before shouldFilterReceived() runs, which is where the implicit ACK for our own transmission is generated. The client therefore never receives the ROUTING_APP ack it renders as "Delivered to mesh" for a DM, and the message sits in "sending" until it either succeeds outright or times out as max retransmissions. The ACK only needs the packet header (from/id), not the decoded payload, so split it out of shouldFilterReceived() into perhapsGenerateImplicitAckForOwnOverheard() and also call it from the opaque short-circuit for packets that are from us. Behavior on the decodable path is unchanged. Broadcasts on a PSK channel decode normally and always reached the generator, which is why channel messages were unaffected and only DMs showed the symptom. * test: rename implicit-ack tests to avoid a trufflehog false positive The camelCase identifiers tripped trunk's trufflehog/Lob secret detector. * test: shorten one test name past trunk's Lob secret-detector pattern trufflehog's Lob rule matches test_ followed by exactly 35 word characters, which both new test names happened to hit. Unrelated to the fix. --- src/mesh/ReliableRouter.cpp | 49 ++++++++++++---------- src/mesh/ReliableRouter.h | 5 +++ src/mesh/Router.cpp | 6 +++ src/mesh/Router.h | 8 ++++ test/test_nexthop_routing/test_main.cpp | 55 +++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 21 deletions(-) diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index f823232c2..4e8d2c2e9 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -53,34 +53,41 @@ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p) return result; } -bool ReliableRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) +void ReliableRouter::perhapsGenerateImplicitAckForOwnOverheard(const meshtastic_MeshPacket *p) { // Note: do not use getFrom() here, because we want to ignore messages sent from phone - if (p->from == getNodeNum()) { - printPacket("Rx someone rebroadcasting for us", p); + if (p->from != getNodeNum()) + return; - // We are seeing someone rebroadcast one of our broadcast attempts. - // If this is the first time we saw this, cancel any retransmissions we have queued up and generate an internal ack for - // the original sending process. + printPacket("Rx someone rebroadcasting for us", p); - // This "optimization", does save lots of airtime. For DMs, you also get a real ACK back - // from the intended recipient. - auto key = GlobalPacketId(getFrom(p), p->id); - auto old = findPendingPacket(key); - if (old) { - LOG_DEBUG("Generate implicit ack"); - // NOTE: we do NOT check p->wantAck here because p is the INCOMING rebroadcast and that packet is not expected to be - // marked as wantAck - sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel); + // We are seeing someone rebroadcast one of our transmissions. If this is the first time we saw + // this, cancel any retransmissions we have queued up and generate an internal ack for the + // original sending process. Header-only (from/id), so it works even for a packet we cannot + // decrypt - notably a PKI DM we originated, which is opaque to us when overheard. - // Only stop retransmissions if the rebroadcast came via LoRa - if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) { - stopRetransmission(key); - } - } else { - LOG_DEBUG("Didn't find pending packet"); + // This "optimization", does save lots of airtime. For DMs, you also get a real ACK back + // from the intended recipient. + auto key = GlobalPacketId(getFrom(p), p->id); + auto old = findPendingPacket(key); + if (old) { + LOG_DEBUG("Generate implicit ack"); + // NOTE: we do NOT check p->wantAck here because p is the INCOMING rebroadcast and that packet is not expected to be + // marked as wantAck + sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, old->packet->channel); + + // Only stop retransmissions if the rebroadcast came via LoRa + if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) { + stopRetransmission(key); } + } else { + LOG_DEBUG("Didn't find pending packet"); } +} + +bool ReliableRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) +{ + perhapsGenerateImplicitAckForOwnOverheard(p); /* At this point we have already deleted the pending retransmission if this packet was an (implicit) ACK to it. Now for all other pending retransmissions, we have to add the airtime of this received packet to the retransmission timer, diff --git a/src/mesh/ReliableRouter.h b/src/mesh/ReliableRouter.h index 33121de6b..1dafaca80 100644 --- a/src/mesh/ReliableRouter.h +++ b/src/mesh/ReliableRouter.h @@ -32,6 +32,11 @@ class ReliableRouter : public NextHopRouter */ virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override; + /** + * Header-only implicit ACK for our own overheard rebroadcast (also usable before decode). + */ + virtual void perhapsGenerateImplicitAckForOwnOverheard(const meshtastic_MeshPacket *p) override; + private: /** * Should this packet be ACKed with a want_ack for reliable delivery? diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 2aa6a6c63..e4f52c779 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -1611,6 +1611,12 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) return; } if (authVerdict == RoutingAuthVerdict::OPAQUE_RELAY_ONLY) { + // A packet we originated but cannot decrypt (a PKI DM we sent, overheard being rebroadcast) + // is opaque to us and would otherwise skip shouldFilterReceived entirely, so the implicit + // ACK that marks a DM "Delivered to mesh" never fires. The ACK is header-only (from/id), so + // generate it here from the still-encrypted packet before opaque relay. + if (isFromUs(p)) + perhapsGenerateImplicitAckForOwnOverheard(p); relayOpaquePacket(p); packetPool.release(p); return; diff --git a/src/mesh/Router.h b/src/mesh/Router.h index d5ea73cfe..eb1213de3 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -137,6 +137,14 @@ class Router : protected concurrency::OSThread, protected PacketHistory /** Relay an opaque packet without admitting it to local routing/history state. */ virtual bool relayOpaquePacket(const meshtastic_MeshPacket *) { return false; } + /** + * Generate the implicit ACK for our own transmission overheard being rebroadcast, using header + * fields only (from/id). Split out of shouldFilterReceived() so it can also run when the auth + * gate short-circuits a packet we cannot decrypt (a PKI DM we originated is opaque to us, so + * without this the client never sees "Delivered to mesh" for DMs). + */ + virtual void perhapsGenerateImplicitAckForOwnOverheard(const meshtastic_MeshPacket *) {} + /** * Determine if hop_limit should be decremented for a relay operation. * Returns false (preserve hop_limit) only if all conditions are met: diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index 4dca5b5e4..c4891056c 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -268,6 +268,8 @@ class ReliableRouterTestShim : public ReliableRouter ReliableRouter::sniffReceived(p, routing); } + void implicitAckForTest(const meshtastic_MeshPacket *p) { perhapsGenerateImplicitAckForOwnOverheard(p); } + void clearPendingForTest() { while (!pending.empty()) @@ -820,6 +822,57 @@ void test_reliableAckStopsNormalPendingTransmission(void) TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); } +// A PKI DM we originated is encrypted to the recipient, so when we overhear it being rebroadcast we +// cannot decode it. The routing auth gate classifies it opaque and returns before +// shouldFilterReceived() runs, so the implicit ACK has to be reachable from the header alone - +// otherwise the client never sees "Delivered to mesh" for a DM. +void test_implicit_ack_for_opaque_own_packet(void) +{ + auto original = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 0, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + mockRoutingModule->ackNaks.clear(); + + // The overheard copy as it actually arrives: still encrypted, nothing decoded. + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = kLocalNode; + overheard.to = kRemoteNode; + overheard.id = original.id; + overheard.channel = 0; + overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + overheard.encrypted.size = 32; + + reliableShim->implicitAckForTest(&overheard); + + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + const auto &ack = mockRoutingModule->ackNaks.front(); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, std::get<0>(ack)); + TEST_ASSERT_EQUAL_UINT32(kLocalNode, std::get<1>(ack)); // addressed to us -> reaches the phone + TEST_ASSERT_EQUAL_UINT32(original.id, std::get<2>(ack)); + + reliableShim->clearPendingForTest(); +} + +// Someone else's traffic must never mint an ACK, even with a colliding id. +void test_implicit_ack_ignores_foreign_pkt(void) +{ + auto original = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 0, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + mockRoutingModule->ackNaks.clear(); + + meshtastic_MeshPacket foreign = meshtastic_MeshPacket_init_zero; + foreign.from = kRemoteNode; + foreign.to = kLocalNode; + foreign.id = original.id; + foreign.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + foreign.encrypted.size = 32; + + reliableShim->implicitAckForTest(&foreign); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + reliableShim->clearPendingForTest(); +} + void test_pending_does_not_cancel_radio_queue_before_first_retry(void) { MockRadioInterface *mockIface = installMockIface(); @@ -1049,6 +1102,8 @@ void setup() RUN_TEST(test_reliableAckStopsNormalPendingTransmission); printf("\n=== pending retransmission bookkeeping ===\n"); + RUN_TEST(test_implicit_ack_for_opaque_own_packet); + RUN_TEST(test_implicit_ack_ignores_foreign_pkt); RUN_TEST(test_pending_does_not_cancel_radio_queue_before_first_retry); RUN_TEST(test_pending_cancels_radio_queue_after_first_retry_for_any_budget); RUN_TEST(test_directed_hop_tracks_three_total_attempts); From 51eadb77d4c0daeb84aecc61f82ac4dd1450067e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:37:01 +0000 Subject: [PATCH 072/109] fix(NodeDB): reset a persisted event firmware_edition on vanilla builds (#11504) * fix(NodeDB): reset a persisted event firmware_edition on vanilla builds myNodeInfo lives in devicestate, which survives a firmware reinstall, and the boot-time edition stamp was compiled out entirely on builds without USERPREFS_FIRMWARE_EDITION. A device flashed from an event build back to vanilla therefore kept reporting the event edition forever, and clients kept its branding until a factory reset. Stamp VANILLA in the else branch so the running build is always the source of truth. * Stamp the edition before the boot save decision, and assert the on-disk value Review follow-up: the stamp sat after the devicestate CRC compare, so an edition-only change stayed RAM-only and the persisted event edition survived on disk. Move it next to the other running-build-wins fixups (device_id, min_app_version), which run inside the CRC window, and extend the test to read device.proto back so the persisted value is asserted too. --- src/mesh/NodeDB.cpp | 11 ++++-- test/state-manifest.tsv | 1 + test/test_firmware_edition/test_main.cpp | 49 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 test/test_firmware_edition/test_main.cpp diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 6f87422f2..715daff1b 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -430,6 +430,14 @@ NodeDB::NodeDB() // likewise - we always want the app requirements to come from the running appload myNodeInfo.min_app_version = 30200; // format is Mmmss (where M is 1+the numeric major number. i.e. 30200 means 2.2.00 + + // likewise the edition: it lives in persisted devicestate, so a vanilla install must + // overwrite the previous event build's value. Before the CRC compare, so the change persists. +#ifdef USERPREFS_FIRMWARE_EDITION + myNodeInfo.firmware_edition = USERPREFS_FIRMWARE_EDITION; +#else + myNodeInfo.firmware_edition = meshtastic_FirmwareEdition_VANILLA; +#endif pickNewNodeNum(); // Set our board type so we can share it with others @@ -615,9 +623,6 @@ NodeDB::NodeDB() config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED; config.position.gps_enabled = 0; } -#ifdef USERPREFS_FIRMWARE_EDITION - myNodeInfo.firmware_edition = USERPREFS_FIRMWARE_EDITION; -#endif #ifdef USERPREFS_FIXED_GPS if (myNodeInfo.reboot_count == 1) { // Check if First boot ever or after Factory Reset. meshtastic_Position fixedGPS = meshtastic_Position_init_default; diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index 45a8fa962..7420504e8 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -42,6 +42,7 @@ # suite flags reason test_admin_radio writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs per-test NodeDB fixture, and the admin handlers under test persist config, channels and node metadata test_admin_session_repro writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty +test_firmware_edition writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto persists an event firmware_edition in devicestate, then reboots a NodeDB to prove a vanilla build resets it test_fuzz_packets writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs drives decode of fuzzed packets through the real NodeDB and message store test_hop_scaling writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB to hold the hop-distance fixtures test_mesh_beacon writes=module.proto exercises the beacon's module-config save path diff --git a/test/test_firmware_edition/test_main.cpp b/test/test_firmware_edition/test_main.cpp new file mode 100644 index 000000000..949dd0335 --- /dev/null +++ b/test/test_firmware_edition/test_main.cpp @@ -0,0 +1,49 @@ +// devicestate.my_node survives a firmware reinstall, so a vanilla build (no +// USERPREFS_FIRMWARE_EDITION) must reset a persisted event edition at boot. +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include "mesh/NodeDB.h" +#include + +#if defined(ARCH_PORTDUINO) +#define FE_TEST_ENTRY extern "C" +#else +#define FE_TEST_ENTRY +#endif + +void setUp(void) {} +void tearDown(void) {} + +static meshtastic_FirmwareEdition persistedEdition() +{ + meshtastic_DeviceState saved = meshtastic_DeviceState_init_zero; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(deviceStateFileName, meshtastic_DeviceState_size, + sizeof(saved), &meshtastic_DeviceState_msg, &saved)); + return saved.my_node.firmware_edition; +} + +static void test_vanillaBoot_resetsPersistedEventEdition(void) +{ + devicestate.my_node.firmware_edition = meshtastic_FirmwareEdition_DEFCON; + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_DEVICESTATE)); + TEST_ASSERT_EQUAL(meshtastic_FirmwareEdition_DEFCON, persistedEdition()); + + NodeDB *rebooted = new NodeDB(); + delete nodeDB; + nodeDB = rebooted; + + TEST_ASSERT_EQUAL(meshtastic_FirmwareEdition_VANILLA, devicestate.my_node.firmware_edition); + // On disk too, not just in RAM: the stamp must land before the boot save decision. + TEST_ASSERT_EQUAL(meshtastic_FirmwareEdition_VANILLA, persistedEdition()); +} + +FE_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + nodeDB = new NodeDB(); + + UNITY_BEGIN(); + RUN_TEST(test_vanillaBoot_resetsPersistedEventEdition); + exit(UNITY_END()); +} +FE_TEST_ENTRY void loop() {} From bca7c0b480d77cb4208d93540a5bf7cbc6823027 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:34:02 +0000 Subject: [PATCH 073/109] Tom fiddles with the test suite - again (#11517) * test: make every suite run its own binary, and fail the run when it does not PlatformIO links every native test program to the one $BUILD_DIR/$PROGNAME path and attributes Unity output by text alone, never checking that the source file a case came from belongs to the suite it thinks it ran. Both harnesses had been split into a build pass (--without-testing) and a run pass (--without-building), and for a non-embedded platform the run pass never relinks - so all 57 suites executed whichever suite was linked last, each reporting PASSED under its own name. Introduced for CI in 4906f8a6 and for bin/run-tests.sh in de6b2319; both ran fused, and correctly, before that. Drop --without-building from both run passes. The --without-testing pass stays as a warm-up so no single suite absorbs the whole src compile in its reported duration; with the objects already cached the per-suite step is one test_main.cpp plus a link. Add bin/check-test-attribution.py, which grades the JUnit reports both harnesses already produce. It fails on a test case whose source file lies outside the suite that reported it, and on a suite that was asked to run and produced no cases at all. Wired in three places: bin/run-tests.sh as a RED verdict ahead of the softer ones, per area in CI so a mismatch names its area, and once over the merged report so an area that never executed cannot hide. Suite ownership is matched on whole path segments, so test_mesh does not claim test_mesh_module, and the -f pattern is resolved against the canonical set rather than taken as a literal suite name. * fix(test): pin simradio off for the packet-signing PKI cases [env:coverage] passes -s to the test binary (74e6723ad, #8251), which sets portduino_config.force_simradio. wouldEncryptWithPKC() lists !force_simradio among its preconditions, so perhapsEncode() takes the channel-crypto branch, returns NONE and leaves pki_encrypted false - failing test_B11_normal_unicast_still_uses_pki and test_B12_licensed_receiver_does_not_decrypt_pki, both of which assert the production PKI path. [env:native] passes no such flag, which is the whole of the long-standing "passes under native, fails under coverage" split; it was never gcov, ASan or a host. Save and clear the flag in setUp, restore it in tearDown, so the suite asserts the encode path it is named for under either env's invocation. Same binary, pristine $HOME: 77 tests 0 failures with -s and without, where before -s gave 2 failures. Whether the unit-test binary should run with -s at all is a separate question - it means CI exercises the simradio configuration for every suite - and is left alone here. * fix(router): drive the admin-key fallback budget from the injectable clock The budget is 8 tokens refilling one per 250ms of wall clock, and test_admin_key_fallback_is_rate_limited drains it with eight PKI decodes before asserting the ninth is refused. That gives the drain loop 31ms per iteration, each of which generates a keypair and does three X25519 operations under gcov and ASan. This box runs them in ~4ms; a GitHub runner takes ~38ms, so a token refills mid-drain and the packet the test expects to be blocked decodes. Measured from both runs' own log timestamps, 9.5x apart. Read the bucket through Time::getMillis() instead of millis(), and have the test set and advance the virtual clock rather than sleeping. The subtraction was already wrap-correct, so the deadline guard is unaffected. Restores the clock in tearDown so the rest of the suite is untouched, and drops ~3s of real sleeping from the run. * test: declare the event-channel suites' shared state Both construct a NodeDB, whose constructor persists a default set into an empty prefs directory, so each writes the five prefs protos. Neither was declared, because until suites started running their own binaries nothing had ever observed them writing anything. * test: add a repeat runner for order-independent flakes A single green run says nothing about a real-time race or a slow-host margin: the rate-limit budget above passes here with 7x headroom and still fails on a CI runner. Run one suite N times against a fresh scratch $HOME each time, optionally against CPU contention, and print a flake rate. Failing runs keep their log and their sandbox; passing runs leave nothing. Simradio is taken from the env's own test_testing_command, so a stress run reproduces the real invocation rather than inventing a third one. * fix(test): keep a native test run off the host's radio bin/pio-test-isolate.sh sandboxes $HOME, but portduinoSetup() looks for config in ./config.yaml and /etc/meshtasticd/config.yaml - the second absolute, so no $HOME sandbox can hide it. On a machine running meshtasticd that config selects the real LoRa module and the run continues into GPIO and SPI setup, so ./bin/run-tests.sh -e native would drive the developer's own radio without saying so. -e native is also the faster of the two, and the one reached for when iterating. [env:coverage] already passes -s, which short-circuits ahead of the config search and returns before hardware init. Pass it for [env:native] too. That closes the hazard and, incidentally, makes the two envs invoke the binary identically - they did not, which is the whole of the long-standing "green locally, red in CI" split. * test: run every suite with PKC on, and assert it stays that way force_simradio does two unrelated jobs. It keeps portduinoSetup() off the host's hardware, which every test run wants, and it makes wouldEncryptWithPKC() return false, which no test run wants: the encode path under test then falls back to channel crypto and any case asserting PKI fails, or worse, passes while asserting the wrong thing. Three suites had each worked this out separately and cleared the flag themselves - test_admin_session_repro's comment describes the mechanism exactly. Clear it once in initializeTestEnvironment() instead. By then portduinoSetup() has already skipped the config search and chosen the simulated radio, and it never reconsults the flag, so clearing it cannot bring hardware back; the only remaining readers are the PKC gate and an exit_simulator intercept no test can reach. The per-suite copy added to test_packet_signing for B11/B12 goes away with it. Two asserts, because both invariants were true only by inspection: - No listening sockets. main.cpp's setup()/loop() are compiled out under PIO_UNIT_TESTING, so the phone API, MQTT and the web server never start - but nothing checked. A suite that pulled in a service binding a port would open one on the developer's machine for the length of the run. - force_simradio still clear, before every test rather than once per suite, since a case that restores a struct it snapshotted earlier puts it back and silently disables PKC for everything after it. Named per test, so the report points at the case after the culprit. Both exit rather than TEST_FAIL: they run outside a Unity test frame, and silently repairing either one would leave the suite that broke it passing. Verified by disabling the clear and watching the guard fire on the first case instead of reporting two quiet failures. * test: let the repeat runner vary suite order too Repeating one binary finds races and slow-host margins; it cannot find state that leaks from one suite into the next, because only one suite runs. --shuffle drives run-tests.sh --seed with a fresh seed each iteration and reports which seeds went red, so the shuffle already in the harness yields a flake rate rather than a single sample. Seeds are printed and replayable. * fix(test): baseline the environment from whichever runs first Clearing force_simradio in initializeTestEnvironment() missed the suites that never call it. test_atak is one, and it also pulls in TestUtil.h, so it got the per-test assert without ever getting the baseline and aborted on its first case - caught by CI, which is what the assert is for. test_geocoord_distance, test_meshpacket_serializer and test_utf8 skip the init too, but include no TestUtil.h at all, so nothing reached them either way. Move the clear and the socket check into baselineEnvironment(), called from initializeTestEnvironment() or from the first RUN_TEST, whichever comes first. Suites that initialise are still asserted from their first case; the rest are baselined at case one and asserted from case two. Print the violation on stdout as well as stderr: bin/run-tests.sh filters the program's stderr, so locally the message vanished and the run reported "exit-time abort (likely sanitizer)" - the exit code read as a signal number again, with no sign of the real reason. * test: drop the per-suite simradio exceptions Three suites had each found that force_simradio disables PKC and cleared it themselves. initializeTestEnvironment() now clears it once for every suite, so all six sites are dead code - along with the PortduinoGlue.h include each pulled in for it. test_event_channel_router's is the one worth removing rather than leaving: it snapshotted the flag into SavedGlobals and restored it at teardown, which is exactly the shape the per-test assert exists to catch. Harmless while the snapshot reads false, and a silent PKC-off for every later case if that ever changed. The three suites pass unchanged: 54 cases, attribution clean. * test: tell a deliberate harness abort from a sanitizer fault A guard in TestUtil.cpp that aborts on purpose - a listening socket, or force_simradio put back - exits non-zero with no sanitizer report, so it fell through to the exit-time-abort heuristic and was announced as "RED exit-time abort (tests passed; likely sanitizer)". That is the same trap as the phantom SIGILL two checks above: a verdict line naming a cause it has not established, sending the reader after a memory bug that does not exist. It cost hours in the original investigation and it cost the first read of a test_atak failure today. Match the FATAL line the guards print on stdout for exactly this purpose, and report the reason they gave instead of guessing. * test: say why three suites omit TestUtil.h They are pure-function - no NodeDB, no router, no sockets, no PKC - so the harness-wide guards in TestUtil.h would assert conditions they cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup would pull in portduino globals they otherwise never touch. Suite-level state cleanliness still applies: bin/pio-test-isolate.sh fingerprints the sandbox from outside and wraps every suite regardless. Recorded at the top of each so the omission reads as a decision rather than an oversight - it looked like the latter when the socket and simradio asserts landed. * test(traffic): give every case a primary channel resetTrafficConfig() zeroed channelFile and left channels_count at 0, so the 66 cases that do not install a channel themselves ran against a device with none. Every router lookup then hit Channels::getByIndex()'s out-of-range branch and logged, which is 12106 of the suite's 20088 ERROR lines and tests nothing - a real device always has a primary channel, and no case here asserts channels-unset behaviour. Install the well-known primary the suite already builds for its precision cases. All 85 pass unchanged, and the suite's ERROR output drops to 7985, the remainder being decode failures from test_tm_fuzz_nodenum_blitz's malformed payloads. * test: budget each suite's LOG_ERROR output A suite can pass while emitting six figures of ERROR, which buries a real failure and trains everyone to skim. Count them per suite and grade the count as a second axis, alongside the CLEAN/DIRTY verdict already computed from the same captured log. Declared in the same manifest, as a RANGE rather than a ceiling, because for a fuzz suite the floor is the half that matters: test_fuzz_decode logging ~100k rejections is the suite working, and the same suite logging none means it stopped feeding malformed input while every case still passes. Bounds are wide on purpose - they catch a path that has stopped running, not a drift of a few hundred lines. Undeclared suites get 100, which 50 of 57 already meet. AMBER, not RED. Three log sites - mesh-pb-constants.cpp:28, Channels.cpp:356, MQTT.cpp:92 - account for nearly all the remaining volume, and landing this red before they are demoted would buy exemptions rather than fixes. * test: canary the attribution check, and run the state self-test in CI check-test-attribution.py guards against the false green, and nothing guarded the guard. A checker that has quietly stopped matching looks exactly like a codebase with no problem, which is how the original went unnoticed for three weeks of green runs. The canary reproduces the failure deliberately - two suites run with --without-building, so PlatformIO does not relink and both execute the same leftover binary - and requires the checker to catch it. It also fails if the reproduction stops reproducing: if PlatformIO ever relinks per suite under that flag, the reason both harnesses stopped passing it no longer holds, and the harness should be revisited rather than left on a stale assumption. bin/test-state-check.sh already existed with fixtures asserting CLEAN/CLEAN/DIRTY/MISSING and had never run in CI. Wire it in too - the shared-state checker had the same blind spot, and somebody had already written the test for it. * fix(ci): run the attribution canary where it cannot clobber the daemon The canary relinks $BUILD_DIR/$PROGNAME, and in simulator-tests that replaced the daemon binary with a test suite. The integration test then started it and waited for a listening socket, which a test binary never opens - by assertion, since initializeTestEnvironment() now fails a suite that holds one - so the step sat until its 20s timeout and the job exited 124. The canary itself had already passed. Move it to platformio-tests, where the binary is per-suite already and nothing downstream needs the daemon, and place it after the coverage capture so its extra runs stay out of the numbers. The shared-state self-test stays in simulator-tests; it touches no binary. Fitting failure mode for this branch: one shared program path, two consumers, and the second one silently getting the first one's build. * fix(ci): silence the XXE rule on the attribution checker semgrep blocks xml.etree.ElementTree.parse as XXE-prone. The input here is the JUnit report PlatformIO wrote moments earlier in the same run, and anything able to plant a hostile report is already executing its own code in that job, so parsing it defused changes nothing it could do. defusedxml is in the tree but only under bin/bump_metainfo with its own requirements, and pulling it onto this path would add an install step to every native test job for no reachable threat. Suppressed with a reason at the call site, the same shape as the subprocess-shell-true suppression in extra_scripts/nrf54l15_linker.py. * fix(test): address the review findings on the harness guards Two were real defects rather than style: - state_count_errors() returned "0\n0" for a log with no ERROR lines, because grep -c prints 0 and *then* exits 1, so the `|| printf 0` fallback appended a second one. The classifier threw a syntax error on it. Dormant only because every suite currently emits at least one ERROR line; the planned log-level demotions would have driven most suites to zero and tripped it everywhere, looking like the demotions broke the harness. - check-test-attribution.py returned OK for a report whose cases carry no `file` attribute. It cannot prove ownership in that state, so a changed JUnit format would have restored the exact false green it exists to catch. Now its own finding, listed and fatal. The rest: keep the sandbox when an error budget is breached, since that is the one outcome whose evidence was being deleted; reject a missing or non-numeric option value in stress-suite.sh instead of running an empty loop and reporting 0/0 as a pass; exit on INT/TERM rather than cleaning up and carrying on; drive repetitions through pio-test-isolate.sh so a stress run exercises the real invocation; require the canary to see MISATTRIBUTED rather than any non-zero exit, so an unreadable report cannot read as a caught mismatch; and check for listening sockets before every test, since a listener would be opened by the code under test. resetAdminKeyFallbackBudget() is a new PIO_UNIT_TESTING hook, shaped like the neighbouring resetRoutingAuthEvaluationCount(). The refill stamp is only meaningful against the clock that produced it, so a suite switching timebases leaves a stamp from the other one and the next unsigned subtraction reads as a near-infinite gap - silently refilling the bucket. Also move the semgrep marker onto its own line: buried mid-sentence in a comment it was ignored, and the XXE finding stayed blocking. --- .github/workflows/test_native.yml | 61 +++++- bin/check-test-attribution.py | 165 ++++++++++++++ bin/lib/test-state.sh | 50 +++++ bin/pio-test-isolate.sh | 11 +- bin/run-tests.sh | 89 +++++++- bin/stress-suite.sh | 202 ++++++++++++++++++ bin/test-attribution-canary.sh | 82 +++++++ src/mesh/Router.cpp | 15 +- src/mesh/Router.h | 2 + test/README.md | 2 + test/TestUtil.cpp | 128 +++++++++++ test/TestUtil.h | 9 + test/state-manifest.tsv | 19 +- test/test_admin_session_repro/test_main.cpp | 12 -- test/test_event_channel_router/test_main.cpp | 15 -- test/test_geocoord_distance/test_main.cpp | 5 + .../test_serializer.cpp | 5 + test/test_pki_admin_fallback/test_main.cpp | 14 +- test/test_position_precision/test_main.cpp | 7 - test/test_traffic_management/test_main.cpp | 6 +- test/test_utf8/test_main.cpp | 5 + variants/native/portduino/platformio.ini | 7 + 22 files changed, 854 insertions(+), 57 deletions(-) create mode 100755 bin/check-test-attribution.py create mode 100755 bin/stress-suite.sh create mode 100755 bin/test-attribution-canary.sh diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 04e6b3a23..2167e2956 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -195,6 +195,13 @@ jobs: timeout-minutes: 5 run: ./bin/test-config-check.sh .pio/build/coverage/meshtasticd + - name: Shared-state checker self-test + # Fixtures that write nothing / exactly what they declare / something undeclared / + # a declared write they never make, asserting CLEAN / CLEAN / DIRTY / MISSING. A + # checker that has silently stopped matching looks identical to a clean codebase. + timeout-minutes: 5 + run: ./bin/test-state-check.sh + - 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, @@ -273,9 +280,12 @@ jobs: restore-keys: | pio-coverage-tests- - - name: Build test programs once - # One shared build of src + every test program. This is the single source build; gcov then - # accumulates coverage counts into this shared .pio/build/coverage/src as the chunks run. + - name: Warm the shared test build + # Compiles src + every test program once so no single area absorbs the whole src build in + # its reported duration; gcov then accumulates counts into this shared + # .pio/build/coverage/src as the areas run. NOT a substitute for building in the run step: + # PlatformIO links every test program to the one .pio/build/coverage/meshtasticd path, so a + # --without-building run executes whichever suite was linked last under every suite's name. run: platformio test -e coverage --without-testing - name: Save PlatformIO cache @@ -368,12 +378,21 @@ jobs: echo "::group::area $a (${group[$a]# })" # Capture platformio's real exit status (not grep's) via a log file, then show the log # with the noisy per-variant SKIPPED rows filtered out. - if ! platformio test -e coverage --without-building -v ${group[$a]# } \ + if ! platformio test -e coverage -v ${group[$a]# } \ --junit-output-path "testreport-$a.xml" > "area-$a.log" 2>&1; then fail=1 echo "::error::area $a had test failures" fi + # Suites outside this area are reported SKIPPED by design (PlatformIO lists every suite + # in the env and marks the unselected ones finished), so those rows are noise here. The + # attribution check below is what catches a suite that was selected and did not run. grep -v "[[:space:]]SKIPPED$" "area-$a.log" || true + # Per area, so a mismatch names the area it happened in rather than the whole run. + if ! ./bin/check-test-attribution.py --label "area $a" \ + --expect "${group[$a]# }" "testreport-$a.xml"; then + fail=1 + echo "::error::area $a ran suites that did not match their own test binaries" + fi echo "::endgroup::" done exit $fail @@ -398,6 +417,18 @@ jobs: ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True) PY + - name: Verify every suite ran its own tests + # Whole-run gate over the merged report: every test_* directory must appear with at least + # one test case, and every case must come from the suite that reported it. The per-area + # check above cannot see an area that never executed - this can. + if: always() # a suite going missing is the finding; do not hide it behind an earlier failure + shell: bash + run: | + set -euo pipefail + mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort) + ./bin/check-test-attribution.py --label "coverage (all areas)" \ + --expect "${suites[*]}" testreport.xml + - name: Capture coverage information if: always() # run this step even if previous step failed run: | @@ -405,9 +436,31 @@ jobs: lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative. + - name: Attribution canary + # Guards the guard above: runs two suites the broken way (--without-building, so PlatformIO + # does not relink and both execute the same leftover binary) and requires the checker to + # catch it. Fails if the checker regressed, or if the reproduction stops reproducing - in + # which case the reason both harnesses stopped passing that flag no longer holds. + # + # Lives in this job, not simulator-tests: it relinks $BUILD_DIR/$PROGNAME, and there that + # replaced the daemon binary with a test suite, so the integration test waited for a socket + # a test binary never opens. Here the binary is already per-suite and nothing later needs it. + timeout-minutes: 15 + run: ./bin/test-attribution-canary.sh -e coverage + - name: Event channel policy tests run: platformio test -e coverage-event-policy -v --junit-output-path event-policy-testreport.xml + - name: Verify the event-policy suites ran their own tests + # Expected set read through PlatformIO's own config parser, so it cannot drift from the + # env's test_filter the way a second hand-maintained list would. + run: | + set -euo pipefail + expect=$(python3 -c "from platformio.project.config import ProjectConfig; \ + print(' '.join(ProjectConfig().get('env:coverage-event-policy', 'test_filter', [])))") + ./bin/check-test-attribution.py --label coverage-event-policy \ + --expect "$expect" event-policy-testreport.xml + - name: Save test results if: always() # run this step even if previous step failed uses: actions/upload-artifact@v7 diff --git a/bin/check-test-attribution.py b/bin/check-test-attribution.py new file mode 100755 index 000000000..2d3d258e4 --- /dev/null +++ b/bin/check-test-attribution.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Verify each PlatformIO JUnit report ran the suite it claims to have run. + +PlatformIO links every native test program to one path ($BUILD_DIR/$PROGNAME) and parses +Unity output textually, without checking that the reported source file belongs to the suite +it is running. Split a run into `--without-testing` then `--without-building` and every suite +executes whichever binary was linked last, all reporting PASSED. This reads the JUnit reports +that run already produces and fails on the two shapes that hides: + + MISATTRIBUTED - a test case whose source file lives outside the suite that reported it + EMPTY - a suite that was asked to run and produced no test cases at all + +Usage: + check-test-attribution.py [--expect "s1 s2"]... [--label TEXT] REPORT.xml... + +--expect names the suites the run was asked for (repeatable, whitespace- or `-f`-separated, +so a CI area string can be passed through verbatim). Omit it to check attribution only. +Exit: 0 clean, 1 findings, 2 bad usage / unreadable report. +""" + +import argparse +import glob +import sys +import xml.etree.ElementTree as ET + + +def parse_expect(values): + """Flatten repeated --expect values into a suite list, tolerating `-f suite` tokens.""" + suites = [] + for value in values or []: + for token in value.split(): + if token == "-f": + continue + suites.append(token.removeprefix("-f")) + return [s for s in suites if s] + + +def suite_of(testsuite_name): + """`coverage:test_foo` -> `test_foo`; a bare name is returned unchanged.""" + return testsuite_name.split(":", 1)[1] if ":" in testsuite_name else testsuite_name + + +def owns(suite, source_file): + """Report whether source_file sits inside the suite's own directory. + + Matched on a whole path segment so `test_mesh` does not claim `test_mesh_module`, and + with a leading separator so absolute and relative paths behave the same. + """ + normalized = "/" + source_file.replace("\\", "/").lstrip("/") + return f"/{suite}/" in normalized + + +def collect(paths): + """Map suite -> list of (case name, source file or None), merged across reports.""" + cases = {} + for path in paths: + try: + # The input is the JUnit report PlatformIO just wrote in this same run, not untrusted + # data, and defusedxml is not installed for this job. + # nosemgrep: python.lang.security.use-defused-xml-parse.use-defused-xml-parse + root = ET.parse(path).getroot() + except (ET.ParseError, OSError) as exc: + sys.stderr.write(f"check-test-attribution: cannot read {path}: {exc}\n") + sys.exit(2) + # PlatformIO nests under ; accept a bare too. + nodes = [root] if root.tag == "testsuite" else root.iter("testsuite") + for node in nodes: + suite = suite_of(node.get("name", "")) + if not suite: + continue + entries = cases.setdefault(suite, []) + for case in node.iter("testcase"): + entries.append((case.get("name", "?"), case.get("file"))) + return cases + + +def main(): + parser = argparse.ArgumentParser(add_help=True) + parser.add_argument("--expect", action="append", default=[]) + parser.add_argument("--label", default="") + parser.add_argument("reports", nargs="+") + args = parser.parse_args() + + # Expand globs ourselves: CI passes a pattern that may match nothing if a step was skipped, + # and a silent pass over zero reports is exactly the false green this script exists to stop. + paths = sorted({p for pattern in args.reports for p in glob.glob(pattern)}) + if not paths: + sys.stderr.write( + "check-test-attribution: no JUnit reports matched %s\n" + % " ".join(args.reports) + ) + return 2 + + cases = collect(paths) + expected = parse_expect(args.expect) + + misattributed = [] # (suite, case name, source file) + unsourced = [] # (suite, case name) + for suite, entries in sorted(cases.items()): + for name, source in entries: + if source is None: + unsourced.append((suite, name)) + elif not owns(suite, source): + misattributed.append((suite, name, source)) + + empty = [s for s in expected if not cases.get(s)] + + label = f" [{args.label}]" if args.label else "" + total = sum(len(v) for v in cases.values()) + print( + f"test attribution{label}: {len(paths)} report(s), " + f"{len([s for s, v in cases.items() if v])} suite(s) with cases, {total} case(s)" + ) + if unsourced: + print("") + print("UNSOURCED - these cases carry no source file, so ownership cannot be proved:") + for suite, name in unsourced[:20]: + print(f" {suite}: case '{name}'") + if len(unsourced) > 20: + print(f" ... +{len(unsourced) - 20} more") + print( + "A report without file attributes is not evidence that the suites ran their own" + ) + print( + "tests. Treat it as a finding rather than a pass: the JUnit format has changed, or" + ) + print("the runner emitted cases it could not attribute.") + + if misattributed: + print("") + print( + "MISATTRIBUTED - these suites reported test cases belonging to another suite." + ) + print( + "The run executed one suite's binary under another suite's name; the named" + ) + print( + "suites did NOT run. Check for --without-building in the test invocation." + ) + for suite, name, source in misattributed[:20]: + print(f" {suite}: case '{name}' came from {source}") + if len(misattributed) > 20: + print(f" ... +{len(misattributed) - 20} more") + + if empty: + print("") + print("EMPTY - these suites were asked to run and produced no test cases:") + for suite in empty: + print(f" {suite}") + + if misattributed or empty or unsourced: + print("") + print( + "RESULT: test attribution FAILED" + f"{label} ({len(misattributed)} misattributed, {len(empty)} empty," + f" {len(unsourced)} unsourced)" + ) + return 1 + + print(f"RESULT: test attribution OK{label}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/lib/test-state.sh b/bin/lib/test-state.sh index a0c624455..ce77fdd2e 100644 --- a/bin/lib/test-state.sh +++ b/bin/lib/test-state.sh @@ -167,3 +167,53 @@ state_classify() { printf 'CLEAN\t\n' fi } + +# --- Error-line budget ------------------------------------------------------------------------- +# +# A second orthogonal axis, like CLEAN/DIRTY above: a suite can pass while emitting six figures of +# LOG_ERROR, which buries a real failure and trains everyone to skim. The budget is declared in the +# same manifest, as an `errors=` flag, and it is a RANGE rather than a ceiling - for a fuzz suite the +# floor is the load-bearing half. test_fuzz_decode logging ~100k rejections is it working; the same +# suite logging none means it stopped feeding malformed input, and every case would still pass. +# +# Undeclared suites get ERROR_BUDGET_DEFAULT. Declared forms: "N" (max), "MIN..MAX", "MIN.." (floor +# only). Everything is inclusive. +ERROR_BUDGET_DEFAULT=100 + +# Count LOG_ERROR lines in a suite's captured output. +state_count_errors() { + local log="$1" + [[ -f $log ]] || { + printf '0' + return 0 + } + # `|| true`, not `|| printf 0`: grep -c already prints 0 before exiting 1 on no match, so a + # fallback that prints appends a second line and the caller gets "0\n0" to do arithmetic on. + grep -cE '^ERROR +\|' "$log" 2>/dev/null || true +} + +# VERDICTDETAIL. WITHIN / OVER / UNDER, mirroring state_classify()'s shape. +state_classify_errors() { + local count="$1" declared="$2" min=0 max="$ERROR_BUDGET_DEFAULT" + + if [[ -n $declared ]]; then + if [[ $declared == *".."* ]]; then + min="${declared%%..*}" + max="${declared##*..}" + [[ -z $max ]] && max="" + else + max="$declared" + fi + fi + + if [[ -n $max ]] && ((count > max)); then + printf 'OVER\t%d error line(s), budget %s' "$count" "${declared:-$ERROR_BUDGET_DEFAULT}" + return 0 + fi + if ((count < min)); then + printf 'UNDER\t%d error line(s), expected at least %d - is it still exercising the path?' \ + "$count" "$min" + return 0 + fi + printf 'WITHIN\t%d' "$count" +} diff --git a/bin/pio-test-isolate.sh b/bin/pio-test-isolate.sh index bd58c73eb..bfc51cffd 100755 --- a/bin/pio-test-isolate.sh +++ b/bin/pio-test-isolate.sh @@ -92,6 +92,11 @@ GRANULARITY="$(state_flag_value state "$FLAGS")" IFS=$'\t' read -r VERDICT DETAIL <<<"$(state_classify "$CHANGED" "$DECLARED")" +# Error-line budget: same manifest, same declare-and-justify shape as the writes above. Counted from +# the captured log, so it costs nothing extra. +ERROR_COUNT="$(state_count_errors "$LOG")" +IFS=$'\t' read -r ERROR_VERDICT ERROR_DETAIL <<<"$(state_classify_errors "$ERROR_COUNT" "$(state_flag_value errors "$FLAGS")")" + # Per-test attribution, when the suite has not declared that it carries state across its own test # cases. For a state=per-suite suite every test after the first would be flagged by design - that # carry *is* the declared behaviour - so only the suite boundary is meaningful there. @@ -114,14 +119,14 @@ fi STATUS=$([[ $RC -eq 0 ]] && echo PASS || echo FAIL) mkdir -p "$(dirname "$SUMMARY")" 2>/dev/null -printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$SUITE" "$STATUS" "$VERDICT" "${DETAIL-}" "${PER_TEST_DETAIL-}" \ - "${SURVIVORS-}" >>"$SUMMARY" +printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$SUITE" "$STATUS" "$VERDICT" "${DETAIL-}" "${PER_TEST_DETAIL-}" \ + "${SURVIVORS-}" "${ERROR_VERDICT-}" "${ERROR_DETAIL-}" >>"$SUMMARY" # Keep the sandbox when there is something to look at: on a failure it plus the built binary is a # complete, replayable reproduction, and on a DIRTY verdict the leftovers *are* the bug report. A # clean pass leaves nothing behind. KEEP="${MESHTASTIC_TEST_KEEP_STATE:-0}" -if [[ $RC -ne 0 || $VERDICT != CLEAN || -n ${SURVIVORS-} || $KEEP == 1 ]]; then +if [[ $RC -ne 0 || $VERDICT != CLEAN || $ERROR_VERDICT != WITHIN || -n ${SURVIVORS-} || $KEEP == 1 ]]; then DEST="$STATE_ROOT/$SUITE" rm -rf "$DEST" 2>/dev/null mv "$SCRATCH" "$DEST" 2>/dev/null || DEST="$SCRATCH" diff --git a/bin/run-tests.sh b/bin/run-tests.sh index dcc3a705f..f454d5c8f 100755 --- a/bin/run-tests.sh +++ b/bin/run-tests.sh @@ -38,7 +38,8 @@ # test/state-manifest.tsv. # FILTERED - a -f run completed cleanly; suites not in the filter were intentionally skipped. # Use this when iterating on a single suite; it is not a quality signal. -# RED - at least one failure, build error, or sanitizer fault. +# RED - at least one failure, build error, sanitizer fault, or a suite that reported +# another suite's test cases (bin/check-test-attribution.py). # # Two orthogonal axes: PASS/FAIL × CLEAN/DIRTY. Each suite runs in its own scratch $HOME # (bin/pio-test-isolate.sh), so leftovers are harmless; DIRTY means "undeclared", not "dangerous". @@ -59,6 +60,7 @@ # RESULT: AMBER N/M suites ran (missing: test_radio test_serial) - all that ran passed # RESULT: AMBER 3 test case(s) ignored # RESULT: FILTERED 1/N suites ran (not run: …) - filtered: test_utf8 +# RESULT: RED test attribution failed - suites did not run their own tests # RESULT: RED test_traffic_management: 1 failed (or: build/crash error) # RESULT: RED sanitizer fault - SUMMARY: AddressSanitizer: 1272 byte(s) leaked (tests may have # all passed; the coverage build aborts at exit on an ASan/LSan fault - often shown only @@ -163,6 +165,16 @@ export MESHTASTIC_TEST_STATE_SUMMARY="$STATE_SUMMARY" $KEEP_STATE && export MESHTASTIC_TEST_KEEP_STATE=1 $WRITE_MANIFEST && export MESHTASTIC_TEST_KEEP_STATE=1 +# --- Test attribution -------------------------------------------------------- +# PlatformIO parses Unity output textually and never checks that the source file a case came from +# belongs to the suite it thinks it ran, so one suite's binary running under another's name reads +# as a pass. The JUnit reports carry both halves (testsuite@name vs testcase@file), so collect them +# here and grade with bin/check-test-attribution.py below. Cleared first: a stale report from an +# earlier run would otherwise satisfy this run's expectations. +ATTRIB_DIR="$ROOT_DIR/.pio/test-attribution" +rm -rf "$ATTRIB_DIR" +mkdir -p "$ATTRIB_DIR" + # Canonical suite set = the directories in test/, detected on the fly. This is the sole source # of truth for "what should run"; a filtered run only expects its filtered suite. mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort) @@ -251,10 +263,15 @@ if $SHUFFLE; then echo "suite order: shuffled with --seed $SEED (${#RUN_ORDER[@]} suites)" fi -# Build every test program before running any of them, the way .github/workflows/test_native.yml +# Warm the shared src objects before running any suite, the way .github/workflows/test_native.yml # does. Fused build+run makes whichever suite PlatformIO's directory walk reaches first absorb the # whole src compile and report it as its own duration - that is how a 35s suite once reported 13 # minutes, and it hides the build cost from every timing the summary prints. +# +# This is a WARM-UP ONLY: the run below must still build. PlatformIO links every test program to +# the one $BUILD_DIR/$PROGNAME path, so a `--without-building` run executes whichever suite was +# linked last - every suite, under its own name, all PASSED. The warm-up keeps the src compile out +# of the suite timings; the per-suite step is then just one test_main.cpp plus a link. BUILD_SECS=0 build_started=$SECONDS if $QUIET; then @@ -289,19 +306,23 @@ if $SHUFFLE; then : >"$LOG" for suite in "${RUN_ORDER[@]}"; do if $QUIET; then - "$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" --without-building >>"$LOG" 2>&1 + "$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" \ + --junit-output-path "$ATTRIB_DIR/$suite.xml" >>"$LOG" 2>&1 rc=$? else - "$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" --without-building 2>&1 | tee -a "$LOG" + "$PIO" test -e "$ENV" -f "$suite" "${EXTRA_ARGS[@]}" \ + --junit-output-path "$ATTRIB_DIR/$suite.xml" 2>&1 | tee -a "$LOG" rc=${PIPESTATUS[0]} fi ((rc != 0)) && PIO_RC=$rc done elif $QUIET; then - "$PIO" test -e "$ENV" "${PASSTHRU[@]}" --without-building >"$LOG" 2>&1 + "$PIO" test -e "$ENV" "${PASSTHRU[@]}" \ + --junit-output-path "$ATTRIB_DIR/all.xml" >"$LOG" 2>&1 PIO_RC=$? else - "$PIO" test -e "$ENV" "${PASSTHRU[@]}" --without-building 2>&1 | tee "$LOG" + "$PIO" test -e "$ENV" "${PASSTHRU[@]}" \ + --junit-output-path "$ATTRIB_DIR/all.xml" 2>&1 | tee "$LOG" PIO_RC=${PIPESTATUS[0]} fi @@ -426,6 +447,18 @@ verdict_red() { exit 1 fi + # A guard in test/TestUtil.cpp aborting on purpose - a listening socket, or force_simradio put + # back. It prints FATAL on stdout precisely so this can be told apart from a fault: otherwise its + # exit(EXIT_FAILURE) lands in the heuristic below and is reported as a sanitizer abort that never + # happened, which is the same wrong-cause-in-the-verdict trap as the phantom signal above. + if grep -qE '^FATAL: ' "$LOG"; then + grep -E '^FATAL: ' "$LOG" | head -3 | sed 's/^/ /' + echo " -> a harness guard aborted the suite deliberately. Not a crash and not a sanitizer" + echo " fault; the reason is the FATAL line above, and the suite's sandbox has the full log." + echo "RESULT: RED harness guard - $(grep -m1 -oE '^FATAL: .*' "$LOG")" + exit 1 + fi + # All tests passed but the process still aborted at EXIT (ERRORED/SIGHUP/SIGABRT) and the # sanitizer report was swallowed by the runner (often surfaced only as SIGHUP). Almost always a # sanitizer fault - point at how to surface it rather than calling it a generic crash. @@ -462,6 +495,34 @@ verdict_suffix() { echo "$rating" } +# --- Attribution axis --------------------------------------------------------- +# RED, and checked before every softer verdict: a suite that reported another suite's test cases +# did not run at all, so every count and state verdict below it is measuring the wrong thing. A +# filtered run expects only its own suite; a full run expects the canonical set. +# -f takes an fnmatch pattern, not necessarily a suite name, so resolve it against the canonical +# set rather than expecting a suite literally called "test_nodedb*". An unmatched pattern leaves +# the list empty, which checks attribution only - a filter that selects nothing is already RED +# above, for want of a pass summary. +ATTRIB_EXPECT="${ALL_SUITES[*]}" +if [[ -n $FILTER ]]; then + ATTRIB_EXPECT="" + for attrib_suite in "${ALL_SUITES[@]}"; do + # shellcheck disable=SC2053 # deliberate glob match: FILTER is a pattern, not a literal + [[ $attrib_suite == $FILTER ]] && ATTRIB_EXPECT+="$attrib_suite " + done +fi +ATTRIB_OUT="$("$SCRIPT_DIR/check-test-attribution.py" --expect "$ATTRIB_EXPECT" \ + --label "$ENV" "$ATTRIB_DIR"/*.xml 2>&1)" +ATTRIB_RC=$? +if ((ATTRIB_RC != 0)); then + echo "" + echo "$ATTRIB_OUT" | sed 's/^/ /' + preserve_run_log + echo "RESULT: RED test attribution failed - suites did not run their own tests $(verdict_suffix)" + exit 1 +fi +$QUIET || echo "$ATTRIB_OUT" | tail -1 + # --- Shared-state axis -------------------------------------------------------- # Read what the per-suite wrapper recorded. Reported after the count checks so a structural problem # still wins, and before the pass/fail verdict lines so the state summary always prints. @@ -472,6 +533,7 @@ if [[ -f $STATE_SUMMARY ]]; then mapfile -t DIRTY_SUITES < <(awk -F'\t' '$3 == "DIRTY" { print $1 " (" $4 ")" }' "$STATE_SUMMARY") mapfile -t MISSING_SUITES < <(awk -F'\t' '$3 == "MISSING" { print $1 " (" $4 ")" }' "$STATE_SUMMARY") mapfile -t SURVIVOR_SUITES < <(awk -F'\t' '$6 != "" { print $1 " (pid " $6 ")" }' "$STATE_SUMMARY") + mapfile -t ERROR_BUDGET_SUITES < <(awk -F'\t' '$7 == "OVER" || $7 == "UNDER" { print $1 " " tolower($7) " budget: " $8 }' "$STATE_SUMMARY") fi # Print the opt-out count on every run, so the number creeping upward is visible without anyone @@ -551,6 +613,21 @@ if ((${#DIRTY_SUITES[@]} > 0)); then exit 2 fi +# AMBER: a suite spent its LOG_ERROR budget, or came in under a declared floor. Over budget buries a +# real failure in noise - three log sites account for nearly all of today's volume, and until those +# are demoted this stays AMBER rather than RED so it does not land red on day one and get switched +# off. Under a floor is the more interesting half: a fuzz suite that stops logging rejections has +# stopped feeding malformed input, and every one of its cases still passes. +if ((${#ERROR_BUDGET_SUITES[@]} > 0)); then + echo "" + printf ' %s\n' "${ERROR_BUDGET_SUITES[@]}" + echo "" + echo " -> over: demote the log line if the condition is expected, or declare errors= in" + echo " test/state-manifest.tsv with a reason. Under: check the suite still exercises the path." + echo "RESULT: AMBER ${#ERROR_BUDGET_SUITES[@]} suite(s) outside their error budget $(verdict_suffix)" + exit 2 +fi + # AMBER: a suite was still running after PlatformIO reported it. A bare UNITY_END() ends the # reporting, not the process - the runtime goes on calling loop() - so the suite passes, the run goes # green, and the binary stays resident. The wrapper has already killed it, but the consequences do diff --git a/bin/stress-suite.sh b/bin/stress-suite.sh new file mode 100755 index 000000000..ec63612b1 --- /dev/null +++ b/bin/stress-suite.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# +# Run one native test suite repeatedly and report how often it fails. +# +# For order-independent flakes - a real-time race, a slow-host margin, an uninitialised read - a +# single green run proves nothing. This runs the same built binary N times and prints a flake rate, +# so "passes here" becomes a measurement instead of an anecdote. +# +# ./bin/stress-suite.sh test_pki_admin_fallback # 20 runs, coverage, as CI invokes it +# ./bin/stress-suite.sh -n 200 test_packet_signing # 200 runs +# ./bin/stress-suite.sh -e native -n 50 test_admin_radio # the other env's invocation +# ./bin/stress-suite.sh -l 8 -n 50 test_pki_admin_fallback # 8 spinners of CPU contention +# ./bin/stress-suite.sh --no-simradio -n 50 test_packet_signing +# ./bin/stress-suite.sh --shuffle -n 5 # whole suite set, a new order each time +# +# --shuffle is the other axis and takes no suite name: it drives bin/run-tests.sh --seed with a fresh +# seed per iteration, so suite ORDER varies. Use it for state that leaks suite -> suite; use the +# single-suite mode above for races and slow-host margins, which order cannot expose. Every seed is +# printed, and a red one is replayable with ./bin/run-tests.sh --seed . +# +# Each run gets a fresh scratch $HOME, so no run inherits another's prefs. Failing runs keep their +# log and their $HOME; passing runs leave nothing behind. +# +# Exit: 0 = every run passed, 1 = at least one failed, 2 = usage/build error. + +set -uo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_NAME=coverage +RUNS=20 +LOAD=0 +SIMRADIO=auto +SHUFFLE=false +SUITE="" + +usage() { + sed -n '3,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 2 +} + +# A missing or non-numeric value used to sail through and produce a loop that never ran, reporting +# "0/0 failed" as a pass. Reject it at parse time instead. +need_value() { + [[ -n ${2:-} && $2 != -* ]] || { + echo "$1 needs a value" >&2 + exit 2 + } +} +need_number() { + [[ $2 =~ ^[0-9]+$ ]] || { + echo "$1 needs a number, got '$2'" >&2 + exit 2 + } +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -e | --environment) + need_value "$1" "${2:-}" + ENV_NAME="$2" + shift 2 + ;; + -n | --runs) + need_value "$1" "${2:-}" + need_number "$1" "$2" + RUNS="$2" + shift 2 + ;; + -l | --load) + need_value "$1" "${2:-}" + need_number "$1" "$2" + LOAD="$2" + shift 2 + ;; + --shuffle) + SHUFFLE=true + shift + ;; + --simradio) + SIMRADIO=yes + shift + ;; + --no-simradio) + SIMRADIO=no + shift + ;; + -h | --help) usage ;; + -*) + echo "unknown option: $1" >&2 + usage + ;; + *) + SUITE="$1" + shift + ;; + esac +done + +if $SHUFFLE; then + [[ -z $SUITE ]] || { + echo "--shuffle varies suite order across the whole set; drop the suite name" >&2 + exit 2 + } + fails=0 + reds=() + echo "running the full suite set x$RUNS on $ENV_NAME, reshuffled each time" + for ((run = 1; run <= RUNS; run++)); do + # Seeds from /dev/urandom, printed and recorded: an order you cannot replay is not evidence. + seed=$((RANDOM * 32768 + RANDOM)) + log="$REPO/.pio/build/$ENV_NAME/stress-shuffle.$seed.log" + mkdir -p "$(dirname "$log")" + printf 'run %d/%d seed %s ... ' "$run" "$RUNS" "$seed" + if "$REPO/bin/run-tests.sh" -e "$ENV_NAME" --seed "$seed" >"$log" 2>&1; then + echo "GREEN" + rm -f "$log" + else + rc=$? + fails=$((fails + 1)) + reds+=("$seed") + echo "$(grep -m1 '^RESULT:' "$log" || echo "exit $rc") - log $log" + fi + done + echo "RESULT: $fails/$RUNS runs not green" + [[ ${#reds[@]} -gt 0 ]] && echo "replay: ./bin/run-tests.sh --seed ${reds[0]}" + [[ $fails -eq 0 ]] || exit 1 + exit 0 +fi + +[[ -n $SUITE ]] || usage + +# Mirror what the env's test_testing_command passes, so a stress run reproduces the real invocation +# rather than a third one of its own. [env:coverage] adds -s (simradio); [env:native] does not. +if [[ $SIMRADIO == auto ]]; then + # Read to the next [section] header, not a fixed window: -s is the last line of the command block. + if awk "/^\\[env:$ENV_NAME\\]/{f=1;next} /^\\[/{f=0} f" \ + "$REPO/variants/native/portduino/platformio.ini" | grep -qE '^[[:space:]]+-s[[:space:]]*$'; then + SIMRADIO=yes + else + SIMRADIO=no + fi +fi +ARGS=() +[[ $SIMRADIO == yes ]] && ARGS+=(-s) + +PIO="$REPO/.pio_env/bin/pio" +[[ -x $PIO ]] || PIO="$(command -v pio)" || { + echo "pio not found" >&2 + exit 2 +} + +BIN="$REPO/.pio/build/$ENV_NAME/meshtasticd" +echo "building $SUITE for $ENV_NAME ..." +"$PIO" test -e "$ENV_NAME" -f "$SUITE" --without-testing >/dev/null 2>&1 || { + echo "build failed - rerun without --without-testing to see why" >&2 + exit 2 +} +[[ -x $BIN ]] || { + echo "no binary at $BIN" >&2 + exit 2 +} + +LOADPIDS=() +cleanup() { + [[ ${#LOADPIDS[@]} -gt 0 ]] && kill "${LOADPIDS[@]}" 2>/dev/null + return 0 +} +# EXIT cleans up; INT/TERM must also stop, or the loop keeps launching runs after a ^C. +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM + +if [[ $LOAD -gt 0 ]]; then + echo "starting $LOAD spinner(s) against $(nproc) cpu(s)" + for ((i = 0; i < LOAD; i++)); do + (while :; do :; done) & + LOADPIDS+=($!) + done +fi + +OUT="$REPO/.pio/build/$ENV_NAME/stress" +mkdir -p "$OUT" +fails=0 +echo "running $SUITE x$RUNS on $ENV_NAME (simradio=$SIMRADIO)" +for ((run = 1; run <= RUNS; run++)); do + scratch=$(mktemp -d) + log="$OUT/$SUITE.$run.log" + # Through pio-test-isolate.sh, not the bare binary: that is what test_testing_command runs, so + # a repetition here exercises the sandboxing, survivor reaping and state verdict too. + if MESHTASTIC_TEST_STATE_DIR="$scratch/state" "$REPO/bin/pio-test-isolate.sh" "$BIN" "${ARGS[@]}" >"$log" 2>&1; then + rm -rf "$scratch" "$log" + printf '.' + else + fails=$((fails + 1)) + printf '\nRUN %d FAILED - log %s - state %s\n' "$run" "$log" "$scratch" + grep -E ':(FAIL|IGNORE)' "$log" | head -5 + fi +done +printf '\n' + +pct=$((fails * 100 / RUNS)) +echo "RESULT: $fails/$RUNS failed (${pct}%)" +[[ $fails -eq 0 ]] || exit 1 diff --git a/bin/test-attribution-canary.sh b/bin/test-attribution-canary.sh new file mode 100755 index 000000000..cb873e6a8 --- /dev/null +++ b/bin/test-attribution-canary.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Canary for bin/check-test-attribution.py: reproduce the false green on purpose and require the +# checker to catch it. +# +# The attribution check exists because both harnesses once ran every suite against whichever binary +# was linked last, so all 57 reported a pass while five test programs actually executed. A checker +# for that is only worth having if it still fires, and a checker that has quietly stopped firing +# looks exactly like a codebase with no problem. So: build two suites, run them the broken way +# (--without-building, which is what stops PlatformIO relinking on a non-embedded platform), and +# assert the checker reports a mismatch. +# +# It also fails if the reproduction stops reproducing - if PlatformIO ever relinks per suite under +# --without-building, the premise behind dropping that flag no longer holds and the harness should +# be revisited rather than left resting on a stale assumption. +# +# Not a Unity suite and not a test_* directory, so it stays outside the suite count run-tests.sh +# derives from test/ - same arrangement as bin/test-state-check.sh and bin/test-config-check.sh. +# +# Usage: ./bin/test-attribution-canary.sh [-e ] (default: coverage, as CI runs) + +set -uo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO" || exit 2 + +ENV_NAME=coverage +[[ ${1-} == "-e" ]] && ENV_NAME="$2" + +PIO="$REPO/.pio_env/bin/pio" +[[ -x $PIO ]] || PIO="$(command -v pio)" || { + echo "canary: pio not found" >&2 + exit 2 +} + +# Two suites whose cases cannot be confused: different source files, different counts. Both are +# small and neither touches shared state, so the canary costs a link rather than a rebuild. +A=test_utf8 +B=test_breakout +REPORT="$(mktemp -d)/canary.xml" + +echo "canary: building $A and $B for $ENV_NAME" +"$PIO" test -e "$ENV_NAME" -f "$A" -f "$B" --without-testing >/dev/null 2>&1 || { + echo "canary: build failed" >&2 + exit 2 +} + +echo "canary: running them the broken way (--without-building)" +"$PIO" test -e "$ENV_NAME" -f "$A" -f "$B" --without-building --junit-output-path "$REPORT" >/dev/null 2>&1 + +[[ -s $REPORT ]] || { + echo "canary: no JUnit report at $REPORT - cannot judge the checker" >&2 + exit 2 +} + +# The checker must FAIL here, and fail for the RIGHT reason. Exit 1 is a finding; exit 2 is bad +# usage or an unreadable report, which would let a broken canary read as a caught mismatch. +OUT="$(./bin/check-test-attribution.py --label "canary" "$REPORT" 2>&1)" +RC=$? +if [[ $RC -eq 2 ]]; then + echo "" + echo "CANARY INCONCLUSIVE: the checker could not read the report it was given (exit 2)." + echo "$OUT" + echo "Report kept at: $REPORT" + exit 2 +fi +if [[ $RC -eq 0 ]] || ! grep -q 'MISATTRIBUTED' <<<"$OUT"; then + echo "" + echo "CANARY FAILED: the attribution check passed a run that mis-attributes its cases." + echo "" + echo "Two suites were run with --without-building, so PlatformIO did not relink and both" + echo "executed the same leftover binary. check-test-attribution.py is supposed to catch exactly" + echo "that and it did not, which means the guard against the whole false-green class is dead." + echo "" + echo "Either the checker regressed, or PlatformIO now relinks per suite under --without-building" + echo "- in which case the reason bin/run-tests.sh and CI stopped passing that flag has changed," + echo "and the harness should be revisited rather than left on a stale assumption." + echo "Report kept at: $REPORT" + exit 1 +fi + +echo "canary: OK - the attribution check caught the deliberate mis-attribution" +rm -rf "$(dirname "$REPORT")" diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index e4f52c779..c382e8576 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -809,6 +809,18 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) static uint32_t adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; static uint32_t adminKeyFallbackRefillMs = 0; +#ifdef PIO_UNIT_TESTING +// The refill stamp is a timestamp, so it is only meaningful against the clock that produced it. +// A suite that swaps between the real and the virtual clock leaves a stamp from the other +// timebase, and the next unsigned subtraction reads as a near-infinite gap: the bucket silently +// refills to full. Re-stamp when the clock changes. +void resetAdminKeyFallbackBudget() +{ + adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; + adminKeyFallbackRefillMs = Time::getMillis(); +} +#endif + static bool adminKeyFallbackAllowed() { bool haveAdminKey = false; @@ -821,7 +833,8 @@ static bool adminKeyFallbackAllowed() if (!haveAdminKey) return false; // nothing to try, so do not spend a token - uint32_t now = millis(); + // Injectable clock so the budget can be tested without sleeping, and without racing a slow host. + uint32_t now = Time::getMillis(); if (adminKeyFallbackRefillMs == 0) adminKeyFallbackRefillMs = now; uint32_t elapsed = now - adminKeyFallbackRefillMs; diff --git a/src/mesh/Router.h b/src/mesh/Router.h index eb1213de3..9882a4b9c 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -265,6 +265,8 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p); #ifdef PIO_UNIT_TESTING uint32_t routingAuthEvaluationCount(); void resetRoutingAuthEvaluationCount(); +/** Refill the admin-key fallback budget and re-stamp it against the clock in use right now. */ +void resetAdminKeyFallbackBudget(); #endif /** Return 0 for success or a Routing_Error code for failure diff --git a/test/README.md b/test/README.md index d1dbd804c..ceb819206 100644 --- a/test/README.md +++ b/test/README.md @@ -33,6 +33,8 @@ Randomisation costs one `pio` invocation per suite (about 4.7s each), because Pl > **Copilot interface note:** When running tests via the Copilot chat interface, edits made through the chat may not be reflected in the on-disk files that the test binary reads. If tests pass in chat but fail locally (or vice versa), verify the files on disk match what you expect before trusting the result. Always confirm with a local terminal run. +**Never add `--without-building` to a test run.** PlatformIO links every native test program to the single `$BUILD_DIR/$PROGNAME` path and attributes Unity output by text alone, so a run that only builds beforehand executes whichever suite was linked last under _every_ suite's name - all reporting PASSED. Build once with `--without-testing` to warm the shared src objects if you like; the run itself must still build. `bin/check-test-attribution.py` grades the JUnit reports for exactly this and is wired into both `bin/run-tests.sh` (RED) and CI. + **Raw `pio test` (no sanitizers, no verdict logic)** - use when you need to override the env or inspect verbose Unity output: ```bash diff --git a/test/TestUtil.cpp b/test/TestUtil.cpp index 58cd34c15..9f36ee17a 100644 --- a/test/TestUtil.cpp +++ b/test/TestUtil.cpp @@ -18,21 +18,149 @@ // The state checkpoint needs a POSIX directory walk, and only the host builds run these suites. // Note ARDUINO *is* defined on portduino, so it is not the right guard here. #if ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" #include #include #include #include #include #include +#include #include #include +#include #endif +#if ARCH_PORTDUINO +// A test binary must not be reachable from the network. main.cpp's setup()/loop() are compiled out +// under PIO_UNIT_TESTING, so the phone API, MQTT and the web server are never started - but that is +// a property of today's guards, not something anything checks. A suite that pulled in a service +// which binds a port would otherwise open one on the developer's machine, silently, for the length +// of the run. Assert the absence instead of trusting it. +// +// Listening sockets only: an outbound connection is a different (and louder) problem, and gethostby* +// opens transient sockets that would make an any-socket check flap. +static void assertNoListeningSockets() +{ + // Socket fds appear as "socket:[inode]"; a listening TCP row in /proc/self/net carries st 0A. + std::set ours; + if (DIR *fds = opendir("/proc/self/fd")) { + while (struct dirent *e = readdir(fds)) { + char path[64], target[128]; + snprintf(path, sizeof(path), "/proc/self/fd/%s", e->d_name); + ssize_t n = readlink(path, target, sizeof(target) - 1); + if (n <= 0) + continue; + target[n] = '\0'; + unsigned long inode = 0; + if (sscanf(target, "socket:[%lu]", &inode) == 1) + ours.insert(std::to_string(inode)); + } + closedir(fds); + } + if (ours.empty()) + return; + + std::string offenders; + for (const char *table : {"/proc/self/net/tcp", "/proc/self/net/tcp6"}) { + FILE *f = fopen(table, "r"); + if (!f) + continue; + char line[512]; + bool header = true; + while (fgets(line, sizeof(line), f)) { + if (header) { + header = false; + continue; + } + // sl local_address rem_address st tx:rx tr:when retrnsmt uid timeout inode + char local[128] = {0}; + unsigned st = 0, uid = 0; + unsigned long inode = 0; + if (sscanf(line, "%*d: %127s %*s %x %*s %*s %*s %u %*d %lu", local, &st, &uid, &inode) != 4) + continue; + if (st != 0x0A) // TCP_LISTEN + continue; + if (ours.count(std::to_string(inode)) == 0) + continue; + offenders += " "; + offenders += local; + } + fclose(f); + } + if (offenders.empty()) + return; + + // Before UNITY_BEGIN(), so there is no Unity failure to record - and a test binary that has + // opened a port is not a result worth collecting. Fail the suite outright and say why. + fprintf(stderr, + "FATAL: test binary is listening on%s\n" + "A unit-test run must not be reachable. Something started a network service - check what\n" + "the suite constructs, and whether it belongs behind main.cpp's PIO_UNIT_TESTING guard.\n", + offenders.c_str()); + fflush(stderr); + exit(EXIT_FAILURE); +} +#endif + +#if ARCH_PORTDUINO +static bool environmentBaselined = false; + +// -s is how the harness keeps a test run off the host's radio: it makes portduinoSetup() skip the +// /etc/meshtasticd/config.yaml search and return before GPIO/SPI init. That job is done by the time +// any of this runs, and the flag's only remaining readers are behaviour we do want under test - +// wouldEncryptWithPKC() disables PKC while it is set. Clear it so suites exercise the production +// encode path; the radio choice is already made and is not revisited. +static void baselineEnvironment() +{ + portduino_config.force_simradio = false; + assertNoListeningSockets(); + environmentBaselined = true; +} +#endif + +void testAssertEnvironmentIntact(const char *testName) +{ +#if ARCH_PORTDUINO + // Not every suite calls initializeTestEnvironment() - test_atak does not - so the baseline + // cannot live only there, or those suites run with PKC off and skip the socket check. Establish + // it at the first RUN_TEST for whoever has not, and hold it from then on. + if (!environmentBaselined) { + baselineEnvironment(); + return; + } + + // Per test, not once per suite: a service that binds a port is opened by the code under test, + // not by the harness, so checking only at startup would miss every case that starts one. + assertNoListeningSockets(); + + if (!portduino_config.force_simradio) + return; + + // Hard exit rather than TEST_FAIL: this runs between tests, outside any Unity test frame, so + // there is no failure to longjmp into. Repairing the flag silently would be worse - it would + // leave the suite that broke it passing. + for (FILE *out : {stdout, stderr}) + fprintf(out, + "FATAL: force_simradio was set back on before %s\n" + "PKC is disabled while it is set, so the encode path under test falls back to channel\n" + "crypto and every later case asserts the wrong thing. A test that needs simradio must\n" + "restore the flag before it returns.\n", + testName ? testName : "(unknown test)"); + fflush(stderr); + exit(EXIT_FAILURE); +#else + (void)testName; +#endif +} + void initializeTestEnvironment() { concurrency::hasBeenSetup = true; consoleInit(); #if ARCH_PORTDUINO + baselineEnvironment(); + struct timeval tv; tv.tv_sec = time(NULL); tv.tv_usec = 0; diff --git a/test/TestUtil.h b/test/TestUtil.h index bb56d1096..d2a145e52 100644 --- a/test/TestUtil.h +++ b/test/TestUtil.h @@ -17,6 +17,14 @@ void testDelay(unsigned long ms); // place instead of being spread across 40-odd suites. void testStateCheckpoint(const char *testName, const char *sourceFile); +// Checked before every test, because the environment a suite starts in is not the one it keeps. +// initializeTestEnvironment() clears force_simradio once, and a test that sets it - directly, or by +// restoring a struct it saved before the clear - silently disables PKC for every test after it. +// wouldEncryptWithPKC() would then return false and the encode path would quietly fall back to +// channel crypto, which is a passing test asserting the wrong thing. Named per test so the culprit +// is the test that follows the one that broke it. +void testAssertEnvironmentIntact(const char *testName); + // Every RUN_TEST becomes a checkpoint. An unintended write has no matching assertion *by // definition* - nobody wrote a TEST_ASSERT for the nodes.proto write that broke test_admin_radio, // because nobody knew it happened - so attribution has to come from outside the test body. @@ -27,6 +35,7 @@ void testStateCheckpoint(const char *testName, const char *sourceFile); #undef RUN_TEST #define RUN_TEST(func, ...) \ do { \ + testAssertEnvironmentIntact(#func); \ UnityDefaultTestRun(func, #func, __LINE__); \ testStateCheckpoint(#func, __FILE__); \ } while (0) diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index 7420504e8..02870c573 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -39,21 +39,30 @@ # add, for a human to paste and justify. It never applies them itself, and CI never applies them at # all - an auto-accepted baseline is the same rot as an auto-updated snapshot. # +# errors= | .. | .. caps a suite's LOG_ERROR lines, default 100. A range, not a +# ceiling: for a fuzz suite the floor is the half that matters. test_fuzz_decode logging ~100k +# rejections is the suite working; the same suite logging none means it stopped feeding malformed +# input, and every case would still pass. Bounds are wide on purpose - they catch a path that has +# stopped running, not a drift of a few hundred lines. +# # suite flags reason -test_admin_radio writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs per-test NodeDB fixture, and the admin handlers under test persist config, channels and node metadata +test_admin_radio writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs errors=400 per-test NodeDB fixture, and the admin handlers under test persist config, channels and node metadata test_admin_session_repro writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty +test_event_channel_phone_api writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB, whose constructor persists a default set when the prefs directory is empty +test_event_channel_router writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto errors=200 subclasses NodeDB for the event-channel fixtures; the base constructor persists a default set when the prefs directory is empty test_firmware_edition writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto persists an event firmware_edition in devicestate, then reboots a NodeDB to prove a vanilla build resets it -test_fuzz_packets writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs drives decode of fuzzed packets through the real NodeDB and message store +test_fuzz_decode errors=20000..250000 fuzzes protobuf decode; every rejection logs. A collapse to near zero means the corpus stopped reaching the decoder +test_fuzz_packets writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs errors=5000..60000 drives decode of fuzzed packets through the real NodeDB and message store test_hop_scaling writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB to hold the hop-distance fixtures test_mesh_beacon writes=module.proto exercises the beacon's module-config save path test_mesh_module writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat module framework tests construct a NodeDB -test_mqtt writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB for node lookups in the MQTT paths +test_mqtt writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto errors=1000..12000 constructs a NodeDB for node lookups in the MQTT paths test_nexthop_routing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto next-hop selection reads and updates the node DB test_nodedb_blocked state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat saturates the DB with MAX_NUM_NODES-2 favourited nodes to test the protected cap; a later test's removeNodeByNum() persists that state, and the cap test depends on the fill from the test before it -test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat needs a NodeDB holding both peers' keys for the PKI encode/decode paths +test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=300 needs a NodeDB holding both peers' keys for the PKI encode/decode paths test_pki_admin_fallback writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto needs a NodeDB holding admin keys for the fallback paths test_stream_api writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto drives real PhoneAPI handshakes, which read and persist config and the node DB test_traceroute_nexthop writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto traceroute route selection reads the node DB -test_traffic_management writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat constructs a NodeDB for the per-node rate-limit and dedup state +test_traffic_management writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=3000..12000 constructs a NodeDB for the per-node rate-limit and dedup state; test_tm_fuzz_nodenum_blitz feeds malformed payloads, and each rejection logs (measured 7985) test_transmit_history writes=transmit_history.dat persistence round-trip: what it asserts is that retransmission state survives a save/load test_warm_store writes=warm.dat persistence round-trip of the warm-tier snapshot, which is the tier's whole contract diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp index c93c1fd94..36efc9a41 100644 --- a/test/test_admin_session_repro/test_main.cpp +++ b/test/test_admin_session_repro/test_main.cpp @@ -21,10 +21,6 @@ #include "support/MockMeshService.h" #include -#ifdef ARCH_PORTDUINO -#include "platform/portduino/PortduinoGlue.h" -#endif - static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to @@ -161,14 +157,6 @@ void setUp(void) nodeDB = mockNodeDB; myNodeInfo.my_node_num = LOCAL_NODE; -#ifdef ARCH_PORTDUINO - // The native test harness boots Portduino in simulated mode, and wouldEncryptWithPKC() - // hard-disables PKC whenever force_simradio is set. Left true, no outgoing admin request is - // ever key-pinned, so the pinning tests below cannot exercise what they are asserting. - // Model a real (non-sim) device instead. - portduino_config.force_simradio = false; -#endif - config = meshtastic_LocalConfig_init_zero; // A real device always holds a private key; without one perhapsEncode never picks PKC. config.security.private_key.size = 32; diff --git a/test/test_event_channel_router/test_main.cpp b/test/test_event_channel_router/test_main.cpp index c1f5e8bec..82700a795 100644 --- a/test/test_event_channel_router/test_main.cpp +++ b/test/test_event_channel_router/test_main.cpp @@ -10,9 +10,6 @@ #include "mesh/MeshService.h" #include "mesh/NodeDB.h" #include "mesh/Router.h" -#if ARCH_PORTDUINO -#include "platform/portduino/PortduinoGlue.h" -#endif #include #include #include @@ -121,9 +118,6 @@ struct SavedGlobals { MeshService *service; AirTime *airTime; concurrency::Lock *cryptLock; -#if ARCH_PORTDUINO - bool forceSimRadio; -#endif }; SavedGlobals saved; @@ -319,9 +313,6 @@ void setUp(void) saved.service = service; saved.airTime = airTime; saved.cryptLock = cryptLock; -#if ARCH_PORTDUINO - saved.forceSimRadio = portduino_config.force_simradio; -#endif testNodeDB = new TestNodeDB(); testNodeDB->clearTestNodes(); @@ -335,9 +326,6 @@ void setUp(void) memset(&myNodeInfo, 0, sizeof(myNodeInfo)); myNodeInfo.my_node_num = kLocalNode; service = nullptr; -#if ARCH_PORTDUINO - portduino_config.force_simradio = false; -#endif installChannels(); testAirTime = new AirTime(); @@ -379,9 +367,6 @@ void tearDown(void) router = saved.router; service = saved.service; airTime = saved.airTime; -#if ARCH_PORTDUINO - portduino_config.force_simradio = saved.forceSimRadio; -#endif } EVENT_ROUTER_TEST_ENTRY void setup() diff --git a/test/test_geocoord_distance/test_main.cpp b/test/test_geocoord_distance/test_main.cpp index de3430f1c..e54498a15 100644 --- a/test/test_geocoord_distance/test_main.cpp +++ b/test/test_geocoord_distance/test_main.cpp @@ -1,3 +1,8 @@ +// Deliberately does NOT include TestUtil.h. This suite is pure-function - no NodeDB, no router, no +// sockets, no PKC - so the harness-wide guards there (no listening sockets, force_simradio clear) +// would assert conditions it cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup +// would add portduino globals it otherwise never touches. Suite-level state cleanliness is still +// checked from outside by bin/pio-test-isolate.sh, which wraps every suite regardless. #include "configuration.h" #include "gps/GeoCoord.h" #include diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp index db863ca3c..0ddb4ca0b 100644 --- a/test/test_meshpacket_serializer/test_serializer.cpp +++ b/test/test_meshpacket_serializer/test_serializer.cpp @@ -1,3 +1,8 @@ +// Deliberately does NOT include TestUtil.h. This suite is pure-function - no NodeDB, no router, no +// sockets, no PKC - so the harness-wide guards there (no listening sockets, force_simradio clear) +// would assert conditions it cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup +// would add portduino globals it otherwise never touches. Suite-level state cleanliness is still +// checked from outside by bin/pio-test-isolate.sh, which wraps every suite regardless. #include "test_helpers.h" #include #include diff --git a/test/test_pki_admin_fallback/test_main.cpp b/test/test_pki_admin_fallback/test_main.cpp index 5c3b408c9..127d4f999 100644 --- a/test/test_pki_admin_fallback/test_main.cpp +++ b/test/test_pki_admin_fallback/test_main.cpp @@ -9,6 +9,7 @@ // The whole feature is compiled out when PKI is excluded. #if !(MESHTASTIC_EXCLUDE_PKI) +#include "UptimeClock.h" #include "mesh/Channels.h" #include "mesh/CryptoEngine.h" #include "mesh/NodeDB.h" @@ -148,6 +149,11 @@ void setUp(void) void tearDown(void) { + // The rate-limit case drives a virtual timebase; leave the real clock for everyone else, and + // re-stamp the budget so the next case does not measure a virtual stamp against real millis. + Time::useRealClock(); + resetAdminKeyFallbackBudget(); + delete mockNodeDB; mockNodeDB = nullptr; nodeDB = nullptr; @@ -205,8 +211,10 @@ void test_wrong_admin_key_does_not_decode(void) // The fallback is budget-limited against flooding; see Router.cpp for why the budget is global. void test_admin_key_fallback_is_rate_limited(void) { - // Start from a full bucket regardless of what earlier tests consumed (8 tokens, one per 250ms). - delay(2500); + // Drive the virtual clock: on the wall clock the eight decodes below have to beat the 250ms + // refill, which is ~31ms each - CI misses that and the bucket refills mid-drain. + Time::setTestMillis(1000000); + resetAdminKeyFallbackBudget(); // re-stamp against the virtual clock we just switched to uint8_t otherPub[32], otherPriv[32]; crypto->generateKeyPair(otherPub, otherPriv); @@ -225,7 +233,7 @@ void test_admin_key_fallback_is_rate_limited(void) TEST_ASSERT_NOT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&blocked), "fallback should be budget-limited"); // The budget refills, so the throttle is not a permanent lockout. - delay(600); + Time::advanceTestMillis(600); meshtastic_MeshPacket allowed = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&allowed), "budget should refill over time"); assertDecodedAndLearned(&allowed, adminPub); diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index fae50e87f..7497b42f6 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -8,10 +8,6 @@ #include #include #include -#if ARCH_PORTDUINO -#include "platform/portduino/PortduinoGlue.h" -#endif - static meshtastic_Position makePosition() { meshtastic_Position position = meshtastic_Position_init_default; @@ -332,9 +328,6 @@ static void test_eventCoordinatePolicy_coversPortsAndExcludesPki() waypoint.to = 0x12345678; config.security.private_key.size = 32; owner.is_licensed = false; -#if ARCH_PORTDUINO - portduino_config.force_simradio = false; -#endif TEST_ASSERT_TRUE(willUsePki(&waypoint)); TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&waypoint)); #else diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index cfe06e7f5..d7d947d5a 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -211,6 +211,8 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule MockNodeDB *mockNodeDB = nullptr; +static void installWellKnownPrimaryChannel(); // defined below, next to the other channel fixtures + static void resetTrafficConfig() { moduleConfig = meshtastic_LocalModuleConfig_init_zero; @@ -220,7 +222,9 @@ static void resetTrafficConfig() config = meshtastic_LocalConfig_init_zero; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; - channelFile = meshtastic_ChannelFile_init_zero; + // A real device always has a primary channel; leaving channels_count at 0 made every router + // lookup log "Invalid channel index", 12k lines of it, without testing anything. + installWellKnownPrimaryChannel(); owner.is_licensed = false; myNodeInfo.my_node_num = kLocalNode; diff --git a/test/test_utf8/test_main.cpp b/test/test_utf8/test_main.cpp index 7ce90f250..ebf47be9b 100644 --- a/test/test_utf8/test_main.cpp +++ b/test/test_utf8/test_main.cpp @@ -1,3 +1,8 @@ +// Deliberately does NOT include TestUtil.h. This suite is pure-function - no NodeDB, no router, no +// sockets, no PKC - so the harness-wide guards there (no listening sockets, force_simradio clear) +// would assert conditions it cannot reach, and initializeTestEnvironment()'s RTC and OSThread setup +// would add portduino globals it otherwise never touches. Suite-level state cleanliness is still +// checked from outside by bin/pio-test-isolate.sh, which wraps every suite regardless. #include "meshUtils.h" #include #include diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 37d5bf2a0..81b96f3e8 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -32,9 +32,16 @@ build_flags = ${native_base.build_flags} ; assertions. Registered here rather than only in bin/run-tests.sh so a bare `pio test` and CI get ; the same boundary. See bin/pio-test-isolate.sh. ; https://docs.platformio.org/en/latest/projectconf/sections/env/options/test/test_testing_command.html +; -s matches [env:coverage]. The sandbox above only covers $HOME, but portduinoSetup() searches +; ./config.yaml and /etc/meshtasticd/config.yaml - absolute, so no $HOME sandbox can hide it. On a +; host running meshtasticd that config selects the real LoRa module and the run proceeds into GPIO +; and SPI setup, so a test run would drive the developer's own radio. -s short-circuits ahead of the +; config search and returns before hardware init. Suites asserting behaviour that simradio changes +; (PKC selection) clear the flag themselves in setUp, after the radio choice is already made. test_testing_command = ${platformio.src_dir}/../bin/pio-test-isolate.sh ${platformio.build_dir}/${this.__env__}/meshtasticd + -s [env:native-tft] extends = native_base From c773049b1dbb245acba5e53f60fc3b11c80698aa Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Sun, 16 Aug 2026 11:58:59 +0000 Subject: [PATCH 074/109] I2C reclock guard - avoid gazillion calls to reclock on SENXX sensors (#11412) * Add SEN6X * Adds new SENXX class for SEN5X and SEN6X * Adds CO2 sensor calibration class to be shared among othre CO2 sensors * Make existing CO2 sensor draw from CO2Sensor class * Minor coment for CO2 sensor class * Move away from getRTC in SENXX class to keep track of time changes. * Change all sensors to millis for tracking time, instead of using getRTC * Add comments regarding VOC state * Avoid storing non-valid RTC Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Avoid CO2 sensor warm-up time to be below PM measured started Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix limits in CO2 sensor calibration Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Add pragma once on headers * Avoid non-working ASC commands Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix data poll * Move pm measure started before warmup check * Make cleaning non-blocking * Restore previous state if cleaning fails. Fix data ready condition. * Fix CO2 sensor checks for calibration * Add ReClockI2C guard to simplify calls to Reclock. Make SENXX calls to reclock outside of readBuffer, to avoid bizillion calls * Add new reClockGuard to all sensor classes that require it * Make clock guard store values on each construction and restore them directly * Reduce log messages * Update ADS1X15 to new guard --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/detect/ReClockI2C.h | 57 +++++++++----- .../Telemetry/Sensor/ADS1X15Sensor.cpp | 15 +--- src/modules/Telemetry/Sensor/DS248XSensor.cpp | 20 +---- src/modules/Telemetry/Sensor/HM330XSensor.cpp | 21 ++---- .../Telemetry/Sensor/PMSA003ISensor.cpp | 21 ++---- src/modules/Telemetry/Sensor/SCD30Sensor.cpp | 46 ++++-------- src/modules/Telemetry/Sensor/SCD4XSensor.cpp | 74 ++++--------------- src/modules/Telemetry/Sensor/SENXXSensor.cpp | 59 ++++++++------- src/modules/Telemetry/Sensor/SENXXSensor.h | 5 ++ src/modules/Telemetry/Sensor/SFA30Sensor.cpp | 40 ++-------- 10 files changed, 126 insertions(+), 232 deletions(-) diff --git a/src/detect/ReClockI2C.h b/src/detect/ReClockI2C.h index 24a166d53..2501224a0 100644 --- a/src/detect/ReClockI2C.h +++ b/src/detect/ReClockI2C.h @@ -13,7 +13,8 @@ https://github.com/sandeepmistry/arduino-nRF5/blob/master/libraries/Wire/Wire.h#L50 https://github.com/earlephilhower/arduino-pico/blob/master/libraries/Wire/src/Wire.h#L60 https://github.com/stm32duino/Arduino_Core_STM32/blob/main/libraries/Wire/src/Wire.h#L103 - For cases when I2C speed is different to the ones defined by sensors (see defines in sensor classes) + For cases when I2C speed is different to the ones defined by sensors + (see defines in sensor classes) we need to reclock I2C and set it back to the previous established speed. Only for cases where we can know it (ESP32 or known screen) we can do this. */ @@ -27,10 +28,16 @@ class ReClockI2C { this->i2cBus = i2cBus; this->port = port; - this->previousClock = 0; } - bool setClock(uint32_t desiredClock) + // Sets the I2C clock to desiredClock and returns whatever clock was active + // beforehand, so the caller can hand it back to restoreClock() later. The + // previous clock is returned rather than stored on this object, so callers + // that nest calls (see ReClockI2CGuard) each keep their own restoration + // value instead of clobbering a single shared one. + // Returns 0 if the clock was already at desiredClock, or if the previous + // clock couldn't be determined - in both cases there's nothing to restore. + uint32_t setClock(uint32_t desiredClock) { uint32_t currentClock = this->getClock(); @@ -41,36 +48,27 @@ class ReClockI2C if (currentClock != desiredClock) { LOG_TRACE("Changing I2C clock to %uHz", desiredClock); this->i2cBus->setClock(desiredClock); - // If the clock is 0Hz, we still store it - // We'll check in restoreClock function - setPreviousClock(currentClock); - LOG_TRACE("Stored previous clock I2C clock: %uHz", this->previousClock); - return true; + LOG_TRACE("Previous I2C clock: %uHz", currentClock); + return currentClock; } LOG_TRACE("I2C clock was already %uHz. Skipping", desiredClock); - setPreviousClock(0); - return false; + return 0; } - bool restoreClock() + void restoreClock(uint32_t previousClock) { - if (this->previousClock) { - LOG_TRACE("Restoring I2C clock to %uHz", this->previousClock); - i2cBus->setClock(this->previousClock); - setPreviousClock(0); - return true; + if (previousClock) { + LOG_TRACE("Restoring I2C clock to %uHz", previousClock); + i2cBus->setClock(previousClock); + return; } LOG_TRACE("I2C clock was unknown. Not restored"); - return false; } private: TwoWire *i2cBus{}; ScanI2C::I2CPort port{}; - uint32_t previousClock = 0; - - void setPreviousClock(uint32_t clock) { this->previousClock = clock; } uint32_t getClock() { @@ -95,4 +93,23 @@ class ReClockI2C } }; +/* Helper for ReClockI2C: sets the clock on construction and restores it on + destruction, so a caller with multiple early-return paths doesn't need to + remember to call restoreClock() on each one. + */ +class ReClockI2CGuard +{ + public: + ReClockI2CGuard(ReClockI2C &reClock, uint32_t desiredClock) : reClock(reClock), previousClock(reClock.setClock(desiredClock)) + { + } + ~ReClockI2CGuard() { reClock.restoreClock(previousClock); } + ReClockI2CGuard(const ReClockI2CGuard &) = delete; + ReClockI2CGuard &operator=(const ReClockI2CGuard &) = delete; + + private: + ReClockI2C &reClock; + uint32_t previousClock; +}; + #endif diff --git a/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp b/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp index 1b22e6358..1a8e923f1 100644 --- a/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp +++ b/src/modules/Telemetry/Sensor/ADS1X15Sensor.cpp @@ -14,21 +14,18 @@ bool ADS1X15Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) LOG_INFO("Init sensor: %s (address: 0x%x)", sensorName, dev->address.address); _bus = bus; - _port = dev->address.port; + _address = dev->address.address; _deviceType = dev->type; #ifdef ADS1X15_I2C_CLOCK_SPEED + _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(ADS1X15_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, ADS1X15_I2C_CLOCK_SPEED); #endif /* ADS1X15_I2C_CLOCK_SPEED */ status = ads1x15.begin(_address, _bus); -#ifdef ADS1X15_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* ADS1X15_I2C_CLOCK_SPEED */ - initI2CSensor(); return status; @@ -104,15 +101,11 @@ bool ADS1X15Sensor::getMetrics(meshtastic_Telemetry *measurement) { // Done here and not in getMeasurements to avoid the back-and-forth 4-8 times one after the other #ifdef ADS1X15_I2C_CLOCK_SPEED - reClockI2C.setClock(ADS1X15_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, ADS1X15_I2C_CLOCK_SPEED); #endif /* ADS1X15_I2C_CLOCK_SPEED */ struct _ADS1X15Measurements m = getMeasurements(); -#ifdef ADS1X15_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* ADS1X15_I2C_CLOCK_SPEED */ - switch (_deviceType) { case ScanI2C::DeviceType::ADS1X15: { measurement->variant.environment_metrics.has_adc_voltage_ch0 = true; diff --git a/src/modules/Telemetry/Sensor/DS248XSensor.cpp b/src/modules/Telemetry/Sensor/DS248XSensor.cpp index d0e138552..fe0877e6d 100644 --- a/src/modules/Telemetry/Sensor/DS248XSensor.cpp +++ b/src/modules/Telemetry/Sensor/DS248XSensor.cpp @@ -63,13 +63,11 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef DS248X_I2C_CLOCK_SPEED reClockI2C.setup(_bus, _port); - reClockI2C.setClock(DS248X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, DS248X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, DS248X_I2C_CLOCK_SPEED); #endif /* DS248X_I2C_CLOCK_SPEED */ if (!ds248x.begin(bus, _address)) { -#ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* DS248X_I2C_CLOCK_SPEED */ return false; } @@ -151,9 +149,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) } if (initError && retry == numRetries) { -#ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* DS248X_I2C_CLOCK_SPEED */ LOG_ERROR("%s: Max retries for one-wire init (%u/%u). Aborting", sensorName, retry, numRetries); return false; } @@ -173,10 +168,6 @@ bool DS248XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) delay(500); } -#ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* DS248X_I2C_CLOCK_SPEED */ - initI2CSensor(); return status; } @@ -190,7 +181,8 @@ bool DS248XSensor::isValidROM(const uint8_t *rom) float DS248XSensor::readTemperatureROM(const uint8_t *rom) { #ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.setClock(DS248X_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, DS248X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, DS248X_I2C_CLOCK_SPEED); #endif /* DS248X_I2C_CLOCK_SPEED */ uint8_t data[9]{}; @@ -219,10 +211,6 @@ float DS248XSensor::readTemperatureROM(const uint8_t *rom) } } -#ifdef DS248X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* DS248X_I2C_CLOCK_SPEED */ - if (!ok) { LOG_WARN("%s: One-wire transaction failed", sensorName); return DS248X_INVALID_TEMPERATURE; diff --git a/src/modules/Telemetry/Sensor/HM330XSensor.cpp b/src/modules/Telemetry/Sensor/HM330XSensor.cpp index 91c0267e7..206b89d1d 100644 --- a/src/modules/Telemetry/Sensor/HM330XSensor.cpp +++ b/src/modules/Telemetry/Sensor/HM330XSensor.cpp @@ -17,21 +17,16 @@ bool HM330XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef HM330X_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(HM330X_I2C_CLOCK_SPEED); + + LOG_INFO("%s: reclock speed %uHz", sensorName, HM330X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, HM330X_I2C_CLOCK_SPEED); #endif /* HM330X_I2C_CLOCK_SPEED */ if (hm330x.init(_bus) != HM330XErrorCode::NO_ERROR) { -#ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* HM330X_I2C_CLOCK_SPEED */ LOG_WARN("%s error in sensor init", sensorName); return false; } -#ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* HM330X_I2C_CLOCK_SPEED */ - status = 1; LOG_INFO("%s Enabled", sensorName); @@ -77,21 +72,15 @@ int32_t HM330XSensor::pendingForReadyMs() bool HM330XSensor::getMetrics(meshtastic_Telemetry *measurement) { #ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.setClock(HM330X_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, HM330X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, HM330X_I2C_CLOCK_SPEED); #endif /* HM330X_I2C_CLOCK_SPEED */ if (hm330x.read_sensor_value(buffer, 29)) { LOG_WARN("%s: read result failed", sensorName); -#ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* HM330X_I2C_CLOCK_SPEED */ return false; } -#ifdef HM330X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* HM330X_I2C_CLOCK_SPEED */ - if (hm330x.checksum_calc(buffer) != HM330XErrorCode::NO_ERROR) { LOG_ERROR("%s: Checksum error", sensorName); return false; diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp index 8b9151379..16aa5e7d1 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.cpp @@ -24,23 +24,18 @@ bool PMSA003ISensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef PMSA003I_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(PMSA003I_I2C_CLOCK_SPEED); + + LOG_INFO("%s: reclock speed %uHz", sensorName, PMSA003I_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, PMSA003I_I2C_CLOCK_SPEED); #endif /* PMSA003I_I2C_CLOCK_SPEED */ _bus->beginTransmission(_address); if (_bus->endTransmission() != 0) { LOG_WARN("%s not found on I2C at 0x12", sensorName); -#ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* PMSA003I_I2C_CLOCK_SPEED */ sleep(); return false; } -#ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* PMSA003I_I2C_CLOCK_SPEED */ - status = 1; LOG_INFO("%s: Enabled", sensorName); sleep(); @@ -57,15 +52,13 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) } #ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.setClock(PMSA003I_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, PMSA003I_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, PMSA003I_I2C_CLOCK_SPEED); #endif /* PMSA003I_I2C_CLOCK_SPEED */ _bus->requestFrom(_address, (uint8_t)PMSA003I_FRAME_LENGTH); if (_bus->available() < PMSA003I_FRAME_LENGTH) { LOG_WARN("%s: read failed: incomplete data (%d bytes)", sensorName, _bus->available()); -#ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* PMSA003I_I2C_CLOCK_SPEED */ return false; } @@ -73,10 +66,6 @@ bool PMSA003ISensor::getMetrics(meshtastic_Telemetry *measurement) buffer[i] = _bus->read(); } -#ifdef PMSA003I_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* PMSA003I_I2C_CLOCK_SPEED */ - if (buffer[0] != 0x42 || buffer[1] != 0x4D) { LOG_WARN("%s: frame header invalid: 0x%02X 0x%02X", sensorName, buffer[0], buffer[1]); return false; diff --git a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp index c2631f5b0..d203837bb 100644 --- a/src/modules/Telemetry/Sensor/SCD30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD30Sensor.cpp @@ -18,16 +18,15 @@ bool SCD30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SCD30_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ scd30.begin(*_bus, _address); if (!startMeasurement()) { - LOG_ERROR("%s: Periodic measurement start failed", sensorName); -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Failed to start periodic measurement", sensorName); return false; } @@ -35,10 +34,6 @@ bool SCD30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) LOG_WARN("%s: Can't determine ASC state", sensorName); } -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ - if (state == SCD30_MEASUREMENT) { status = 1; } else { @@ -55,21 +50,15 @@ bool SCD30Sensor::getMetrics(meshtastic_Telemetry *measurement) float co2, temperature, humidity; #ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ if (scd30.readMeasurementData(co2, temperature, humidity) != SCD30_NO_ERROR) { - LOG_ERROR("%s: Measurement read failed", sensorName); -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Failed to read measurement data", sensorName); return false; } -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ - if (co2 == 0) { LOG_ERROR("%s: Invalid CO₂ reading", sensorName); return false; @@ -359,15 +348,12 @@ bool SCD30Sensor::isActive() uint32_t SCD30Sensor::wakeUp() { #ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ startMeasurement(); -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ - return 0; } @@ -378,14 +364,11 @@ uint32_t SCD30Sensor::wakeUp() void SCD30Sensor::sleep() { #ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ stopMeasurement(); - -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ } bool SCD30Sensor::canSleep() @@ -409,7 +392,8 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa AdminMessageHandleResult result; #ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD30_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD30_I2C_CLOCK_SPEED); #endif /* SCD30_I2C_CLOCK_SPEED */ switch (request->which_payload_variant) { @@ -462,10 +446,6 @@ AdminMessageHandleResult SCD30Sensor::handleAdminMessage(const meshtastic_MeshPa result = AdminMessageHandleResult::NOT_HANDLED; } -#ifdef SCD30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD30_I2C_CLOCK_SPEED */ - return result; } diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp index 261f8259b..92cb696e6 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp @@ -19,7 +19,8 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SCD4X_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ scd4x.begin(*_bus, _address); @@ -29,9 +30,6 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) // Stop periodic measurement if (!stopMeasurement()) { -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ return false; } @@ -41,35 +39,22 @@ bool SCD4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) if (sensorVariant == SCD4X_SENSOR_VARIANT_SCD41) { LOG_INFO("%s: Found SCD41", sensorName); if (!powerUp()) { - LOG_ERROR("%s: powerUp() failed", sensorName); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Error trying to execute powerUp()", sensorName); return false; } } if (!getASC(ascActive)) { - LOG_ERROR("%s: Can't check if ASC enabled", sensorName); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Unable to check if ASC is enabled", sensorName); return false; } // Start measurement in selected power mode (low power by default) if (!startMeasurement()) { - LOG_ERROR("%s: Can't start measurement", sensorName); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Couldn't start measurement", sensorName); return false; } -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - if (state == SCD4X_MEASUREMENT) { status = 1; } else { @@ -93,7 +78,8 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) float temperature, humidity; #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ bool dataReady = false; @@ -109,19 +95,12 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) } if (error != SCD4X_NO_ERROR || !dataReady) { -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ LOG_ERROR("SCD4X: Data is not ready"); return false; } error = scd4x.readMeasurement(co2, temperature, humidity); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - LOG_DEBUG("Got %s readings: co2=%u, co2_temp=%.2f, co2_hum%.2f", sensorName, co2, temperature, humidity); if (error != SCD4X_NO_ERROR) { LOG_DEBUG("%s: Error getting measurements: %u", sensorName, error); @@ -634,28 +613,19 @@ bool SCD4XSensor::powerDown() } #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ if (!stopMeasurement()) { -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ return false; } if (scd4x.powerDown() != SCD4X_NO_ERROR) { - LOG_ERROR("%s: sleep() failed", sensorName); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ + LOG_ERROR("%s: Error trying to execute sleep()", sensorName); return false; } -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - state = SCD4X_OFF; return true; } @@ -701,21 +671,15 @@ uint32_t SCD4XSensor::wakeUp() { #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ if (startMeasurement()) { co2MeasureStarted = millis(); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ return SCD4X_WARMUP_MS; } -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - return 0; } @@ -726,14 +690,11 @@ uint32_t SCD4XSensor::wakeUp() void SCD4XSensor::sleep() { #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ stopMeasurement(); - -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ } /** @@ -771,7 +732,8 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa AdminMessageHandleResult result; #ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SCD4X_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ // TODO: potentially add selftest command? @@ -837,10 +799,6 @@ AdminMessageHandleResult SCD4XSensor::handleAdminMessage(const meshtastic_MeshPa // Start measurement mode this->startMeasurement(); -#ifdef SCD4X_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SCD4X_I2C_CLOCK_SPEED */ - return result; } diff --git a/src/modules/Telemetry/Sensor/SENXXSensor.cpp b/src/modules/Telemetry/Sensor/SENXXSensor.cpp index 42b8a4ae3..d63d44850 100644 --- a/src/modules/Telemetry/Sensor/SENXXSensor.cpp +++ b/src/modules/Telemetry/Sensor/SENXXSensor.cpp @@ -174,6 +174,7 @@ bool SENXXSensor::probe(TwoWire *bus, uint8_t address, ScanI2C::I2CPort port) #ifdef SENXX_I2C_CLOCK_SPEED _port = port; reClockI2C.setup(_bus, _port); + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); #endif /* SENXX_I2C_CLOCK_SPEED */ if (!findModel()) { @@ -215,23 +216,12 @@ bool SENXXSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNum } } -#ifdef SENXX_I2C_CLOCK_SPEED - LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, SENXX_I2C_CLOCK_SPEED); - reClockI2C.setClock(SENXX_I2C_CLOCK_SPEED); -#endif /* SENXX_I2C_CLOCK_SPEED */ - - // Transmit the data // Note: this delay is necessary to allow for long-buffers delay(20); _bus->beginTransmission(_address); size_t writtenBytes = _bus->write(toSend, bufferSize); uint8_t i2c_error = _bus->endTransmission(); -#ifdef SENXX_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); - reClockI2C.restoreClock(); -#endif /* SENXX_I2C_CLOCK_SPEED */ - if (writtenBytes != bufferSize) { LOG_ERROR("%s: Error writing on I2C bus", sensorName); return false; @@ -246,18 +236,9 @@ bool SENXXSensor::sendCommand(uint16_t command, uint8_t *buffer, uint8_t byteNum uint8_t SENXXSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) { -#ifdef SENXX_I2C_CLOCK_SPEED - LOG_DEBUG("%s: Attempting to reclock speed to %uHz", sensorName, SENXX_I2C_CLOCK_SPEED); - reClockI2C.setClock(SENXX_I2C_CLOCK_SPEED); -#endif /* SENXX_I2C_CLOCK_SPEED */ - size_t readBytes = _bus->requestFrom(_address, byteNumber); if (readBytes != byteNumber) { LOG_ERROR("%s: Error reading I2C bus", sensorName); -#ifdef SENXX_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); - reClockI2C.restoreClock(); -#endif /* SENXX_I2C_CLOCK_SPEED */ return 0; } @@ -270,21 +251,12 @@ uint8_t SENXXSensor::readBuffer(uint8_t *buffer, uint8_t byteNumber) uint8_t calcCRC = senxxCRC(&buffer[i - 2]); if (recvCRC != calcCRC) { LOG_ERROR("%s: Checksum error while receiving msg", sensorName); -#ifdef SENXX_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); - reClockI2C.restoreClock(); -#endif /* SENXX_I2C_CLOCK_SPEED */ return 0; } readBytes -= 3; receivedBytes += 2; } -#ifdef SENXX_I2C_CLOCK_SPEED - LOG_DEBUG("%s: restoring clock speed", sensorName); - reClockI2C.restoreClock(); -#endif /* SENXX_I2C_CLOCK_SPEED */ - return receivedBytes; } @@ -320,6 +292,9 @@ void SENXXSensor::sleep() LOG_INFO("%s: Not going to sleep, fan cleaning is in progress", sensorName); return; } +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ idle(true); } @@ -690,6 +665,14 @@ void SENXXSensor::reconcileTimeDependentState(uint32_t now) } uint32_t SENXXSensor::wakeUp() +{ +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + return wakeUpInternal(); +} + +uint32_t SENXXSensor::wakeUpInternal() { LOG_DEBUG("%s: Waking up sensor", sensorName); @@ -789,6 +772,7 @@ bool SENXXSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SENXX_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); #endif /* SENXX_I2C_CLOCK_SPEED */ delay(50); // without this there is an error on the deviceReset function @@ -1141,6 +1125,12 @@ int32_t SENXXSensor::wakeUpTimeMs() int32_t SENXXSensor::pendingForReadyMs() { +#ifdef SENXX_I2C_CLOCK_SPEED + // Only the SENXX_MEASUREMENT/SENXX_CLEANING branches below touch I2C, but this is only + // ever called while isActive() (i.e. one of those, or SENXX_MEASUREMENT_2, which doesn't), + // so bracketing unconditionally here is simpler than guarding each branch separately. + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ uint32_t now = millis(); uint32_t sincePmMeasureStarted = now - pmMeasureStarted; LOG_DEBUG("%s: Since measure started: %ums", sensorName, sincePmMeasureStarted); @@ -1203,6 +1193,10 @@ bool SENXXSensor::getMetrics(meshtastic_Telemetry *measurement) return false; } +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ + uint8_t response; response = getMeasurements(); @@ -1525,6 +1519,9 @@ AdminMessageHandleResult SENXXSensor::handleAdminMessage(const meshtastic_MeshPa switch (request->which_payload_variant) { case meshtastic_AdminMessage_sensor_config_tag: { +#ifdef SENXX_I2C_CLOCK_SPEED + ReClockI2CGuard clockGuard(reClockI2C, SENXX_I2C_CLOCK_SPEED); +#endif /* SENXX_I2C_CLOCK_SPEED */ bool ok = true; bool wasActive = isActive(); @@ -1582,7 +1579,9 @@ AdminMessageHandleResult SENXXSensor::handleAdminMessage(const meshtastic_MeshPa } if (wasActive) { - this->wakeUp(); + // Not this->wakeUp() - we're already inside this function's own + // ReClockI2CGuard, and that guard isn't reentrant (see its comment). + this->wakeUpInternal(); } } } else { diff --git a/src/modules/Telemetry/Sensor/SENXXSensor.h b/src/modules/Telemetry/Sensor/SENXXSensor.h index 1bc80efd8..94c1930b3 100644 --- a/src/modules/Telemetry/Sensor/SENXXSensor.h +++ b/src/modules/Telemetry/Sensor/SENXXSensor.h @@ -255,6 +255,11 @@ class SENXXSensor : public TelemetrySensor, public CO2CalibrationSensor bool readPNValues(bool cumulative); bool readValues(); + // Actual wakeUp() logic, factored out so handleAdminMessage() can resume + // measurement after a calibration pause without nesting a second I2C-clock + // guard inside its own (see ReClockI2CGuard's reentrancy note). + uint32_t wakeUpInternal(); + // Monotonic (millis()) timers for warmup/poll intervals. Deliberately not // wall-clock (getTime()) based: getTime() can jump discontinuously the moment the RTC // quality improves mid-session (see checkRTCQualityImproved()), which would corrupt diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp index 90671e229..743130d48 100644 --- a/src/modules/Telemetry/Sensor/SFA30Sensor.cpp +++ b/src/modules/Telemetry/Sensor/SFA30Sensor.cpp @@ -17,33 +17,24 @@ bool SFA30Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) #ifdef SFA30_I2C_CLOCK_SPEED _port = dev->address.port; reClockI2C.setup(_bus, _port); - reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); + LOG_INFO("%s: reclock speed %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ sfa30.begin(*_bus, _address); delay(20); if (this->isError(sfa30.deviceReset())) { -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ return false; } state = State::IDLE; if (this->isError(sfa30.startContinuousMeasurement())) { -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ return false; } LOG_INFO("%s starting measurement", sensorName); -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ - status = 1; state = State::ACTIVE; measureStarted = millis(); @@ -66,7 +57,8 @@ bool SFA30Sensor::isError(uint16_t response) void SFA30Sensor::sleep() { #ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ // Note - not recommended for this sensor on a periodic basis @@ -74,10 +66,6 @@ void SFA30Sensor::sleep() LOG_ERROR("%s: Can't stop measurement", sensorName); }; -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ - LOG_DEBUG("%s: stop measurement", sensorName); state = State::IDLE; measureStarted = 0; @@ -86,21 +74,15 @@ void SFA30Sensor::sleep() uint32_t SFA30Sensor::wakeUp() { #ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ LOG_DEBUG("Waking %s", sensorName); if (this->isError(sfa30.startContinuousMeasurement())) { -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ return 0; } -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ - state = State::ACTIVE; measureStarted = millis(); return SFA30_WARMUP_MS; @@ -142,21 +124,15 @@ bool SFA30Sensor::getMetrics(meshtastic_Telemetry *measurement) float temperature = 0.0; #ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.setClock(SFA30_I2C_CLOCK_SPEED); + LOG_DEBUG("%s: reclock speed %uHz", sensorName, SFA30_I2C_CLOCK_SPEED); + ReClockI2CGuard clockGuard(reClockI2C, SFA30_I2C_CLOCK_SPEED); #endif /* SFA30_I2C_CLOCK_SPEED */ if (this->isError(sfa30.readMeasuredValues(hcho, humidity, temperature))) { LOG_WARN("%s: No values", sensorName); -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ return false; } -#ifdef SFA30_I2C_CLOCK_SPEED - reClockI2C.restoreClock(); -#endif /* SFA30_I2C_CLOCK_SPEED */ - measurement->variant.air_quality_metrics.has_form_temperature = true; measurement->variant.air_quality_metrics.has_form_humidity = true; measurement->variant.air_quality_metrics.has_form_formaldehyde = true; From 3a7c49972209c7b9adbda245b0d3c1d8337fd8b1 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Sun, 16 Aug 2026 20:33:21 -0500 Subject: [PATCH 075/109] fix(mesh): dedup opaque relays to prevent an undecryptable-frame broadcast storm (#11522) * NextHopRouter: dedup opaque relays to prevent a broadcast storm Undecryptable ("opaque") frames are relayed by relayOpaquePacket(), which by design never enters PacketHistory - so unauthenticated frames can't poison next-hop learning or ACK matching (packet-authenticity policy, d6b12ea3f). But PacketHistory admission was also the *only* deduplication on that path. With none, a dense mesh re-relays every copy of every opaque frame and the copy count multiplies at each hop into an unbounded broadcast storm; "let hop exhaustion bound it" caps depth, not count. Add a small, isolated (from,id) seen-set checked in relayOpaquePacket() before rebroadcast: a second PacketHistory-style table (fixed 32-slot ring, round-robin eviction) that only suppresses duplicate opaque rebroadcasts and never feeds routing/ACK/next-hop, preserving the security property. Genuine originator (re)transmissions (hop_start == hop_limit) are still relayed so reliable opaque unicast propagates (mirrors FloodingRouter's isRepeated). Observed on a mixed-channel mesh: one node relayed a single undecryptable broadcast 23x (every overheard copy) with TX queues saturated, while decodable traffic on the same node deduped normally. Co-Authored-By: Claude Opus 4.8 * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Change log level from WARN to TRACE for duplicates --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mesh/NextHopRouter.cpp | 30 ++++++++++++++++++++++++++++++ src/mesh/NextHopRouter.h | 20 ++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 5b4511120..c86f35ec7 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -37,6 +37,18 @@ bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p) (p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum()))) return false; + // Dedup opaque relays. Opaque frames deliberately never enter PacketHistory (so unauthenticated + // traffic can't influence routing/ACK/next-hop) - but with NO dedup at all, a dense mesh re-relays + // every copy of every frame, multiplying at each hop into an unbounded broadcast storm ("let hop + // exhaustion bound it" caps depth, not count). Suppress duplicate opaque rebroadcasts with a small, + // routing-isolated seen-set. Genuine originator (re)transmissions (hop_start == hop_limit) are + // always relayed so reliable opaque unicast still propagates (mirrors FloodingRouter's isRepeated). + const bool isOriginatorTx = p->hop_start > 0 && p->hop_start == p->hop_limit; + if (opaqueWasSeenRecently(getFrom(p), p->id) && !isOriginatorTx) { + LOG_TRACE("Drop duplicate opaque relay from 0x%08x id 0x%08x", getFrom(p), p->id); + return false; + } + meshtastic_MeshPacket *relay = packetPool.allocCopy(*p); if (!relay) return false; @@ -53,6 +65,24 @@ bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p) return res == ERRNO_OK; } +// Isolated dedup for opaque relays (see relayOpaquePacket). Returns true if (from,id) is already in the +// ring; otherwise records it (round-robin eviction) and returns false. A separate table from +// PacketHistory on purpose: opaque frames must never influence routing/ACK/next-hop. No timestamps - +// a stale (from,id) can't false-match a later packet because ids are effectively random. +bool NextHopRouter::opaqueWasSeenRecently(NodeNum from, PacketId id) +{ + for (uint8_t i = 0; i < OPAQUE_SEEN_MAX; i++) { + if (opaqueSeen[i].sender == from && opaqueSeen[i].id == id) + return true; + } + // Not seen: record it, overwriting the oldest-written slot (FIFO). Empty slots hold id 0, which a + // real entry never has (relayOpaquePacket drops id 0), so they simply never match above. + opaqueSeen[opaqueSeenNext].sender = from; + opaqueSeen[opaqueSeenNext].id = id; + opaqueSeenNext = (uint8_t)((opaqueSeenNext + 1) % OPAQUE_SEEN_MAX); + return false; +} + PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions) { packet = p; diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 26cda830a..0d6d971fc 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -124,6 +124,8 @@ class NextHopRouter : public FloodingRouter constexpr static uint32_t ROUTE_TTL_MSEC = 30UL * 60 * 1000; // re-discover a route unconfirmed for 30 min constexpr static uint8_t ROUTE_FAILURE_THRESHOLD = 3; // consecutive un-ACKed directed deliveries -> dead + constexpr static uint8_t OPAQUE_SEEN_MAX = 32; // opaque-relay dedup slots (see relayOpaquePacket); ~8B/slot -> ~256B + protected: /** * Pending retransmissions @@ -135,6 +137,21 @@ class NextHopRouter : public FloodingRouter */ RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {}; + /** + * Recently-seen opaque (undecryptable) frames, keyed on the outer (from,id) header. A second, + * isolated PacketHistory-style dedup: it bounds broadcast amplification of frames we can't decrypt + * WITHOUT admitting them to the real PacketHistory/NodeDB, so unauthenticated traffic can never + * influence routing / ACK / next-hop decisions. Fixed-size ring, round-robin (FIFO) eviction, no + * timestamps (a stale (from,id) can't false-match: packet ids are effectively random, and a real + * entry never has id 0 - relayOpaquePacket drops id 0 before this). RAM-only. + */ + struct OpaqueSeen { + NodeNum sender = 0; + PacketId id = 0; // 0 == empty/unused slot + }; + OpaqueSeen opaqueSeen[OPAQUE_SEEN_MAX] = {}; + uint8_t opaqueSeenNext = 0; // ring write cursor (round-robin eviction) + /** * Should this incoming filter be dropped? * @@ -143,6 +160,9 @@ class NextHopRouter : public FloodingRouter */ virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override; bool relayOpaquePacket(const meshtastic_MeshPacket *p) override; + // Dedup helper for relayOpaquePacket: true if (from,id) is already recorded; otherwise records it + // (round-robin eviction) and returns false. Pure function of the table - no clock. + bool opaqueWasSeenRecently(NodeNum from, PacketId id); /** * Look for packets we need to relay From 5dffd22584035ada16f2d90ce435e8296d5449fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:41:17 +0200 Subject: [PATCH 076/109] Update protobufs and classes (#11531) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/admin.pb.cpp | 3 ++ src/mesh/generated/meshtastic/admin.pb.h | 33 ++++++++++++-- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- .../generated/meshtastic/telemetry.pb.cpp | 3 ++ src/mesh/generated/meshtastic/telemetry.pb.h | 45 ++++++++++++++++--- 6 files changed, 75 insertions(+), 13 deletions(-) diff --git a/protobufs b/protobufs index 84bfb0fdb..c9cb9ef6e 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 84bfb0fdb3b853ea18abc4535497fa41a1b09546 +Subproject commit c9cb9ef6ee0dd579fbe9424e232484392637e11e diff --git a/src/mesh/generated/meshtastic/admin.pb.cpp b/src/mesh/generated/meshtastic/admin.pb.cpp index d029daf31..42d7fa9a2 100644 --- a/src/mesh/generated/meshtastic/admin.pb.cpp +++ b/src/mesh/generated/meshtastic/admin.pb.cpp @@ -51,6 +51,9 @@ PB_BIND(meshtastic_SHTXX_config, meshtastic_SHTXX_config, AUTO) PB_BIND(meshtastic_DS248X_config, meshtastic_DS248X_config, AUTO) +PB_BIND(meshtastic_AS3935_config, meshtastic_AS3935_config, AUTO) + + diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index 9d73b8508..ccf6f54c8 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -368,6 +368,13 @@ typedef struct _meshtastic_DS248X_config { uint32_t main_temperature_channel; } meshtastic_DS248X_config; +typedef struct _meshtastic_AS3935_config { + /* Antenna tuning capacitance in pF, 0 to 120 in steps of 8. The antenna tank must + resonate within 3.5% of 500kHz; the correct trim is specific to the sensor board. */ + bool has_set_tuning_cap_pf; + uint32_t set_tuning_cap_pf; +} meshtastic_AS3935_config; + typedef struct _meshtastic_SensorConfig { /* SCD4X CO2 Sensor configuration */ bool has_scd4x_config; @@ -387,6 +394,9 @@ typedef struct _meshtastic_SensorConfig { /* SEN6X PM/RHT/VOC/NOx/CO2/HCHO Sensor configuration */ bool has_sen6x_config; meshtastic_SEN6X_config sen6x_config; + /* AS3935 lightning sensor configuration */ + bool has_as3935_config; + meshtastic_AS3935_config as3935_config; } meshtastic_SensorConfig; typedef PB_BYTES_ARRAY_T(8) meshtastic_AdminMessage_session_passkey_t; @@ -588,6 +598,7 @@ extern "C" { + /* Initializer values for message structs */ #define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0} @@ -597,13 +608,14 @@ extern "C" { #define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}} #define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0} #define meshtastic_KeyVerificationAdmin_init_default {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0} -#define meshtastic_SensorConfig_init_default {false, meshtastic_SCD4X_config_init_default, false, meshtastic_SEN5X_config_init_default, false, meshtastic_SCD30_config_init_default, false, meshtastic_SHTXX_config_init_default, false, meshtastic_DS248X_config_init_default, false, meshtastic_SEN6X_config_init_default} +#define meshtastic_SensorConfig_init_default {false, meshtastic_SCD4X_config_init_default, false, meshtastic_SEN5X_config_init_default, false, meshtastic_SCD30_config_init_default, false, meshtastic_SHTXX_config_init_default, false, meshtastic_DS248X_config_init_default, false, meshtastic_SEN6X_config_init_default, false, meshtastic_AS3935_config_init_default} #define meshtastic_SCD4X_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN5X_config_init_default {false, 0, false, 0, false, 0} #define meshtastic_SEN6X_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SCD30_config_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SHTXX_config_init_default {false, 0} #define meshtastic_DS248X_config_init_default {false, 0} +#define meshtastic_AS3935_config_init_default {false, 0} #define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}} #define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0} #define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}} @@ -612,13 +624,14 @@ extern "C" { #define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}} #define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0} #define meshtastic_KeyVerificationAdmin_init_zero {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0} -#define meshtastic_SensorConfig_init_zero {false, meshtastic_SCD4X_config_init_zero, false, meshtastic_SEN5X_config_init_zero, false, meshtastic_SCD30_config_init_zero, false, meshtastic_SHTXX_config_init_zero, false, meshtastic_DS248X_config_init_zero, false, meshtastic_SEN6X_config_init_zero} +#define meshtastic_SensorConfig_init_zero {false, meshtastic_SCD4X_config_init_zero, false, meshtastic_SEN5X_config_init_zero, false, meshtastic_SCD30_config_init_zero, false, meshtastic_SHTXX_config_init_zero, false, meshtastic_DS248X_config_init_zero, false, meshtastic_SEN6X_config_init_zero, false, meshtastic_AS3935_config_init_zero} #define meshtastic_SCD4X_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN5X_config_init_zero {false, 0, false, 0, false, 0} #define meshtastic_SEN6X_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SCD30_config_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_SHTXX_config_init_zero {false, 0} #define meshtastic_DS248X_config_init_zero {false, 0} +#define meshtastic_AS3935_config_init_zero {false, 0} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_AdminMessage_InputEvent_event_code_tag 1 @@ -673,12 +686,14 @@ extern "C" { #define meshtastic_SCD30_config_soft_reset_tag 6 #define meshtastic_SHTXX_config_set_accuracy_tag 1 #define meshtastic_DS248X_config_main_temperature_channel_tag 1 +#define meshtastic_AS3935_config_set_tuning_cap_pf_tag 1 #define meshtastic_SensorConfig_scd4x_config_tag 1 #define meshtastic_SensorConfig_sen5x_config_tag 2 #define meshtastic_SensorConfig_scd30_config_tag 3 #define meshtastic_SensorConfig_shtxx_config_tag 4 #define meshtastic_SensorConfig_ds248x_config_tag 5 #define meshtastic_SensorConfig_sen6x_config_tag 6 +#define meshtastic_SensorConfig_as3935_config_tag 7 #define meshtastic_AdminMessage_get_channel_request_tag 1 #define meshtastic_AdminMessage_get_channel_response_tag 2 #define meshtastic_AdminMessage_get_owner_request_tag 3 @@ -886,7 +901,8 @@ X(a, STATIC, OPTIONAL, MESSAGE, sen5x_config, 2) \ X(a, STATIC, OPTIONAL, MESSAGE, scd30_config, 3) \ X(a, STATIC, OPTIONAL, MESSAGE, shtxx_config, 4) \ X(a, STATIC, OPTIONAL, MESSAGE, ds248x_config, 5) \ -X(a, STATIC, OPTIONAL, MESSAGE, sen6x_config, 6) +X(a, STATIC, OPTIONAL, MESSAGE, sen6x_config, 6) \ +X(a, STATIC, OPTIONAL, MESSAGE, as3935_config, 7) #define meshtastic_SensorConfig_CALLBACK NULL #define meshtastic_SensorConfig_DEFAULT NULL #define meshtastic_SensorConfig_scd4x_config_MSGTYPE meshtastic_SCD4X_config @@ -895,6 +911,7 @@ X(a, STATIC, OPTIONAL, MESSAGE, sen6x_config, 6) #define meshtastic_SensorConfig_shtxx_config_MSGTYPE meshtastic_SHTXX_config #define meshtastic_SensorConfig_ds248x_config_MSGTYPE meshtastic_DS248X_config #define meshtastic_SensorConfig_sen6x_config_MSGTYPE meshtastic_SEN6X_config +#define meshtastic_SensorConfig_as3935_config_MSGTYPE meshtastic_AS3935_config #define meshtastic_SCD4X_config_FIELDLIST(X, a) \ X(a, STATIC, OPTIONAL, BOOL, set_asc, 1) \ @@ -946,6 +963,11 @@ X(a, STATIC, OPTIONAL, UINT32, main_temperature_channel, 1) #define meshtastic_DS248X_config_CALLBACK NULL #define meshtastic_DS248X_config_DEFAULT NULL +#define meshtastic_AS3935_config_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, UINT32, set_tuning_cap_pf, 1) +#define meshtastic_AS3935_config_CALLBACK NULL +#define meshtastic_AS3935_config_DEFAULT NULL + extern const pb_msgdesc_t meshtastic_AdminMessage_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_InputEvent_msg; extern const pb_msgdesc_t meshtastic_AdminMessage_OTAEvent_msg; @@ -961,6 +983,7 @@ extern const pb_msgdesc_t meshtastic_SEN6X_config_msg; extern const pb_msgdesc_t meshtastic_SCD30_config_msg; extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; extern const pb_msgdesc_t meshtastic_DS248X_config_msg; +extern const pb_msgdesc_t meshtastic_AS3935_config_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_AdminMessage_fields &meshtastic_AdminMessage_msg @@ -978,9 +1001,11 @@ extern const pb_msgdesc_t meshtastic_DS248X_config_msg; #define meshtastic_SCD30_config_fields &meshtastic_SCD30_config_msg #define meshtastic_SHTXX_config_fields &meshtastic_SHTXX_config_msg #define meshtastic_DS248X_config_fields &meshtastic_DS248X_config_msg +#define meshtastic_AS3935_config_fields &meshtastic_AS3935_config_msg /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_ADMIN_PB_H_MAX_SIZE meshtastic_AdminMessage_size +#define meshtastic_AS3935_config_size 6 #define meshtastic_AdminMessage_InputEvent_size 14 #define meshtastic_AdminMessage_OTAEvent_size 36 #define meshtastic_AdminMessage_size 511 @@ -994,7 +1019,7 @@ extern const pb_msgdesc_t meshtastic_DS248X_config_msg; #define meshtastic_SEN5X_config_size 9 #define meshtastic_SEN6X_config_size 31 #define meshtastic_SHTXX_config_size 6 -#define meshtastic_SensorConfig_size 120 +#define meshtastic_SensorConfig_size 128 #define meshtastic_SharedContact_size 127 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index a4757b5ca..51e43526e 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -458,7 +458,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; #define meshtastic_BackupPreferences_size 2740 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 -#define meshtastic_NodeEnvironmentEntry_size 218 +#define meshtastic_NodeEnvironmentEntry_size 231 #define meshtastic_NodeInfoLite_size 112 #define meshtastic_NodePositionEntry_size 42 #define meshtastic_NodeStatusEntry_size 89 diff --git a/src/mesh/generated/meshtastic/telemetry.pb.cpp b/src/mesh/generated/meshtastic/telemetry.pb.cpp index 64cc0422f..aa095b1a2 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.cpp +++ b/src/mesh/generated/meshtastic/telemetry.pb.cpp @@ -36,6 +36,9 @@ PB_BIND(meshtastic_Telemetry, meshtastic_Telemetry, 2) PB_BIND(meshtastic_Nau7802Config, meshtastic_Nau7802Config, AUTO) +PB_BIND(meshtastic_AS3935Config, meshtastic_AS3935Config, AUTO) + + PB_BIND(meshtastic_SEN5XState, meshtastic_SEN5XState, AUTO) diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index 8c0168843..bfac3b038 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -125,7 +125,9 @@ typedef enum _meshtastic_TelemetrySensorType { /* HM330X PM SENSOR */ meshtastic_TelemetrySensorType_HM330X = 55, /* Sensirion SEN6X PM/RHT/VOC/NOx/CO2/HCHO sensor family (SEN62, SEN63C, SEN65, SEN66, SEN68, SEN69C) */ - meshtastic_TelemetrySensorType_SEN6X = 56 + meshtastic_TelemetrySensorType_SEN6X = 56, + /* AS3935 Franklin lightning sensor */ + meshtastic_TelemetrySensorType_AS3935 = 57 } meshtastic_TelemetrySensorType; /* Struct definitions */ @@ -266,6 +268,12 @@ typedef struct _meshtastic_EnvironmentMetrics { /* Multi-channel One-Wire Temperature Channel 7 (*C) */ bool has_one_wire_temperature_ch7; float one_wire_temperature_ch7; + /* Lightning strikes detected in the last hour */ + bool has_lightning_strike_count_1h; + uint32_t lightning_strike_count_1h; + /* Estimated distance to the leading edge of the storm, in km */ + bool has_lightning_distance_km; + float lightning_distance_km; } meshtastic_EnvironmentMetrics; /* Power Metrics (voltage / current / etc) */ @@ -531,6 +539,13 @@ typedef struct _meshtastic_Nau7802Config { float calibrationFactor; } meshtastic_Nau7802Config; +/* AS3935 lightning sensor configuration, for saving to flash */ +typedef struct _meshtastic_AS3935Config { + /* Antenna tuning capacitance in pF, 0 to 120 in steps of 8. The chip does not retain + this across power loss, so it is stored here and re-applied on every boot. */ + uint32_t tuning_cap_pf; +} meshtastic_AS3935Config; + /* SEN5X State, for saving to flash (to be merged with SEN6XState) */ typedef struct _meshtastic_SEN5XState { /* Last cleaning time for SEN5X */ @@ -576,8 +591,9 @@ extern "C" { /* Helper constants for enums */ #define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET -#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_SEN6X -#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_SEN6X+1)) +#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_AS3935 +#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_AS3935+1)) + @@ -594,7 +610,7 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_DeviceMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -603,10 +619,11 @@ extern "C" { #define meshtastic_HostMetrics_init_default {0, 0, 0, false, 0, false, 0, 0, 0, 0, false, ""} #define meshtastic_Telemetry_init_default {0, 0, {meshtastic_DeviceMetrics_init_default}} #define meshtastic_Nau7802Config_init_default {0, 0} +#define meshtastic_AS3935Config_init_default {0} #define meshtastic_SEN5XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN6XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_DeviceMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_PowerMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -615,6 +632,7 @@ extern "C" { #define meshtastic_HostMetrics_init_zero {0, 0, 0, false, 0, false, 0, 0, 0, 0, false, ""} #define meshtastic_Telemetry_init_zero {0, 0, {meshtastic_DeviceMetrics_init_zero}} #define meshtastic_Nau7802Config_init_zero {0, 0} +#define meshtastic_AS3935Config_init_zero {0} #define meshtastic_SEN5XState_init_zero {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_SEN6XState_init_zero {0, 0, 0, false, 0, false, 0, false, 0} @@ -662,6 +680,8 @@ extern "C" { #define meshtastic_EnvironmentMetrics_one_wire_temperature_ch5_tag 37 #define meshtastic_EnvironmentMetrics_one_wire_temperature_ch6_tag 38 #define meshtastic_EnvironmentMetrics_one_wire_temperature_ch7_tag 39 +#define meshtastic_EnvironmentMetrics_lightning_strike_count_1h_tag 40 +#define meshtastic_EnvironmentMetrics_lightning_distance_km_tag 41 #define meshtastic_PowerMetrics_ch1_voltage_tag 1 #define meshtastic_PowerMetrics_ch1_current_tag 2 #define meshtastic_PowerMetrics_ch2_voltage_tag 3 @@ -749,6 +769,7 @@ extern "C" { #define meshtastic_Telemetry_traffic_management_stats_tag 9 #define meshtastic_Nau7802Config_zeroOffset_tag 1 #define meshtastic_Nau7802Config_calibrationFactor_tag 2 +#define meshtastic_AS3935Config_tuning_cap_pf_tag 1 #define meshtastic_SEN5XState_last_cleaning_time_tag 1 #define meshtastic_SEN5XState_last_cleaning_valid_tag 2 #define meshtastic_SEN5XState_one_shot_mode_tag 3 @@ -810,7 +831,9 @@ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch3, 35) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch4, 36) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch5, 37) \ X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch6, 38) \ -X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch7, 39) +X(a, STATIC, OPTIONAL, FLOAT, one_wire_temperature_ch7, 39) \ +X(a, STATIC, OPTIONAL, UINT32, lightning_strike_count_1h, 40) \ +X(a, STATIC, OPTIONAL, FLOAT, lightning_distance_km, 41) #define meshtastic_EnvironmentMetrics_CALLBACK NULL #define meshtastic_EnvironmentMetrics_DEFAULT NULL @@ -941,6 +964,11 @@ X(a, STATIC, SINGULAR, FLOAT, calibrationFactor, 2) #define meshtastic_Nau7802Config_CALLBACK NULL #define meshtastic_Nau7802Config_DEFAULT NULL +#define meshtastic_AS3935Config_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, tuning_cap_pf, 1) +#define meshtastic_AS3935Config_CALLBACK NULL +#define meshtastic_AS3935Config_DEFAULT NULL + #define meshtastic_SEN5XState_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, last_cleaning_time, 1) \ X(a, STATIC, SINGULAR, BOOL, last_cleaning_valid, 2) \ @@ -971,6 +999,7 @@ extern const pb_msgdesc_t meshtastic_HealthMetrics_msg; extern const pb_msgdesc_t meshtastic_HostMetrics_msg; extern const pb_msgdesc_t meshtastic_Telemetry_msg; extern const pb_msgdesc_t meshtastic_Nau7802Config_msg; +extern const pb_msgdesc_t meshtastic_AS3935Config_msg; extern const pb_msgdesc_t meshtastic_SEN5XState_msg; extern const pb_msgdesc_t meshtastic_SEN6XState_msg; @@ -985,14 +1014,16 @@ extern const pb_msgdesc_t meshtastic_SEN6XState_msg; #define meshtastic_HostMetrics_fields &meshtastic_HostMetrics_msg #define meshtastic_Telemetry_fields &meshtastic_Telemetry_msg #define meshtastic_Nau7802Config_fields &meshtastic_Nau7802Config_msg +#define meshtastic_AS3935Config_fields &meshtastic_AS3935Config_msg #define meshtastic_SEN5XState_fields &meshtastic_SEN5XState_msg #define meshtastic_SEN6XState_fields &meshtastic_SEN6XState_msg /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_TELEMETRY_PB_H_MAX_SIZE meshtastic_Telemetry_size +#define meshtastic_AS3935Config_size 6 #define meshtastic_AirQualityMetrics_size 157 #define meshtastic_DeviceMetrics_size 27 -#define meshtastic_EnvironmentMetrics_size 209 +#define meshtastic_EnvironmentMetrics_size 222 #define meshtastic_HealthMetrics_size 11 #define meshtastic_HostMetrics_size 264 #define meshtastic_LocalStats_size 87 From ef2be877a5c2b7feb93a3c24d29c175661e924d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 17 Aug 2026 17:04:31 +0000 Subject: [PATCH 077/109] Stop breaking TestUtil.cpp on Windows, dangit! (#11529) * Stop breaking TestUtil.cpp on Windows, dangit! * Update test/TestUtil.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- test/TestUtil.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/TestUtil.cpp b/test/TestUtil.cpp index 9f36ee17a..2855cc9c7 100644 --- a/test/TestUtil.cpp +++ b/test/TestUtil.cpp @@ -40,6 +40,11 @@ // // Listening sockets only: an outbound connection is a different (and louder) problem, and gethostby* // opens transient sockets that would make an any-socket check flap. +// Linux-only: this check reads /proc; MinGW-w64 has no readlink() for fd links. +// Linux CI covers the check; native Windows uses a no-op. +#ifdef _WIN32 +static void assertNoListeningSockets() {} +#else static void assertNoListeningSockets() { // Socket fds appear as "socket:[inode]"; a listening TCP row in /proc/self/net carries st 0A. @@ -102,6 +107,7 @@ static void assertNoListeningSockets() exit(EXIT_FAILURE); } #endif +#endif #if ARCH_PORTDUINO static bool environmentBaselined = false; From c308d0aca4b8de4034cb19b591d0d2c3f13d950b Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:28:33 +0000 Subject: [PATCH 078/109] feat: Support Elecrow ThinkNode M9 (#10908) * thinknode-m9 variant * move lora to SPI1 device * enable SDcard * use HSPI * BaseUI tft -> HSPI * buzzer, webdav lib * fix build issues * M9 default to MUI, no BT, short ringtone * add keyboard long-press config * update variant * add ThingNode-M9 GPS string * GPS 115200 baud * Basic BaseUI support * Fixup power detection * Compass and KB fixes for M9 * add timed Lock::lock() * add SD card * point device-ui to thinknode m9 draft branch * trunk fmt * fix FusionCompass * Fix t-deck-tft linker arg list overflow in CI * SDcard/lora fix: SPI1 must not be declared twice in arduino 3.x -> reuse SPI1 defined in FSCommon.cpp * update battery parameters * reinit SD card when updating; fix PSRAM size * update lib versions * fix wakeup on key press (KB_INT) * fix default nag_timeout for TFT/MUI devices with buzzer * increase PSRAM and SD freq * trunk fmt * update lovyanGFX 1.2.26 * update device-ui commit reference * fix screen definition * remove DONE; maybe a keyword or other used identifier * fixed CI error nag_timeout * fix prepareSleep initialization * trunk fmt * reduce SD SPI frequency * update device-ui * fix SDcard issue * stage * fix device-ui commit reference * fix device-ui commit * update device-ui commit (fixed keyboard lag) * fix QMI8658 * trunk fmt * update .ini meta information, align SD freq * fix device-ui reference to target (ready to merge) * device-ui for all other targets * make the rabbit happy * trunk fmt * fixed lock screen * fix compile error * SPI lock timeout * apply device-ui fix * revert bad RadioLib commit hash in platformio.ini Co-authored-by: mverch67 <71137295+mverch67@users.noreply.github.com> * fix wrong commit hash change * fix fix commit fix * I love changing random numbers in random files --------- Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- platformio.ini | 2 +- src/PowerFSM.cpp | 24 +-- src/concurrency/Lock.cpp | 10 ++ src/concurrency/Lock.h | 5 + src/configuration.h | 2 + src/detect/ScanI2C.cpp | 14 +- src/detect/ScanI2C.h | 2 + src/detect/ScanI2CTwoWire.cpp | 15 ++ src/gps/GPS.cpp | 8 +- src/graphics/draw/UIRenderer.cpp | 23 +++ src/graphics/tftSetup.cpp | 29 +++- src/input/STC8HKeyboard.cpp | 155 ++++++++++++++++++ src/input/STC8HKeyboard.h | 74 +++++++++ src/input/cardKbI2cImpl.cpp | 4 + src/input/kbI2cBase.cpp | 114 +++++++++++++ src/main.cpp | 4 + src/mesh/NodeDB.cpp | 14 +- src/motion/AccelerometerThread.h | 6 + src/motion/MagnetometerThread.h | 6 + src/motion/MotionSensor.cpp | 32 ++++ src/motion/MotionSensor.h | 7 +- src/motion/QMC6309Sensor.cpp | 152 +++++++++++++++++ src/motion/QMC6309Sensor.h | 40 +++++ src/motion/QMI8658Sensor.cpp | 88 ++++++++++ src/motion/QMI8658Sensor.h | 25 +++ src/sleep.cpp | 6 +- .../ELECROW-ThinkNode-M9/pins_arduino.h | 20 +++ .../ELECROW-ThinkNode-M9/platformio.ini | 99 +++++++++++ .../esp32s3/ELECROW-ThinkNode-M9/rfswitch.h | 11 ++ .../esp32s3/ELECROW-ThinkNode-M9/variant.cpp | 34 ++++ .../esp32s3/ELECROW-ThinkNode-M9/variant.h | 107 ++++++++++++ variants/esp32s3/t-deck/platformio.ini | 4 + 32 files changed, 1098 insertions(+), 38 deletions(-) create mode 100644 src/input/STC8HKeyboard.cpp create mode 100644 src/input/STC8HKeyboard.h create mode 100644 src/motion/QMC6309Sensor.cpp create mode 100644 src/motion/QMC6309Sensor.h create mode 100644 src/motion/QMI8658Sensor.cpp create mode 100644 src/motion/QMI8658Sensor.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp create mode 100644 variants/esp32s3/ELECROW-ThinkNode-M9/variant.h diff --git a/platformio.ini b/platformio.ini index 6e0d20d46..a1dd0d310 100644 --- a/platformio.ini +++ b/platformio.ini @@ -137,7 +137,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/e1de01e0b3c4a6b149c00e95d59cfb0cca7ad49e.zip + https://github.com/meshtastic/device-ui/archive/adfbd3811a53b6aed0649c8d8f078118c042a407.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp index 268cb8211..2ef5b2ea1 100644 --- a/src/PowerFSM.cpp +++ b/src/PowerFSM.cpp @@ -173,23 +173,25 @@ static void lsIdle() powerFSM.trigger(EVENT_SERIAL_CONNECTED); break; - default: - // We woke for some other reason (button press, device IRQ interrupt) - -#ifdef BUTTON_PIN - bool pressed = !digitalRead(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN); -#else + case ESP_SLEEP_WAKEUP_GPIO: { bool pressed = false; +#if defined(BUTTON_PIN) + pressed = !digitalRead(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN); +#elif defined(KB_INT) + // keyboard press (probably) triggered GPIO interrupt + pressed = true; #endif - if (pressed) { // If we woke because of press, instead generate a PRESS event. + if (pressed) { powerFSM.trigger(EVENT_PRESS); - } else { - // Otherwise let the NB state handle the IRQ (and that state will handle stuff like IRQs etc) - // we lie and say "wake timer" because the interrupt will be handled by the regular IRQ code - powerFSM.trigger(EVENT_WAKE_TIMER); } break; } + default: + // Otherwise let the NB state handle the IRQ (and that state will handle stuff like IRQs etc) + // we lie and say "wake timer" because the interrupt will be handled by the regular IRQ code + powerFSM.trigger(EVENT_WAKE_TIMER); + break; + } } else { // Someone says we can't sleep now, so just save some power by sleeping the CPU for 100ms or so delay(100); diff --git a/src/concurrency/Lock.cpp b/src/concurrency/Lock.cpp index 4596e0edf..9068cee43 100644 --- a/src/concurrency/Lock.cpp +++ b/src/concurrency/Lock.cpp @@ -26,6 +26,11 @@ void Lock::lock() } } +bool Lock::lock(uint32_t timeout) +{ + return xSemaphoreTake(handle, pdMS_TO_TICKS(timeout)) == pdTRUE; +} + void Lock::unlock() { if (xSemaphoreGive(handle) == false) { @@ -39,6 +44,11 @@ Lock::~Lock() {} void Lock::lock() {} +bool Lock::lock(uint32_t) +{ + return true; +} + void Lock::unlock() {} #endif diff --git a/src/concurrency/Lock.h b/src/concurrency/Lock.h index a51248117..342747ae7 100644 --- a/src/concurrency/Lock.h +++ b/src/concurrency/Lock.h @@ -22,6 +22,11 @@ class Lock // Must not be called from an ISR. void lock(); + /// Locks the lock with timeout. + // + // Must not be called from an ISR. + bool lock(uint32_t timeout); + // Unlocks the lock. // // Must not be called from an ISR. diff --git a/src/configuration.h b/src/configuration.h index 1a6550366..03cb12bf3 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -253,6 +253,7 @@ along with this program. If not, see . #define BBQ10_KB_ADDR 0x1F #define MPR121_KB_ADDR 0x5A #define TCA8418_KB_ADDR 0x34 +#define TSTC8_KB_ADDR 0x6C // STC8H companion-MCU keypad on the ThinkNode-M9 // ----------------------------------------------------------------------------- // SENSOR @@ -270,6 +271,7 @@ along with this program. If not, see . #define QMC5883L_ADDR 0x0D #define HMC5883L_ADDR 0x1E #define MMC5983MA_ADDR 0x30 +#define QMC6309_ADDR 0x7C #define SHTC3_ADDR 0x70 #define LPS22HB_ADDR 0x5C #define LPS22HB_ADDR_ALT 0x5D diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index eb0710101..eff44c114 100644 --- a/src/detect/ScanI2C.cpp +++ b/src/detect/ScanI2C.cpp @@ -31,21 +31,21 @@ ScanI2C::FoundDevice ScanI2C::firstRTC() const ScanI2C::FoundDevice ScanI2C::firstKeyboard() const { - ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MPR121KB, TCA8418KB}; - return firstOfOrNONE(6, types); + ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MPR121KB, TCA8418KB, STC8HKB}; + return firstOfOrNONE(7, types); } ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const { - ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX, - ICM20948, BMM150, BMI270, ICM42607P, ISM330DHCX, QMA6100P}; - return firstOfOrNONE(13, types); + ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX, + ICM20948, BMM150, BMI270, ICM42607P, ISM330DHCX, QMA6100P, QMI8658}; + return firstOfOrNONE(14, types); } ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const { - ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR}; - return firstOfOrNONE(2, types); + ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR, QMC6309}; + return firstOfOrNONE(3, types); } ScanI2C::FoundDevice ScanI2C::firstAQI() const diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index 6a97370dc..c36434ae7 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -42,6 +42,7 @@ class ScanI2C QMC5883L, HMC5883L, MMC5983MA, + QMC6309, PMSA003I, QMA6100P, MPU6050, @@ -105,6 +106,7 @@ class ScanI2C IIS2MDCTR, ISM330DHCX, SPA06, + STC8HKB, // STC8H companion-MCU keypad (ThinkNode-M9) DS248X, HM330X } DeviceType; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index f0f4671f5..9085763d5 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -444,6 +444,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) type = BBQ10KB; logFoundDevice("BB Q10", (uint8_t)addr.address); break; + SCAN_SIMPLE_CASE(TSTC8_KB_ADDR, STC8HKB, "STC8H KB", (uint8_t)addr.address); SCAN_SIMPLE_CASE(ST7567_ADDRESS, SCREEN_ST7567, "ST7567", (uint8_t)addr.address); #ifdef HAS_NCP5623 SCAN_SIMPLE_CASE(NCP5623_ADDR, NCP5623, "NCP5623", (uint8_t)addr.address); @@ -1093,6 +1094,20 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) foundDevices[addr] = type; } } + + // The QMC6309 magnetometer sits at 0x7C, above the general scan ceiling (the loop above stops at 0x77 to + // avoid the reserved 0x78-0x7F block). Probe it explicitly. Gated on the SensorLib driver being present so + // only boards that can actually drive the chip poke this reserved address. +#if __has_include() + addr.address = QMC6309_ADDR; + i2cBus->beginTransmission(addr.address); + if (i2cBus->endTransmission() == 0 && + getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1) == 0x90 /* QMC6309 chip id */) { + deviceAddresses[QMC6309] = addr; + foundDevices[addr] = QMC6309; + logFoundDevice("QMC6309", (uint8_t)addr.address); + } +#endif } void ScanI2CTwoWire::scanPort(I2CPort port) diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 69000f2fe..73d1d0355 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1686,7 +1686,7 @@ GnssModel_t GPS::probe(int serialSpeed) {"AG3335", "$PAIR021,AG3335", GNSS_MODEL_AG3335}, {"AG3352", "$PAIR021,AG3352", GNSS_MODEL_AG3352}, {"RYS3520", "$PAIR021,REYAX_RYS3520_V2", GNSS_MODEL_AG3352}, - {"UC6580", "UC6580", GNSS_MODEL_UC6580}, + {"UC6580", "UC6580", GNSS_MODEL_UC6580} // as L76K is sort of a last ditch effort, we won't attempt to detect it by startup messages for now. /*{"L76K", "SW=URANUS", GNSS_MODEL_MTK}*/}; GnssModel_t detectedDriver = getProbeResponse(500, passive_detect, serialSpeed); @@ -1713,8 +1713,10 @@ GnssModel_t GPS::probe(int serialSpeed) case 1: { // Unicore UFirebirdII Series: UC6580, UM620, UM621, UM670A, UM680A, or UM681A,or CM121 - std::vector unicore = { - {"UC6580", "UC6580", GNSS_MODEL_UC6580}, {"UM600", "UM600", GNSS_MODEL_UC6580}, {"CM121", "CM121", GNSS_MODEL_CM121}}; + std::vector unicore = {{"UC6580", "UC6580", GNSS_MODEL_UC6580}, + {"UM600", "UM600", GNSS_MODEL_UC6580}, + {"CM121", "CM121", GNSS_MODEL_CM121}, + {"CC1167Q", "CC1167Q", GNSS_MODEL_CM121}}; PROBE_FAMILY("Unicore Family", "$PDTINFO", unicore, 500); currentDelay = 20; currentStep = 2; diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index a81942aba..fad540cad 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -23,6 +23,9 @@ #include "graphics/images.h" #include "main.h" #include "target_specific.h" +#ifdef COMPASS_SENSOR_DEBUG +#include "motion/MotionSensor.h" +#endif #include #include #include @@ -1776,6 +1779,26 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU const int *textPos = getTextPositions(display); const bool compactPanel = graphics::isCompactPanel(display); +#ifdef COMPASS_SENSOR_DEBUG + // Optional raw IMU accel + magnetometer x/y/z readout for on-device axis/sign tuning. + { + char dbg[40]; + float sx = 0, sy = 0, sz = 0; + uint32_t age = 0; + if (MotionSensor::getLatestCompassAccelSample(sx, sy, sz, age)) + snprintf(dbg, sizeof(dbg), "A %.2f %.2f %.2f", sx, sy, sz); + else + snprintf(dbg, sizeof(dbg), "A ---"); + display->drawString(x, textPos[line++], dbg); + + if (MotionSensor::getLatestCompassMagSample(sx, sy, sz, age)) + snprintf(dbg, sizeof(dbg), "M %.2f %.2f %.2f", sx, sy, sz); + else + snprintf(dbg, sizeof(dbg), "M ---"); + display->drawString(x, textPos[line++], dbg); + } +#endif + // === First Row: My Location === #if HAS_GPS bool origBold = config.display.heading_bold; diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp index cfb23443e..460b2f86e 100644 --- a/src/graphics/tftSetup.cpp +++ b/src/graphics/tftSetup.cpp @@ -235,10 +235,8 @@ DeviceScreen *deviceScreen = nullptr; #ifdef ARCH_ESP32 // Get notified when the system is entering light sleep -CallbackObserver tftSleepObserver = - CallbackObserver(deviceScreen, &DeviceScreen::prepareSleep); -CallbackObserver endSleepObserver = - CallbackObserver(deviceScreen, &DeviceScreen::wakeUp); +static CallbackObserver *tftSleepObserver = nullptr; +static CallbackObserver *endSleepObserver = nullptr; #endif /** @@ -277,6 +275,19 @@ class ReentrantSpiLock : public ISpiLock depth = 1; } + bool lock(uint32_t timeout) override + { + ThreadId self = currentThread(); + if (depth && owner == self) { + depth++; + return true; + } + bool result = spiLock->lock(timeout); + owner = self; + depth = 1; + return result; + } + void unlock(void) override { if (--depth == 0) { @@ -413,8 +424,14 @@ void tftSetup(void) if (deviceScreen) { #ifdef ARCH_ESP32 - tftSleepObserver.observe(¬ifyLightSleep); - endSleepObserver.observe(¬ifyLightSleepEnd); + if (!tftSleepObserver) { + tftSleepObserver = new CallbackObserver(deviceScreen, &DeviceScreen::prepareSleep); + } + if (!endSleepObserver) { + endSleepObserver = new CallbackObserver(deviceScreen, &DeviceScreen::wakeUp); + } + tftSleepObserver->observe(¬ifyLightSleep); + endSleepObserver->observe(¬ifyLightSleepEnd); xTaskCreatePinnedToCore(tft_task_handler, "tft", TFT_TASK_STACK_SIZE, NULL, 1, NULL, 0); #elif defined(ARCH_PORTDUINO) std::thread *tft_task = new std::thread([] { tft_task_handler(); }); diff --git a/src/input/STC8HKeyboard.cpp b/src/input/STC8HKeyboard.cpp new file mode 100644 index 000000000..134153593 --- /dev/null +++ b/src/input/STC8HKeyboard.cpp @@ -0,0 +1,155 @@ +#include "STC8HKeyboard.h" + +#if defined(ELECROW_ThinkNode_M9) +#include "cardKbI2cImpl.h" + +#include "configuration.h" + +// --------------------------------------------------------------------------- +// STC8H companion-MCU keypad driver (ThinkNode-M9). +// +// The original STC8HKeyboard.cpp was lost from the reference source tree, so +// this was recovered from the linked reference firmware.elf (the .o was an LTO +// object with no machine code; the final ELF had the real inlined bodies). +// +// How the hardware works: +// - The STC8H raises KB_INT (rising edge, idle-low) when a key is pressed. The ISR +// latches key_event; is_key_event() just returns that flag. +// - The pressed key code is read over I2C from register 0x05. +// - is_key_state() polls KB_INT directly to keep the backlight lit while a +// key is held. +// - Battery voltage lives in registers 0x01..0x04, little-endian. +// - Sleep is requested by writing 0x01 to the STATE register (0x06). +// - The keypad backlight (KB_LED) and torch (PIN_LED) are plain host GPIOs, +// not I2C commands. +// --------------------------------------------------------------------------- + +STC8HKeyboard Stc8HKeyBoard; + +// ISR latched on each KB_INT rising edge (a key was pressed). +static void has_key_event() +{ + Stc8HKeyBoard.key_event = true; + if (cardKbI2cImpl) { + cardKbI2cImpl->setIntervalFromNow(0); + // runASAP = true; + BaseType_t higherWake = 0; + concurrency::mainDelay.interruptFromISR(&higherWake); + } +} + +void STC8HKeyboard::writeRegister(uint8_t reg, uint8_t val) +{ + _pWire->beginTransmission(_I2C_addr); + _pWire->write(reg); + _pWire->write(val); + _pWire->endTransmission(); +} + +uint8_t STC8HKeyboard::readRegister(uint8_t reg) +{ + _pWire->beginTransmission(_I2C_addr); + _pWire->write(reg); + if (_pWire->endTransmission(false) != 0) + return 0xFF; + if (_pWire->requestFrom(_I2C_addr, (uint8_t)1) != 1) + return 0xFF; + return _pWire->read(); +} + +void STC8HKeyboard::begin(uint8_t addr, TwoWire *wire) +{ + LOG_DEBUG("STC8HKeyboard::begin() addr=0x%02x", addr); + _I2C_addr = addr; + _pWire = wire; + pinMode(KB_INT, INPUT); +#ifdef KB_LED + pinMode(KB_LED, OUTPUT); +#endif +#ifdef PIN_LED + pinMode(PIN_LED, OUTPUT); +#endif + attachInterrupt(KB_INT, has_key_event, RISING); + _pWire->begin(); + Keyboard_state = true; +#ifdef ARCH_ESP32 + // Detach/reattach the key interrupt around ESP32 light sleep + lsObserver.observe(¬ifyLightSleep); + lsEndObserver.observe(¬ifyLightSleepEnd); +#endif +} + +bool STC8HKeyboard::is_Keyboard_begin() +{ + return Keyboard_state; +} + +// A key is currently active (KB_INT held); used to wake the keypad backlight. +bool STC8HKeyboard::is_key_state() +{ + return digitalRead(KB_INT); +} + +// A key-press interrupt has been latched since the flag was last cleared. +bool STC8HKeyboard::is_key_event() +{ + return key_event; +} + +uint8_t STC8HKeyboard::bsp_get_key_value() +{ + return readRegister(0x01); +} + +// Battery millivolts: registers 0x01..0x04 read little-endian, low 16 bits. +uint16_t STC8HKeyboard::bsp_get_battery_voltage() +{ + if (!Keyboard_state) + return 0; + uint32_t voltage = 0; + for (uint8_t i = 0; i < 4; i++) + voltage |= (uint32_t)readRegister(STC8_REG_ADDR_BATTERY + i) << (i * 8); + return voltage > 0xFFFF ? 0xFFFF : (uint16_t)voltage; +} + +void STC8HKeyboard::set_keyboard_blight(bool state) +{ +#ifdef KB_LED + digitalWrite(KB_LED, state); +#else + (void)state; // KB_LED pin not defined for this board +#endif +} + +void STC8HKeyboard::switch_flashlight() +{ +#ifdef PIN_LED + digitalWrite(PIN_LED, !digitalRead(PIN_LED)); +#endif + // else: torch pin unresolved on this board (old board used PIN_LED 13, + // which the current variant assigns to BATTERY_PIN) -- see variant.h. +} + +void STC8HKeyboard::set_sleep_status(void) +{ + writeRegister(STC8_REG_ADDR_STATE, 0x01); + _pWire->end(); +} + +#ifdef ARCH_ESP32 +// Detach the key interrupt before ESP32 light sleep, so it can't fire while asleep. +int STC8HKeyboard::beforeLightSleep(void *unused) +{ + detachInterrupt(KB_INT); + return 0; // Indicates success +} + +// Reattach the key interrupt after waking from light sleep. +int STC8HKeyboard::afterLightSleep(esp_sleep_wakeup_cause_t cause) +{ + attachInterrupt(KB_INT, has_key_event, RISING); + return 0; // Indicates success +} +#endif + +#endif // ELECROW_ThinkNode_M9 diff --git a/src/input/STC8HKeyboard.h b/src/input/STC8HKeyboard.h new file mode 100644 index 000000000..7de3b3d28 --- /dev/null +++ b/src/input/STC8HKeyboard.h @@ -0,0 +1,74 @@ +#pragma once +#ifndef _STC8H_KEYBOARD_H_ +#define _STC8H_KEYBOARD_H_ + +#include "configuration.h" +#include "kbI2cBase.h" +#include +#if defined(ELECROW_ThinkNode_M9) + +#ifdef ARCH_ESP32 +#include "sleep.h" // notifyLightSleep / notifyLightSleepEnd + esp_sleep_wakeup_cause_t +#endif + +// Registers exposed by the STC8H companion MCU over I2C. +#define STC8_REG_ADDR_BATTERY 0x01 +#define STC8_REG_ADDR_MATRIX_KEY 0x05 +#define STC8_REG_ADDR_STATE 0x06 + +class STC8HKeyboard +{ + public: + STC8HKeyboard(){}; + + void begin(uint8_t addr, TwoWire *wire); + + void set_sleep_status(void); + + uint16_t bsp_get_battery_voltage(); + + bool is_key_event(); + + bool is_Keyboard_begin(); + + bool is_key_state(); + + uint8_t bsp_get_key_value(); + + void set_keyboard_blight(bool state); + + void switch_flashlight(); + + uint8_t readRegister(uint8_t reg); + + bool key_event; + +#ifdef ARCH_ESP32 + // Detach/reattach the KB_INT interrupt around ESP32 light sleep, so the + // companion MCU's key interrupt can't fire spuriously while asleep. + int beforeLightSleep(void *unused); + int afterLightSleep(esp_sleep_wakeup_cause_t cause); +#endif + + private: + void writeRegister(uint8_t reg, uint8_t val); + + uint8_t _I2C_addr = TSTC8_KB_ADDR; + + TwoWire *_pWire = &Wire; + + bool Keyboard_state = false; + +#ifdef ARCH_ESP32 + // Get notified when light sleep begins and ends (mirrors TwoButton / Power) + CallbackObserver lsObserver = + CallbackObserver(this, &STC8HKeyboard::beforeLightSleep); + CallbackObserver lsEndObserver = + CallbackObserver(this, &STC8HKeyboard::afterLightSleep); +#endif +}; + +extern STC8HKeyboard Stc8HKeyBoard; + +#endif +#endif diff --git a/src/input/cardKbI2cImpl.cpp b/src/input/cardKbI2cImpl.cpp index cb03eb4ff..dd86607c0 100644 --- a/src/input/cardKbI2cImpl.cpp +++ b/src/input/cardKbI2cImpl.cpp @@ -51,6 +51,10 @@ void CardKbI2cImpl::init() // assign an arbitrary value to distinguish from other models kb_model = 0x84; break; + case ScanI2C::DeviceType::STC8HKB: + // assign an arbitrary value to distinguish from other models + kb_model = 0x12; + break; default: // use this as default since it's also just zero LOG_WARN("kb_info.type is unknown(0x%02x), setting kb_model=0x00", kb_info.type); diff --git a/src/input/kbI2cBase.cpp b/src/input/kbI2cBase.cpp index b78677460..d4851c7f6 100644 --- a/src/input/kbI2cBase.cpp +++ b/src/input/kbI2cBase.cpp @@ -15,6 +15,12 @@ #include "TCA8418Keyboard.h" #endif +#if defined(ELECROW_ThinkNode_M9) +#include "STC8HKeyboard.h" +#include "graphics/Screen.h" // for the global `screen` + FrameFocus +#include "graphics/draw/NotificationRenderer.h" // for resetBanner() +#endif + extern ScanI2C::DeviceAddress cardkb_found; extern uint8_t kb_model; @@ -64,6 +70,11 @@ int32_t KbI2cBase::runOnce() // resolved via the scanner: WIRE1 may be a bridged bus rather // than the local Wire1 (e.g. SenseCAP Indicator) i2cBus = ScanI2CTwoWire::fetchI2CBus(cardkb_found); +#if defined(ELECROW_ThinkNode_M9) + if (cardkb_found.address == TSTC8_KB_ADDR) { + Stc8HKeyBoard.begin(TSTC8_KB_ADDR, &Wire1); + } +#endif if (cardkb_found.address == BBQ10_KB_ADDR) { Q10keyboard.begin(BBQ10_KB_ADDR, i2cBus); Q10keyboard.setBacklight(0); @@ -79,6 +90,11 @@ int32_t KbI2cBase::runOnce() case ScanI2C::WIRE: LOG_DEBUG("Use I2C Bus 0 (the first one)"); i2cBus = &Wire; +#if defined(ELECROW_ThinkNode_M9) + if (cardkb_found.address == TSTC8_KB_ADDR) { + Stc8HKeyBoard.begin(TSTC8_KB_ADDR, &Wire); + } +#endif if (cardkb_found.address == BBQ10_KB_ADDR) { Q10keyboard.begin(BBQ10_KB_ADDR, &Wire); Q10keyboard.setBacklight(0); @@ -546,6 +562,104 @@ int32_t KbI2cBase::runOnce() } break; } +#if defined(ELECROW_ThinkNode_M9) + case 0x12: { // STC8H companion-MCU keypad (ThinkNode-M9) + Stc8HKeyBoard.key_event = false; + InputEvent e = {}; + e.inputEvent = INPUT_BROKER_NONE; + e.source = this->_originName; + uint8_t c = Stc8HKeyBoard.bsp_get_key_value(); // unsigned so the 0x8x/0xbx codes match + switch (c) { + case 0x81: // Mute + e.inputEvent = INPUT_BROKER_ANYKEY; + e.kbchar = INPUT_BROKER_MSG_MUTE_TOGGLE; + break; + case 0x82: // Home + e.inputEvent = INPUT_BROKER_ANYKEY; + graphics::NotificationRenderer::resetBanner(); + // TODO(M9): also reset CannedMessage/PresetMessage state once those modules are ported + if (screen) + screen->setFrames(graphics::Screen::FOCUS_FAULT); + break; + case 0x83: // Time + e.inputEvent = INPUT_BROKER_ANYKEY; + graphics::NotificationRenderer::resetBanner(); + // TODO(M9): also reset CannedMessage/PresetMessage state once those modules are ported + if (screen) + screen->setFrames(graphics::Screen::FOCUS_CLOCK); + break; + case 0x84: + e.inputEvent = INPUT_BROKER_GPS_TOGGLE; + Stc8HKeyBoard.switch_flashlight(); + break; + case 0x85: // FM + e.inputEvent = INPUT_BROKER_SEND_PING; + e.kbchar = 0; + break; + case 0x86: // FM (long press) + e.inputEvent = INPUT_BROKER_CANCEL; + e.kbchar = 0; + break; + case 0x87: // Preset + graphics::NotificationRenderer::resetBanner(); + // TODO(M9): also reset CannedMessage state once that module is ported + e.inputEvent = INPUT_BROKER_SELECT_LONG; + e.kbchar = 0; + break; + case 0xb5: // Up + e.inputEvent = INPUT_BROKER_UP; + e.kbchar = 0; + break; + case 0xb4: // Left + e.inputEvent = INPUT_BROKER_LEFT; + e.kbchar = 0; + break; + case 0xb6: // Down + e.inputEvent = INPUT_BROKER_DOWN; + e.kbchar = 0; + break; + case 0xb7: // Right + e.inputEvent = INPUT_BROKER_RIGHT; + e.kbchar = 0; + break; + case 0x20: // Space + e.inputEvent = INPUT_BROKER_ANYKEY; + e.kbchar = 0x20; + break; + case 0x0d: // Enter + e.inputEvent = INPUT_BROKER_SELECT; + e.kbchar = 0; + break; + case 0x08: // Del + e.inputEvent = INPUT_BROKER_BACK; + e.kbchar = 0; + break; + case 0x89: // Del (long press) + e.inputEvent = INPUT_BROKER_BACK; + e.kbchar = 0; + break; + case 0x88: // Invalid key value + e.inputEvent = INPUT_BROKER_ANYKEY; + e.kbchar = 0; + break; + default: // all other keys (printable ASCII) + if ((c >= 0x20) && (c <= 0x7F)) { + e.inputEvent = INPUT_BROKER_ANYKEY; + e.kbchar = c; + } else { + e.inputEvent = INPUT_BROKER_NONE; + e.kbchar = 0; + } + break; + } + if (e.inputEvent != INPUT_BROKER_NONE) { + // LOG_DEBUG("STC8H companion-MCU keypad key event: 0x%02x", c); + this->notifyObservers(&e); + } + + break; + } +#endif default: LOG_WARN("Unknown kb_model 0x%02x", kb_model); } diff --git a/src/main.cpp b/src/main.cpp index 6b192aa04..df87cfcde 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -761,6 +761,10 @@ void setup() // assign an arbitrary value to distinguish from other models kb_model = 0x84; break; + case ScanI2C::DeviceType::STC8HKB: + // assign an arbitrary value to distinguish from other models + kb_model = 0x12; + break; default: // use this as default since it's also just zero LOG_WARN("kb_info.type unknown(0x%02x), set kb_model=0x00", kb_info.type); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 715daff1b..41d570f3e 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1032,7 +1032,8 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32); #if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \ - defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2)) && \ + defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2) || \ + defined(ELECROW_ThinkNode_M9)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; @@ -1264,7 +1265,10 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.external_notification.output_ms = 1000; #endif -#if defined(PIN_VIBRATION) +#if HAS_TFT + if (moduleConfig.external_notification.nag_timeout == default_ringtone_nag_secs) + moduleConfig.external_notification.nag_timeout = 0; +#elif defined(PIN_VIBRATION) moduleConfig.external_notification.nag_timeout = 2; #elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) || \ defined(HAS_I2S_SPEAKER_NRF52) @@ -1276,12 +1280,6 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.external_notification.enabled = true; moduleConfig.external_notification.use_i2s_as_buzzer = true; moduleConfig.external_notification.alert_message_buzzer = true; -#if HAS_TFT - if (moduleConfig.external_notification.nag_timeout == default_ringtone_nag_secs) - moduleConfig.external_notification.nag_timeout = 0; -#else - moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs; -#endif // HAS_TFT #endif // HAS_I2S #ifdef NANO_G2_ULTRA diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 0bcb504fb..63009aecc 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -21,6 +21,7 @@ #include "LSM6DS3Sensor.h" #include "MPU6050Sensor.h" #include "MotionSensor.h" +#include "QMI8658Sensor.h" #include #ifdef HAS_QMA6100P @@ -146,6 +147,11 @@ class AccelerometerThread : public concurrency::OSThread case ScanI2C::DeviceType::QMA6100P: sensor.reset(new QMA6100PSensor(device)); break; +#endif +#if __has_include() + case ScanI2C::DeviceType::QMI8658: + sensor.reset(new QMI8658Sensor(device)); + break; #endif default: disable(); diff --git a/src/motion/MagnetometerThread.h b/src/motion/MagnetometerThread.h index 8185f296a..4a3d7d69a 100644 --- a/src/motion/MagnetometerThread.h +++ b/src/motion/MagnetometerThread.h @@ -9,6 +9,7 @@ #include "../concurrency/OSThread.h" #include "MMC5983MASensor.h" #include "MotionSensor.h" +#include "QMC6309Sensor.h" #include @@ -73,6 +74,11 @@ class MagnetometerThread : public concurrency::OSThread case ScanI2C::DeviceType::MMC5983MA: sensor.reset(new MMC5983MASensor(device)); break; +#endif +#if __has_include() + case ScanI2C::DeviceType::QMC6309: + sensor.reset(new QMC6309Sensor(device)); + break; #endif default: disable(); diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp index 6cbe8e21d..e6331ea85 100755 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -42,6 +42,9 @@ struct CompassAccelSample { concurrency::Lock latestCompassAccelLock; CompassAccelSample latestCompassAccelSample; + +concurrency::Lock latestCompassMagLock; +CompassAccelSample latestCompassMagSample; } // namespace // screen is defined in main.cpp @@ -245,6 +248,35 @@ bool MotionSensor::getLatestCompassAccelSample(float &x, float &y, float &z, uin return true; } +void MotionSensor::publishCompassMagSample(float x, float y, float z) +{ + concurrency::LockGuard guard(&latestCompassMagLock); + latestCompassMagSample.x = x; + latestCompassMagSample.y = y; + latestCompassMagSample.z = z; + latestCompassMagSample.sampledAtMs = millis(); + latestCompassMagSample.valid = true; +} + +bool MotionSensor::getLatestCompassMagSample(float &x, float &y, float &z, uint32_t &ageMs) +{ + uint32_t sampledAtMs = 0; + { + concurrency::LockGuard guard(&latestCompassMagLock); + if (!latestCompassMagSample.valid) { + return false; + } + + x = latestCompassMagSample.x; + y = latestCompassMagSample.y; + z = latestCompassMagSample.z; + sampledAtMs = latestCompassMagSample.sampledAtMs; + } + + ageMs = millis() - sampledAtMs; + return true; +} + #if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h index 38876fb77..ef84e1b19 100755 --- a/src/motion/MotionSensor.h +++ b/src/motion/MotionSensor.h @@ -42,6 +42,11 @@ class MotionSensor virtual void calibrate(uint16_t forSeconds){}; + // Latest samples published by the compass-fusion drivers (accel from the IMU, mag from the magnetometer). + // Public so an optional on-screen sensor debug readout can read them. Return false if nothing published yet. + static bool getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs); + static bool getLatestCompassMagSample(float &x, float &y, float &z, uint32_t &ageMs); + // True if this sensor produces the compass heading (screen->setHeading()) in runOnce(). // Combined accel+magnetometer parts (e.g. BMX160, ICM20948) and standalone magnetometers // handled by the accelerometer thread (e.g. BMM150) override this. Used to avoid halting @@ -74,7 +79,7 @@ class MotionSensor float &lowestY, float &highestZ, float &lowestZ); static float applyCompassOrientation(float heading); static void publishCompassAccelSample(float x, float y, float z); - static bool getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs); + static void publishCompassMagSample(float x, float y, float z); ScanI2C::FoundDevice device; diff --git a/src/motion/QMC6309Sensor.cpp b/src/motion/QMC6309Sensor.cpp new file mode 100644 index 000000000..bdf82878b --- /dev/null +++ b/src/motion/QMC6309Sensor.cpp @@ -0,0 +1,152 @@ +#include "QMC6309Sensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() + +#include "Fusion/Fusion.h" +#include "detect/ScanI2CTwoWire.h" +#include + +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) +extern std::unique_ptr screen; +#endif + +static constexpr int32_t QMC6309_UPDATE_INTERVAL_MS = 20; +// Heading offset/flip below is a starting point copied from the MMC5983MA path; it is orientation-specific +// and must be verified/tuned against known North on real M9 hardware (see plan). +static constexpr float QMC6309_HEADING_OFFSET_DEG = 180.0f; +static constexpr uint32_t QMC6309_ACCEL_STALE_MS = 300; +static constexpr float QMC6309_MIN_AXIS_RADIUS = 1e-4f; + +QMC6309Sensor::QMC6309Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} + +bool QMC6309Sensor::init() +{ + LOG_DEBUG("QMC6309 begin on addr 0x%02X (port=%d)", device.address.address, device.address.port); + TwoWire *wire = ScanI2CTwoWire::fetchI2CBus(device.address); + + if (!sensor.begin(*wire, deviceAddress())) { + LOG_DEBUG("QMC6309 init error"); + return false; + } + + sensor.reset(); + + // 8 Gauss full-scale easily covers Earth's ~0.5 G field; OSR_8 for low noise. Tunable. + if (!sensor.configMagnetometer(OperationMode::CONTINUOUS_MEASUREMENT, MagFullScaleRange::FS_8G, 100.0f, + MagOverSampleRatio::OSR_8)) { + LOG_DEBUG("QMC6309 config failed"); + return false; + } + + loadMagnetometerCalibration(compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + LOG_DEBUG("QMC6309 init ok"); + LOG_DEBUG("QMC6309 calibration extrema: X=(%.3f, %.3f), Y=(%.3f, %.3f), Z=(%.3f, %.3f)", lowestX, highestX, lowestY, highestY, + lowestZ, highestZ); + return true; +} + +bool QMC6309Sensor::readMagnetometer(float &xGauss, float &yGauss, float &zGauss) +{ + MagnetometerData data; + if (!sensor.readData(data)) { + return false; + } + + // magnetic_field is already scaled to Gauss by the driver. + xGauss = data.magnetic_field.x; + yGauss = data.magnetic_field.y; + zGauss = data.magnetic_field.z; + return true; +} + +int32_t QMC6309Sensor::runOnce() +{ + float magX = 0, magY = 0, magZ = 0; + if (!readMagnetometer(magX, magY, magZ)) { + return QMC6309_UPDATE_INTERVAL_MS; + } + +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) + if (doCalibration) { + beginCalibrationDisplay(showingScreen); + updateCalibrationExtrema(magX, magY, magZ, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + finishCalibrationIfExpired(showingScreen, compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ, + lowestZ); + } +#endif + + // Hard-iron bias removal. + magX -= (highestX + lowestX) * 0.5f; + magY -= (highestY + lowestY) * 0.5f; + magZ -= (highestZ + lowestZ) * 0.5f; + // LOG_WARN("QMC6309 extrema=(%.3f, %.3f, %.3f) to (%.3f, %.3f, %.3f)", + // lowestX, lowestY, lowestZ, highestX, highestY, highestZ); + + // Soft-iron diagonal scaling from calibration extrema. + const float radiusX = (highestX - lowestX) * 0.5f; + const float radiusY = (highestY - lowestY) * 0.5f; + const float radiusZ = (highestZ - lowestZ) * 0.5f; + const float avgRadius = (radiusX + radiusY + radiusZ) / 3.0f; + // magX *= (radiusX > QMC6309_MIN_AXIS_RADIUS) ? (avgRadius / radiusX) : 1.0f; + // magY *= (radiusY > QMC6309_MIN_AXIS_RADIUS) ? (avgRadius / radiusY) : 1.0f; + // magZ *= (radiusZ > QMC6309_MIN_AXIS_RADIUS) ? (avgRadius / radiusZ) : 1.0f; + + // Publish the calibrated magnetometer values (hard/soft-iron applied) for the optional on-screen debug readout. + publishCompassMagSample(magX, magY, magZ); + +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN + float heading; + float accelX = 0.0f; + float accelY = 0.0f; + float accelZ = 0.0f; + uint32_t accelAgeMs = 0; + + // Fuse with the latest accelerometer sample (published by the QMI8658 driver) for tilt compensation. + if (getLatestCompassAccelSample(accelX, accelY, accelZ, accelAgeMs) && accelAgeMs <= QMC6309_ACCEL_STALE_MS) { + FusionVector ga = {.axis = {accelX, accelY, accelZ}}; + FusionVector ma = {.axis = {magX, magY, magZ}}; + // if (config.display.compass_orientation > meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270) { + // ma = FusionAxesSwap(ma, FusionAxesAlignmentNXNYPZ); + // ga = FusionAxesSwap(ga, FusionAxesAlignmentNXNYPZ); + //} + // LOG_WARN("QMC6309 accel age %ums, ga=(%.3f, %.3f, %.3f), ma=(%.3f, %.3f, %.3f)", accelAgeMs, ga.axis.x, ga.axis.y, + // ga.axis.z, ma.axis.x, ma.axis.y, ma.axis.z); + heading = FusionCompass(ga, ma, FusionConventionNed); + if (ga.axis.z > 0.0f) + heading = 360.0f - heading; + + } else { + heading = atan2f(-magY, magX) * RAD_TO_DEG; + } + + if (heading >= 360.0f) + heading -= 360.0f; + else if (heading < 0.0f) + heading += 360.0f; + + heading = applyCompassOrientation(heading); + if (screen) + screen->setHeading(heading); +#endif + + return QMC6309_UPDATE_INTERVAL_MS; +} + +void QMC6309Sensor::calibrate(uint16_t forSeconds) +{ +#if !defined(MESHTASTIC_EXCLUDE_SCREEN) + float xGauss = 0.0f; + float yGauss = 0.0f; + float zGauss = 0.0f; + + LOG_DEBUG("QMC6309 calibration started for %is", forSeconds); + if (readMagnetometer(xGauss, yGauss, zGauss)) { + seedCalibrationExtrema(xGauss, yGauss, zGauss, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + } else { + seedCalibrationExtrema(0.0f, 0.0f, 0.0f, highestX, lowestX, highestY, lowestY, highestZ, lowestZ); + } + startCalibrationWindow(forSeconds); +#endif +} + +#endif diff --git a/src/motion/QMC6309Sensor.h b/src/motion/QMC6309Sensor.h new file mode 100644 index 000000000..c457f3526 --- /dev/null +++ b/src/motion/QMC6309Sensor.h @@ -0,0 +1,40 @@ +#pragma once +#ifndef _QMC6309_SENSOR_H_ +#define _QMC6309_SENSOR_H_ + +#include "MotionSensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() + +// SensorQMC6309.hpp (SensorLib 0.4.1) references the isBitSet() macro in an inline method but never includes +// SensorLib.h where it is defined. Define it here (guarded) so the header compiles regardless of include order +// (pulling in SensorLib.h is unreliable - its #pragma once can already be tripped by an in-progress include). +#ifndef isBitSet +#define isBitSet(value, bit) (((value) & (1UL << (bit))) == (1UL << (bit))) +#endif +#include + +class QMC6309Sensor : public MotionSensor +{ + private: + SensorQMC6309 sensor; + bool showingScreen = false; + static constexpr const char *compassCalibrationFileName = "/prefs/compass_qmc6309.dat"; +#ifdef ELECROW_ThinkNode_M9 + float highestX = -5.548, lowestX = -6.530, highestY = -6.638, lowestY = -7.637, highestZ = -6.676, lowestZ = -7.633; +#else + float highestX = 0, lowestX = 0, highestY = 0, lowestY = 0, highestZ = 0, lowestZ = 0; +#endif + + bool readMagnetometer(float &xGauss, float &yGauss, float &zGauss); + + public: + explicit QMC6309Sensor(ScanI2C::FoundDevice foundDevice); + virtual bool init() override; + virtual int32_t runOnce() override; + virtual void calibrate(uint16_t forSeconds) override; +}; + +#endif + +#endif diff --git a/src/motion/QMI8658Sensor.cpp b/src/motion/QMI8658Sensor.cpp new file mode 100644 index 000000000..e55ffc26f --- /dev/null +++ b/src/motion/QMI8658Sensor.cpp @@ -0,0 +1,88 @@ +#include "QMI8658Sensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() + +#include "NodeDB.h" +#include "detect/ScanI2CTwoWire.h" +#include + +// Accelerometer configuration. 2G full-scale gives the best gravity resolution for the tilt +// compensation that the (future) separate compass module will apply to these samples. +static constexpr SensorQMI8658::AccelRange QMI8658_ACCEL_RANGE = SensorQMI8658::ACC_RANGE_2G; +static constexpr SensorQMI8658::AccelODR QMI8658_ACCEL_ODR = SensorQMI8658::ACC_ODR_125Hz; + +// Any-motion slope threshold (in mg) used to wake the screen. Tunable: raise to reduce false wakes, +// lower to make it more sensitive. 200mg (~0.2g) requires a deliberate movement. +static constexpr float QMI8658_ANY_MOTION_THRESHOLD_MG = 200.0f; +static constexpr uint8_t QMI8658_ANY_MOTION_WINDOW = 1; + +// Optional board-defined rotation (degrees) applied to the accel X/Y before publishing to the compass +// fusion path, mirroring the ICM42607P driver. Defaults to no rotation. +static constexpr float QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE = +#ifdef QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG + QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG; +#else + 0.0f; +#endif + +QMI8658Sensor::QMI8658Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} + +bool QMI8658Sensor::init() +{ + LOG_DEBUG("QMI8658 begin on addr 0x%02X (port=%d)", deviceAddress(), devicePort()); + TwoWire *wire = ScanI2CTwoWire::fetchI2CBus(device.address); + + if (!sensor.begin(*wire, deviceAddress())) { + LOG_DEBUG("QMI8658 init failed"); + return false; + } + + sensor.configAccelerometer(QMI8658_ACCEL_RANGE, QMI8658_ACCEL_ODR, SensorQMI8658::LPF_MODE_0); + sensor.enableAccelerometer(); + + // Configure the on-chip any-motion engine so we can wake the screen without a dedicated interrupt pin. + // configMotion() runs alongside normal accel data output (unlike Wake-on-Motion, which halts data), so + // we keep publishing samples for compass fusion while still detecting motion. + wakeOnMotion = config.display.wake_on_tap_or_motion; + if (wakeOnMotion) { + const uint8_t modeCtrl = SensorQMI8658::ANY_MOTION_EN_X | SensorQMI8658::ANY_MOTION_EN_Y | SensorQMI8658::ANY_MOTION_EN_Z; + // No-motion detection is left disabled (unreliable per the SensorLib example); its thresholds/windows + // are still required arguments but are ignored when the mode bits above are clear. + sensor.configMotion(modeCtrl, QMI8658_ANY_MOTION_THRESHOLD_MG, QMI8658_ANY_MOTION_THRESHOLD_MG, + QMI8658_ANY_MOTION_THRESHOLD_MG, QMI8658_ANY_MOTION_WINDOW, /*NoMotion X/Y/Z*/ 0.1f, 0.1f, 0.1f, + /*NoMotionWindow*/ 1, /*SigMotionWaitWindow*/ 1, /*SigMotionConfirmWindow*/ 1); + sensor.enableMotionDetect(); + } + + LOG_DEBUG("QMI8658 init ok"); + return true; +} + +int32_t QMI8658Sensor::runOnce() +{ + float ax, ay, az; + if (sensor.getAccelerometer(ax, ay, az)) { + if (QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE != 0.0f) { + static const float rotRad = QMI8658_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE * DEG_TO_RAD; + static const float cosTheta = cosf(rotRad); + static const float sinTheta = sinf(rotRad); + const float rotatedX = (ax * cosTheta) - (ay * sinTheta); + const float rotatedY = (ax * sinTheta) + (ay * cosTheta); + ax = rotatedX; + ay = rotatedY; + } + + // Match the accel sign convention used by the other FusionCompass sensor paths (e.g. ICM42607P). + // The final handedness must be verified against the QMI8658 datasheet and real calibration once the + // separate compass module is wired up; do not hand-tune the signs before then. + publishCompassAccelSample(ax, ay, az); + } + + if (wakeOnMotion && (sensor.getStatusRegister() & SensorQMI8658::EVENT_ANY_MOTION)) { + wakeScreen(); + } + + return MOTION_SENSOR_CHECK_INTERVAL_MS; +} + +#endif diff --git a/src/motion/QMI8658Sensor.h b/src/motion/QMI8658Sensor.h new file mode 100644 index 000000000..6aa310132 --- /dev/null +++ b/src/motion/QMI8658Sensor.h @@ -0,0 +1,25 @@ +#pragma once +#ifndef _QMI8658_SENSOR_H_ +#define _QMI8658_SENSOR_H_ + +#include "MotionSensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include() + +#include + +class QMI8658Sensor : public MotionSensor +{ + private: + SensorQMI8658 sensor; + bool wakeOnMotion = false; + + public: + explicit QMI8658Sensor(ScanI2C::FoundDevice foundDevice); + virtual bool init() override; + virtual int32_t runOnce() override; +}; + +#endif + +#endif diff --git a/src/sleep.cpp b/src/sleep.cpp index c5d469b42..0a6a37978 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -453,8 +453,12 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r gpio_wakeup_enable((gpio_num_t)ROTARY_PRESS, GPIO_INTR_LOW_LEVEL); #endif #ifdef KB_INT +#if KB_INT_WAKE_ON_HIGH + gpio_wakeup_enable((gpio_num_t)KB_INT, GPIO_INTR_HIGH_LEVEL); +#else gpio_wakeup_enable((gpio_num_t)KB_INT, GPIO_INTR_LOW_LEVEL); -#endif +#endif // KB_INT_WAKE_ON_HIGH +#endif // KB_INT #ifdef BOARD_PCA9535_INT // Side-key interrupt line from PCA9535 expander (active low). gpio_wakeup_enable((gpio_num_t)BOARD_PCA9535_INT, GPIO_INTR_LOW_LEVEL); diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h b/variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h new file mode 100644 index 000000000..009971194 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/pins_arduino.h @@ -0,0 +1,20 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 20; +static const uint8_t SCL = 21; + +static const uint8_t SS = 39; +static const uint8_t MOSI = 47; +static const uint8_t MISO = 38; +static const uint8_t SCK = 40; + +#endif \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini new file mode 100644 index 000000000..fbd595dee --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini @@ -0,0 +1,99 @@ +[thinknode_m9_base] +custom_meshtastic_hw_model = 131 +custom_meshtastic_hw_model_slug = ELECROW_ThinkNode_M9 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = ThinkNode M9 +custom_meshtastic_images = thinknode_m9.svg +custom_meshtastic_tags = Elecrow +custom_meshtastic_requires_dfu = false +custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true + +extends = esp32s3_base +board = crowpanel +board_level = release +board_build.partitions = default_16MB.csv +upload_protocol = esptool +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/ELECROW-ThinkNode-M9> +build_flags = + ${esp32s3_base.build_flags} + -I variants/esp32s3/ELECROW-ThinkNode-M9 + -D ELECROW_ThinkNode_M9 + -D BOARD_HAS_PSRAM + -D HAS_SDCARD=1 + -D SDCARD_CS=48 + -D SDCARD_USE_SPI1 + -D SDCARD_SHARE_SPI + -D SDCARD_INIT_SPI + -D SD_SPI_FREQUENCY=75000000U + -D HAS_SCREEN=1 +; -D COMPASS_SENSOR_DEBUG=1 +lib_deps = ${esp32s3_base.lib_deps} + # renovate: datasource=custom depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.26 + # renovate: datasource=custom depName=SensorLib packageName=lewisxhe/library/SensorLib + lewisxhe/SensorLib@0.4.1 + +[env:thinknode_m9] +extends = thinknode_m9_base +build_flags = + ${thinknode_m9_base.build_flags} + +[env:thinknode_m9-tft] +extends = thinknode_m9_base +build_flags = + ${thinknode_m9_base.build_flags} + -D RADIOLIB_SPI_PARANOID=0 + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D HAS_TFT=1 + -D USE_PACKET_API + -D USE_PIN_BUZZER=PIN_BUZZER + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE + -D LV_USE_SYSMON=0 + -D LV_USE_PROFILER=0 + -D LV_USE_PERF_MONITOR=0 + -D LV_USE_MEM_MONITOR=0 + -D LV_USE_LOG=0 + -D LV_BUILD_TEST=0 + -D USE_LOG_DEBUG + -D LOG_DEBUG_INC=\"DebugConfiguration.h\" + -D RAM_SIZE=5120 + -D LGFX_BUFSIZE=153600 + -D LGFX_DRIVER_TEMPLATE + -D DISPLAY_SIZE=320x240 + -D LGFX_DRIVER=LGFX_GENERIC + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_GENERIC.h\" + -D VIEW_320x240 + -D LGFX_PANEL=ST7789 + -D LGFX_ROTATION=3 + -D LGFX_CFG_HOST=SPI3_HOST + -D LGFX_PIN_SCK=40 + -D LGFX_PIN_MOSI=47 + -D LGFX_PIN_DC=15 + -D LGFX_PIN_CS=16 + -D LGFX_PIN_BL=17 + -D LGFX_PIN_RST=14 + -D LGFX_SCREEN_WIDTH=240 + -D LGFX_SCREEN_HEIGHT=320 + -D LGFX_INVERT_LIGHT=true + -D SPI_FREQUENCY=75000000 +; -D MAP_FULL_REDRAW + -D MUI_WIFI_PS_MIN_MODEM + -D DEFAULT_FTP_SERVER_NETWORK_TYPE_ESP32=NETWORK_ESP32 + -D DEFAULT_STORAGE_TYPE_ESP32=STORAGE_SD + -D CHARGING_VOLTAGE=4.25 + +lib_deps = + ${thinknode_m9_base.lib_deps} + https://github.com/meshtastic/device-ui/archive/e8a5ff337d1ead20b290307fb2159ad27fe47f86.zip ; PR314 input-policy + https://github.com/mverch67/MultiFTPServer/archive/0e854335b9916ed9f2d3bcfe68975ce746992ccd.zip + +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + ${device-ui_base.custom_sdkconfig} \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h b/variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h new file mode 100644 index 000000000..e5fe182c4 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/rfswitch.h @@ -0,0 +1,11 @@ +#include "RadioLib.h" + +static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11X0_DIO6, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC}; + +static const Module::RfSwitchMode_t rfswitch_table[] = { + // mode DIO5 DIO6 + {LR11x0::MODE_STBY, {LOW, LOW}}, {LR11x0::MODE_RX, {HIGH, LOW}}, + {LR11x0::MODE_TX, {HIGH, HIGH}}, {LR11x0::MODE_TX_HP, {LOW, HIGH}}, + {LR11x0::MODE_TX_HF, {LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW}}, + {LR11x0::MODE_WIFI, {LOW, LOW}}, END_OF_MODE_TABLE, +}; diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp new file mode 100644 index 000000000..279dedeb5 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.cpp @@ -0,0 +1,34 @@ +#include "variant.h" +#include "Arduino.h" +#include "SPILock.h" +#include "Wire.h" + +void earlyInitVariant() +{ + pinMode(LORA_CS, OUTPUT); + digitalWrite(LORA_CS, HIGH); + pinMode(SDCARD_CS, OUTPUT); + digitalWrite(SDCARD_CS, HIGH); + pinMode(TFT_CS, OUTPUT); + digitalWrite(TFT_CS, HIGH); + delay(100); +} + +void lateInitVariant() +{ + // configure keyboard long-press time + const uint16_t ms = 700; + concurrency::LockGuard g(spiLock); + Wire.beginTransmission(0x6C); + Wire.write(0x03); + Wire.write((ms >> 8) & 0xFF); + Wire.write(ms & 0xFF); + Wire.endTransmission(); +} + +void variant_shutdown() +{ + uint64_t gpioMask = (1ULL << KB_INT); + gpio_pulldown_en((gpio_num_t)KB_INT); + esp_sleep_enable_ext1_wakeup(gpioMask, ESP_EXT1_WAKEUP_ANY_HIGH); +} \ No newline at end of file diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h new file mode 100644 index 000000000..543a69089 --- /dev/null +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/variant.h @@ -0,0 +1,107 @@ +#define CANNED_MESSAGE_MODULE_ENABLE 1 +#define PRESET_MESSAGE_MODULE_ENABLE 1 + +/*Power*/ +#define VEXT_ENABLE 18 +#define VEXT_ON_VALUE LOW +#define PIN_GPS_EN 11 +#define GPS_EN_ACTIVE LOW + +#define USE_POWERSAVE +#define SLEEP_TIME 120 + +/*Wire Interface*/ +#define WIRE_INTERFACES_COUNT 2 +// I2C keyboard +#define I2C_SCL 21 +#define I2C_SDA 20 +#define KB_INT 12 // STC8H key-press interrupt (idle low, rising edge on press) +#define KB_INT_WAKE_ON_HIGH 1 // KB_INT rests low; wake light sleep on its HIGH (active) level +#define KB_LED 46 // STC8H keypad backlight LED +// I2C peripheral +#define I2C_SCL1 6 +#define I2C_SDA1 7 + +/*BUZZER*/ +#define PIN_BUZZER 9 + +/*CHARGE_CHECK*/ +#define EXT_PWR_DETECT 1 +// #define EXT_CHRG_DETECT 1 +#define EXT_PWR_DETECT_VALUE LOW + +/*GPS*/ +#define HAS_GPS 1 +#define GPS_BAUDRATE 115200 +#define PIN_GPS_RESET 5 +#define PIN_GPS_PPS 4 +#define GPS_TX_PIN 3 +#define GPS_RX_PIN 2 +#define GPS_THREAD_INTERVAL 50 + +/*SPI*/ +#define SPI_MOSI 47 +#define SPI_SCK 40 +#define SPI_MISO 38 + +/*Screen*/ +#define ST7789_CS 16 +#define ST7789_RS 15 +#define ST7789_TE 19 +#define ST7789_SDA SPI_MOSI // MOSI +#define ST7789_SCK SPI_SCK +#define ST7789_RESET 14 +#define ST7789_MISO SPI_MISO +#define ST7789_BUSY -1 +#define ST7789_BL 17 +#define ST7789_SPI_HOST SPI3_HOST +#define SPI_READ_FREQUENCY 16000000 + +#define USE_TFTDISPLAY 1 +#define HAS_SPI_TFT 1 +#define TFT_CS ST7789_CS +#define TFT_BL ST7789_BL +#define TFT_HEIGHT 320 +#define TFT_WIDTH 240 +#define TFT_OFFSET_X 0 +#define TFT_OFFSET_Y 0 +#define TFT_OFFSET_ROTATION 0 +#define TFT_PWM_FREQ 44000 +#define TFT_PWM_CHANNEL 7 +#define TFT_INVERT_LIGHT true +#define TFT_BACKLIGHT_ON LOW +#define SCREEN_ROTATE +#define SCREEN_TRANSITION_FRAMERATE 10 +#define BRIGHTNESS_DEFAULT 128 + +/*Lora radio*/ +#define HW_SPI1_DEVICE +#define LORA_SCK SPI_SCK +#define LORA_MISO SPI_MISO +#define LORA_MOSI SPI_MOSI +#define LORA_CS 39 +#define LORA_RESET 45 +#define LORA_DIO0 41 +#define LORA_DIO1 42 + +#define USE_LR1110 +#define LR1110_IRQ_PIN LORA_DIO1 +#define LR1110_NRESET_PIN LORA_RESET +#define LR1110_BUSY_PIN LORA_DIO0 +#define LR1110_SPI_NSS_PIN LORA_CS +#define LR1110_SPI_SCK_PIN LORA_SCK +#define LR1110_SPI_MOSI_PIN LORA_MOSI +#define LR1110_SPI_MISO_PIN LORA_MISO +#define LR11X0_DIO3_TCXO_VOLTAGE 3.3 +#define LR11X0_DIO_AS_RF_SWITCH + +/*RTC*/ +#define PCF8563_RTC 0x51 + +/*BATTERY*/ +#define BATTERY_PIN 13 +#define BATTERY_IMMUTABLE +#define ADC_MULTIPLIER 2.0f +#define BAT_MEASURE_ADC_UNIT ADC_UNIT_2 +#define ADC_CHANNEL ADC_CHANNEL_2 +#define OCV_ARRAY 4200, 4080, 3980, 3920, 3870, 3820, 3790, 3750, 3700, 3600, 3100 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 047371db9..644877793 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -39,6 +39,10 @@ lib_deps = ${esp32s3_base.lib_deps} extends = env:t-deck board_level = pr +extra_scripts = + ${env:t-deck.extra_scripts} + extra_scripts/ld_response_file.py + build_flags = ${env:t-deck.build_flags} -D CONFIG_DISABLE_HAL_LOCKS=1 ; "feels" to be a bit more stable without locks From e1ea653a45117dcac6b9d4dc5351ef81159afd26 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 18 Aug 2026 12:10:03 +0000 Subject: [PATCH 079/109] fix(graphics): crash and leak fixes across display drivers (#11455) * fix(graphics): crash and leak fixes across display drivers - TFTDisplay (portduino): _touch_instance was an uninitialized member, and the touch-config block only assigns it for xpt2046/stmpe610/ ft5x06 while the guard accepts any configured module. A gt911 entry in config.yaml (supported by the color-UI path) reached _touch_instance->config() through an indeterminate pointer. Initialize to nullptr and guard the config block. - Screen: the destructor freed normalFrames but leaked the owned dispdev (driver + framebuffer) and ui objects. Screen is genuinely destroyed on the portduino reboot path (screen = nullptr in Power.cpp). - EInkDisplay2: GxEPD2_BW's constructor takes the low-level driver by value and stores a copy, so the 'new EINK_DISPLAY_MODEL' at nine sites was orphaned the moment connect() returned. Pass temporaries, as GxEPD2Multi already does. - EInkParallelDisplay: the async full-refresh task cleared asyncFullRunning before nulling asyncTaskHandle, so the destructor could observe running==false with a stale handle and vTaskDelete a freed TCB. Null the handle first (same ordering fix the eink/Drivers/EInkParallel.cpp sibling already carries). - Panel_sdl: initFrameBuffer only null-checked the first of its three allocations and returned true regardless, leaving the line array full of null+offset garbage on failure; later redraws would write through those. Check all three, release partial allocations, and return false. * fix(graphics): propagate Panel_sdl framebuffer allocation failure from init() Per review: initFrameBuffer() can now fail cleanly, so init() must not register the monitor and report success when it does. --- src/graphics/EInkDisplay2.cpp | 50 +++++++++++++++------------- src/graphics/EInkParallelDisplay.cpp | 4 ++- src/graphics/Panel_sdl.cpp | 18 +++++++++- src/graphics/Screen.cpp | 4 +++ src/graphics/TFTDisplay.cpp | 38 +++++++++++---------- 5 files changed, 72 insertions(+), 42 deletions(-) diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index dca31be60..d18cc680e 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -161,9 +161,9 @@ bool EInkDisplay::connect() #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); + // GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1)); adafruitDisplay->init(); #if defined(ELECROW_ThinkNode_M1) || defined(T_ECHO_LITE) || defined(ELECROW_ThinkNode_M8) adafruitDisplay->setRotation(4); @@ -178,9 +178,9 @@ bool EInkDisplay::connect() hspi = new SPIClass(HSPI); hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi); - - adafruitDisplay = new GxEPD2_BW(*lowLevel); + // GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi)); adafruitDisplay->init(); adafruitDisplay->setRotation(4); @@ -189,9 +189,9 @@ bool EInkDisplay::connect() } #elif defined(MESHLINK) { - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1); - - adafruitDisplay = new GxEPD2_BW(*lowLevel); + // GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, SPI1)); adafruitDisplay->init(); adafruitDisplay->setRotation(3); adafruitDisplay->setPartialWindow(0, 0, displayWidth, displayHeight); @@ -199,8 +199,9 @@ bool EInkDisplay::connect() #elif defined(RAK4630) || defined(MAKERPYTHON) { if (eink_found) { - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY); - adafruitDisplay = new GxEPD2_BW(*lowLevel); + // GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY)); adafruitDisplay->init(115200, true, 10, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0)); // RAK14000 2.13 inch b/w 250x122 does actually now support fast refresh adafruitDisplay->setRotation(3); @@ -236,9 +237,9 @@ bool EInkDisplay::connect() // VExt already enabled in setup() // RTC GPIO hold disabled in setup() - // Create GxEPD2 objects - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi); - adafruitDisplay = new GxEPD2_BW(*lowLevel); + // Create GxEPD2 objects (GxEPD2_BW stores a copy of the driver, so pass a temporary) + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *hspi)); // Init GxEPD2 adafruitDisplay->init(); @@ -253,22 +254,25 @@ bool EInkDisplay::connect() } #elif defined(PCA10059) || defined(ME25LS01) { - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY); - adafruitDisplay = new GxEPD2_BW(*lowLevel); + // GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY)); adafruitDisplay->init(115200, true, 40, false, SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0)); adafruitDisplay->setRotation(0); adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT); } #elif defined(M5_COREINK) || defined(T_DECK_PRO) - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY); - adafruitDisplay = new GxEPD2_BW(*lowLevel); + // GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY)); adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0)); adafruitDisplay->setRotation(0); adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT); #elif defined(my) || defined(ESP32_S3_PICO) { - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY); - adafruitDisplay = new GxEPD2_BW(*lowLevel); + // GxEPD2_BW stores a copy of the driver, so pass a temporary instead of leaking a heap object + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY)); adafruitDisplay->init(115200, true, 40, false, SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0)); adafruitDisplay->setRotation(1); adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT); @@ -280,9 +284,9 @@ bool EInkDisplay::connect() // VExt already enabled in setup() // RTC GPIO hold disabled in setup() - // Create GxEPD2 objects - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1); - adafruitDisplay = new GxEPD2_BW(*lowLevel); + // Create GxEPD2 objects (GxEPD2_BW stores a copy of the driver, so pass a temporary) + adafruitDisplay = new GxEPD2_BW( + EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1)); // Init GxEPD2 adafruitDisplay->init(); diff --git a/src/graphics/EInkParallelDisplay.cpp b/src/graphics/EInkParallelDisplay.cpp index 552b11747..a61b1bae9 100644 --- a/src/graphics/EInkParallelDisplay.cpp +++ b/src/graphics/EInkParallelDisplay.cpp @@ -183,8 +183,10 @@ void EInkParallelDisplay::asyncFullUpdateTask(void *pvParameters) self->resetGhostPixelTracking(); #endif - self->asyncFullRunning.store(false); + // Handle first: once asyncFullRunning reads false, the destructor may act on the handle, so + // it must already be null by then (same ordering fix as eink/Drivers/EInkParallel.cpp). self->asyncTaskHandle = nullptr; + self->asyncFullRunning.store(false); // delete this task vTaskDelete(nullptr); diff --git a/src/graphics/Panel_sdl.cpp b/src/graphics/Panel_sdl.cpp index bad6072f9..f01d7dc68 100644 --- a/src/graphics/Panel_sdl.cpp +++ b/src/graphics/Panel_sdl.cpp @@ -360,7 +360,10 @@ Panel_sdl::Panel_sdl(void) : Panel_FrameBufferBase() bool Panel_sdl::init(bool use_reset) { - initFrameBuffer(_cfg.panel_width * 4, _cfg.panel_height); + // Bail before registering the monitor: continuing with a failed framebuffer allocation + // would leave sdl_update() reading garbage line pointers. + if (!initFrameBuffer(_cfg.panel_width * 4, _cfg.panel_height)) + return false; bool res = Panel_FrameBufferBase::init(use_reset); _list_monitor.push_back(&monitor); @@ -647,6 +650,10 @@ bool Panel_sdl::initFrameBuffer(size_t width, size_t height) } _texturebuf = (rgb888_t *)heap_alloc_dma(width * height * sizeof(rgb888_t)); + if (nullptr == _texturebuf) { + heap_free(lineArray); + return false; + } /// 8byte alignment; width = (width + 7) & ~7u; @@ -655,6 +662,15 @@ bool Panel_sdl::initFrameBuffer(size_t width, size_t height) memset(lineArray, 0, height * sizeof(uint8_t *)); uint8_t *framebuffer = (uint8_t *)heap_alloc_dma(width * height + 16); + if (nullptr == framebuffer) { + // Returning true here would leave _lines_buffer full of null+offset garbage pointers + // and turn the failure into a wild write on the next redraw. + heap_free(_texturebuf); + _texturebuf = nullptr; + heap_free(lineArray); + _lines_buffer = nullptr; + return false; + } auto fb = framebuffer; { diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index c8271ddf1..5e8423886 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -652,6 +652,10 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O Screen::~Screen() { delete[] graphics::normalFrames; + // Owned by the constructor; Screen is genuinely destroyed on the portduino reboot path + // (screen = nullptr in Power.cpp), which previously leaked the display and UI objects. + delete ui; + delete dispdev; } /** diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index ba2d50320..9ee16c85f 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -821,7 +821,7 @@ class LGFX : public lgfx::LGFX_Device { lgfx::Bus_SPI _bus_instance; - lgfx::ITouch *_touch_instance; + lgfx::ITouch *_touch_instance = nullptr; public: lgfx::Panel_Device *_panel_instance; @@ -891,24 +891,28 @@ class LGFX : public lgfx::LGFX_Device } else if (portduino_config.touchscreenModule == ft5x06) { _touch_instance = new lgfx::Touch_FT5x06; } - auto touch_cfg = _touch_instance->config(); + // Not every module in the config enum has a branch above (gt911 is handled by the + // color-UI path in tftSetup.cpp), so the pointer can legitimately still be null here. + if (_touch_instance) { + auto touch_cfg = _touch_instance->config(); - touch_cfg.pin_cs = portduino_config.touchscreenCS.pin; - touch_cfg.x_min = 0; - touch_cfg.x_max = portduino_config.displayHeight - 1; - touch_cfg.y_min = 0; - touch_cfg.y_max = portduino_config.displayWidth - 1; - touch_cfg.pin_int = portduino_config.touchscreenIRQ.pin; - touch_cfg.bus_shared = true; - touch_cfg.offset_rotation = portduino_config.touchscreenRotate; - if (portduino_config.touchscreenI2CAddr != -1) { - touch_cfg.i2c_addr = portduino_config.touchscreenI2CAddr; - } else { - touch_cfg.spi_host = portduino_config.touchscreen_spi_dev_int; + touch_cfg.pin_cs = portduino_config.touchscreenCS.pin; + touch_cfg.x_min = 0; + touch_cfg.x_max = portduino_config.displayHeight - 1; + touch_cfg.y_min = 0; + touch_cfg.y_max = portduino_config.displayWidth - 1; + touch_cfg.pin_int = portduino_config.touchscreenIRQ.pin; + touch_cfg.bus_shared = true; + touch_cfg.offset_rotation = portduino_config.touchscreenRotate; + if (portduino_config.touchscreenI2CAddr != -1) { + touch_cfg.i2c_addr = portduino_config.touchscreenI2CAddr; + } else { + touch_cfg.spi_host = portduino_config.touchscreen_spi_dev_int; + } + + _touch_instance->config(touch_cfg); + _panel_instance->setTouch(_touch_instance); } - - _touch_instance->config(touch_cfg); - _panel_instance->setTouch(_touch_instance); } #if defined(SDL_h_) if (portduino_config.displayPanel == x11) { From 83fd62b756b94d178ee6ce4bad0b2001b38de9de Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 18 Aug 2026 12:41:08 +0000 Subject: [PATCH 080/109] test(native): add 14 suites for routing, persistence, parsing and identity gaps (#11515) * test(native): add 14 suites for routing, persistence, parsing and identity gaps Coverage audit of the native test tree; adds the highest-value untested logic as 11 new suites and extends 3 existing ones (200 test functions). New: test_stream_framing, test_nodedb_boot_recovery, test_nodedb_legacy_migration, test_nodedb_v25_roundtrip, test_nodedb_identity_hygiene, test_channel_keys, test_reliable_ack_matrix, test_hop_start_policy, test_routing_response_hops, test_phone_api_config_dump, test_observer. Extended: test_rtc, test_mqtt, test_xmodem. Two source changes the audit produced: - StreamAPI::handleRecStream copied stream->read()'s `cInt < 0` EOF check into the buffer-fed path, where there is no EOF sentinel; with signed char any byte >= 0x80 (START1 is 0x94) aborted the parse. Read the byte as uint8_t directly. Latent on develop (no callers), pinned by test_stream_framing. - Extract the post-decode pre-hop predicate from Router::handleReceived into shouldSkipHandleForPostDecodeHop() (NodeDB.h) so test_hop_start_policy drives the exact expression the router calls. No behavior change. test/state-manifest.tsv declares the suites that construct a NodeDB. Full 68-suite Docker coverage run matches the pre-change baseline. * test(native): address review - harden observer dispatch, trim comments Review follow-ups on the coverage-audit suites: - Observable::notifyObservers() erased list nodes while holding an iterator into them, so an observer that unobserves itself from onNotify corrupted the dispatch. Today the only self-detacher (PhoneAPI::onNotify -> checkConnectionTimeout -> close -> unobserve) survives solely because it returns -1 and aborts the chain before the increment; that unwritten contract is now gone. Removal during a dispatch nulls the entry and the outermost notify sweeps afterwards, which keeps self-detach, next-detach and destruction-during-notify all safe without an allocation. Hoisting the next iterator instead would have inverted the hazard and broken the existing next-detach case. Two regression tests added. - Correct the documented caller of shouldSkipHandleForPostDecodeHop: the call is in Router::dispatchReceived, not handleReceived. - Cast hop fields to unsigned at the %u call site in test_hop_start_policy. - Trim the new suites' file headers to the one-or-two-line rule in AGENTS.md. - Rename eight test functions whose names were exactly `test_` + 35 chars: that is the shape of a Lob API key, so trufflehog flagged them as secrets and failed the Trunk CI check. Full 68-suite Docker coverage run matches the pre-change baseline. * test(native): revert the observer dispatch change, keep the contract test Backs out the notifyObservers() deferred-removal hardening from the previous commit. It was reviewer-driven scope creep: nothing in the coverage audit needed it, no test required it, and it changes dispatch semantics in a header with ~76 observe() call sites on native verification alone. The hazard it addressed is not reachable today. The only observer that unobserves itself from onNotify is PhoneAPI (onNotify -> checkConnectionTimeout -> close -> unobserve), and it returns -1, which aborts the chain before the iterator is advanced past the erased node. test_self_detach_with_abort_during_notify stays: it passes against the unmodified dispatch and pins that the -1 is load-bearing, so a later cleanup that "simplifies" it away goes red. The unsafe variant (self-detach returning 0) is documented in a comment rather than tested, since asserting it would be asserting UB. * fix(serial): recover the frame behind a stray framing marker A byte that failed the START2 check was discarded rather than re-tested as a possible START1, so 0x94 0x94 0xc3 ... lost the real frame: one corrupted byte on a noisy UART silently dropped the frame behind it. Re-test the byte in place instead. Applied to both copies of the receive state machine. readStream() is the one that matters in the field - it is the serial path every phone client uses - while handleRecStream() still has no callers on develop. Strictly widens what the parser accepts; no frame that parsed before parses differently. test_stream_framing covers it on both receive paths, plus a run of stray markers and a START1-then-unrelated-byte resync. This was originally documented as a known gap in the framing suite. Fixing it instead was NomDeTom's call on review: a passing test asserting the bad behavior is what makes it hard to change later, and it is the same defect shape as the signedness fix three functions away. Also: use Throttle::deadlinePassed() in test_reliable_ack_matrix rather than a bare millis() compare, matching the house deadline rule. * test(native): cover the stray-marker resync on the buffer path too The stray-marker fix went into both copies of the receive state machine, but only test_stray_start1_before_frame_still_delivers drove both. The repeated- marker and unrelated-byte cases drove readStream() alone, so a regression in handleRecStream() would have gone unnoticed by two of the three. Verified load-bearing: reverting only the handleRecStream() half of the fix turns test_repeated_stray_start1_before_frame_still_delivers red on the new assertion. test_start1_then_unrelated_byte_resyncs stays green under that mutation by design - its failing byte is 0x00, where both branches reset to 0 - and covers the other half of the ternary. Also drops the stale header on test_stray_start1_before_frame_still_delivers, which still described the gap as pinned-as-is after the fix landed. Co-Authored-By: Claude Opus 5 * test(native): make the hop-start truth table assert the rows it prints test_truth_table_summary was six TEST_MESSAGE lines and no assertion, so it reported as a case that could not fail - the anti-pattern #11517 names in its unfinished assertion-presence lint, and the one exception to NomDeTom's "no RUN_TEST without an assertion" pass over this PR. The printed row and the checked expectation now come from one struct, so the summary cannot narrate a table the predicates no longer implement. It also covers the consequence columns the per-row tests do not assert together: classifyHopStart, shouldDropPacketForPreHop and shouldSkipHandleForPostDecodeHop for the same packet, with the expectations gated on MESHTASTIC_PREHOP_DROP. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- src/mesh/NodeDB.h | 12 + src/mesh/Router.cpp | 9 +- src/mesh/StreamAPI.cpp | 17 +- test/state-manifest.tsv | 7 + test/test_channel_keys/test_main.cpp | 586 ++++++++++++ test/test_hop_start_policy/test_main.cpp | 352 ++++++++ test/test_mqtt/MQTT.cpp | 263 +++++- test/test_nodedb_boot_recovery/test_main.cpp | 396 ++++++++ .../test_main.cpp | 512 +++++++++++ .../test_main.cpp | 570 ++++++++++++ test/test_nodedb_v25_roundtrip/test_main.cpp | 691 ++++++++++++++ test/test_observer/test_main.cpp | 378 ++++++++ test/test_phone_api_config_dump/test_main.cpp | 574 ++++++++++++ test/test_reliable_ack_matrix/test_main.cpp | 853 ++++++++++++++++++ test/test_routing_response_hops/test_main.cpp | 273 ++++++ test/test_rtc/test_main.cpp | 374 ++++++++ test/test_stream_framing/test_main.cpp | 397 ++++++++ test/test_xmodem/test_main.cpp | 476 +++++++++- 18 files changed, 6710 insertions(+), 30 deletions(-) create mode 100644 test/test_channel_keys/test_main.cpp create mode 100644 test/test_hop_start_policy/test_main.cpp create mode 100644 test/test_nodedb_boot_recovery/test_main.cpp create mode 100644 test/test_nodedb_identity_hygiene/test_main.cpp create mode 100644 test/test_nodedb_legacy_migration/test_main.cpp create mode 100644 test/test_nodedb_v25_roundtrip/test_main.cpp create mode 100644 test/test_observer/test_main.cpp create mode 100644 test/test_phone_api_config_dump/test_main.cpp create mode 100644 test/test_reliable_ack_matrix/test_main.cpp create mode 100644 test/test_routing_response_hops/test_main.cpp create mode 100644 test/test_stream_framing/test_main.cpp diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index e5b5a67ac..ca0acf171 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -223,6 +223,18 @@ inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p) #endif } +/// Post-decode, the encrypted bitfield makes MISSING_OR_UNKNOWN decidable. +/// Local packets are exempt; Router::dispatchReceived uses this predicate to set skipHandle. +inline bool shouldSkipHandleForPostDecodeHop(const meshtastic_MeshPacket &p) +{ +#if !MESHTASTIC_PREHOP_DROP + (void)p; + return false; +#else + return !isFromUs(&p) && classifyHopStart(p) != HopStartStatus::VALID; +#endif +} + /// Rate-limited debug log when hop_start is invalid/missing and packet is dropped. void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index c382e8576..99d448ac6 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -1456,11 +1456,10 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) printPacket("handleReceived(REMOTE)", p); #if MESHTASTIC_PREHOP_DROP - // Pre-hop firmware drop, post-decode half: the bitfield that proves the origin populated hop_start is - // encrypted under the channel key, so it can only be evaluated now that the packet is decoded. A packet - // whose hop_start is still missing/unknown comes from pre-hop firmware - keep it out of module - // processing, admin handling, phone delivery, MQTT and rebroadcast. Local-origin packets are exempt. - if (!isFromUs(p) && classifyHopStart(*p) != HopStartStatus::VALID) { + // Pre-hop firmware drop, post-decode half: a packet whose hop_start is still missing/unknown comes + // from pre-hop firmware - keep it out of module processing, admin handling, phone delivery, MQTT + // and rebroadcast. + if (shouldSkipHandleForPostDecodeHop(*p)) { logHopStartDrop(*p, "post-decode pre-hop drop"); cancelSending(p->from, p->id); skipHandle = true; diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index 412a9786a..7d3ca3953 100644 --- a/src/mesh/StreamAPI.cpp +++ b/src/mesh/StreamAPI.cpp @@ -86,12 +86,9 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen) { uint16_t index = 0; while (bufLen > index) { // Currently we never want to block - int cInt = buf[index++]; - if (cInt < 0) - break; // We ran out of characters (even though available said otherwise) - this can happen on rf52 adafruit - // arduino - - uint8_t c = (uint8_t)cInt; + // Unlike stream->read(), a buffer byte has no EOF sentinel: bufLen already bounds the loop, + // and a signed-char comparison would treat any byte >= 0x80 (START1 included) as EOF. + uint8_t c = (uint8_t)buf[index++]; // Use the read pointer for a little state machine, first look for framing, then length bytes, then payload size_t ptr = rxPtr; @@ -105,8 +102,10 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen) if (c != START1) rxPtr = 0; // failed to find framing } else if (ptr == 1) { // looking for START2 + // A byte that fails START2 can itself be the START1 of the real frame (0x94 0x94 0xc3 + // ...), so re-test it here: discarding it drops the frame behind a single stray marker. if (c != START2) - rxPtr = 0; // failed to find framing + rxPtr = (c == START1) ? 1 : 0; } else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing @@ -161,8 +160,10 @@ int32_t StreamAPI::readStream() if (c != START1) rxPtr = 0; // failed to find framing } else if (ptr == 1) { // looking for START2 + // A byte that fails START2 can itself be the START1 of the real frame (0x94 0x94 + // 0xc3 ...): discarding it drops the frame behind a single stray marker. if (c != START2) - rxPtr = 0; // failed to find framing + rxPtr = (c == START1) ? 1 : 0; } else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing diff --git a/test/state-manifest.tsv b/test/state-manifest.tsv index 02870c573..4c8f56bbb 100644 --- a/test/state-manifest.tsv +++ b/test/state-manifest.tsv @@ -54,13 +54,20 @@ test_firmware_edition writes=config.proto,module.proto,device.proto,channels.pro test_fuzz_decode errors=20000..250000 fuzzes protobuf decode; every rejection logs. A collapse to near zero means the corpus stopped reaching the decoder test_fuzz_packets writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs errors=5000..60000 drives decode of fuzzed packets through the real NodeDB and message store test_hop_scaling writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB to hold the hop-distance fixtures +test_hop_start_policy writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (isFromUs needs nodeDB->getNodeNum()), whose constructor persists a default set when the prefs directory is empty test_mesh_beacon writes=module.proto exercises the beacon's module-config save path test_mesh_module writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat module framework tests construct a NodeDB test_mqtt writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto errors=1000..12000 constructs a NodeDB for node lookups in the MQTT paths test_nexthop_routing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto next-hop selection reads and updates the node DB test_nodedb_blocked state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat saturates the DB with MAX_NUM_NODES-2 favourited nodes to test the protected cap; a later test's removeNodeByNum() persists that state, and the cap test depends on the fill from the test before it +test_nodedb_boot_recovery state=per-suite writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto deliberate boot-recovery ladder: corrupts/deletes/restores the pref files and reboots a NodeDB per test to pin the DECODE_FAILED identity freeze, so each test observes the previous test's on-disk state +test_nodedb_identity_hygiene writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat,Messages_default.msgs constructs a NodeDB; addFromContact persists the node DB after every merge, the reboot test proves the key-erasure guard survives a reload, and the should_ignore path rewrites the message store +test_nodedb_legacy_migration writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat each test hand-writes a v24-format nodes.proto fixture and cold-boots a NodeDB, whose constructor persists the migrated v25 database (warm.dat via the over-cap eviction absorb) +test_nodedb_v25_roundtrip writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat v25 persistence round-trips: every test saves nodes.proto and cold-boots a NodeDB whose constructor persists the default segments; warm.dat on the node-DB save cadence test_packet_signing writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=300 needs a NodeDB holding both peers' keys for the PKI encode/decode paths +test_phone_api_config_dump writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto per-test NodeDB fixture backing full PhoneAPI want_config dumps; the constructor persists a default config/channel/node set in a fresh sandbox test_pki_admin_fallback writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto needs a NodeDB holding admin keys for the fallback paths +test_reliable_ack_matrix writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto constructs a NodeDB (whose constructor persists a default set when the prefs directory is empty) for the sender-key lookups in the ACK/NAK matrix test_stream_api writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto drives real PhoneAPI handshakes, which read and persist config and the node DB test_traceroute_nexthop writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto traceroute route selection reads the node DB test_traffic_management writes=config.proto,module.proto,device.proto,channels.proto,nodes.proto,warm.dat errors=3000..12000 constructs a NodeDB for the per-node rate-limit and dedup state; test_tm_fuzz_nodenum_blitz feeds malformed payloads, and each rejection logs (measured 7985) diff --git a/test/test_channel_keys/test_main.cpp b/test/test_channel_keys/test_main.cpp new file mode 100644 index 000000000..34c42b332 --- /dev/null +++ b/test/test_channel_keys/test_main.cpp @@ -0,0 +1,586 @@ +// Channel key derivation and hash layer: getKey() PSK expansion, generateHash() golden values, +// onConfigChanged() primary restore, setChannel() demotion, and perhapsDecode()'s hash fall-through. + +#include "Channels.h" +#include "CryptoEngine.h" +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isBroadcast, etc.) +#include "NodeDB.h" +#include "Router.h" +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include // printf() group separators +#include +#include + +#if defined(ARCH_PORTDUINO) +#define CK_TEST_ENTRY extern "C" +#else +#define CK_TEST_ENTRY +#endif + +// --- Test output helpers --- +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + +// --- Reference hash implementation --- +// Independent re-statement of the algorithm in Channels.cpp (xorHash of the channel name, +// XORed with xorHash of the *expanded* key bytes), used to derive expected values from +// first principles. The golden constants below were computed by hand from this same rule. +static uint8_t refXorHash(const uint8_t *p, size_t len) +{ + uint8_t code = 0; + for (size_t i = 0; i < len; i++) + code ^= p[i]; + return code; +} + +static uint8_t refHash(const char *name, const uint8_t *keyBytes, size_t keyLen) +{ + return refXorHash((const uint8_t *)name, strlen(name)) ^ refXorHash(keyBytes, keyLen); +} + +// Golden values, derived by hand from the algorithm above (pinned so a helper bug cannot +// silently re-derive a wrong expectation): +// xorHash("LongFast") = 'L'^'o'^'n'^'g'^'F'^'a'^'s'^'t' = 0x0A +// xorHash(defaultpsk) = d4^f1^bb^3a^20^29^07^59^f0^bc^ff^ab^cf^4e^69^01 = 0x02 +// hash(default LongFast channel) = 0x0A ^ 0x02 = 0x08 +static const int16_t GOLDEN_LONGFAST_HASH = 0x08; +static const uint8_t GOLDEN_LONGFAST_NAME_XOR = 0x0A; +static const uint8_t GOLDEN_DEFAULTPSK_XOR = 0x02; + +// --- Fixture helpers --- + +// A 16-byte-of-0xEE sentinel armed before each test so "crypto key unchanged" is a real +// assertion instead of an accident of whatever the previous test left behind. +static const uint8_t kSentinelByte = 0xEE; + +static void armCryptoSentinel() +{ + CryptoKey s; + memset(s.bytes, kSentinelByte, sizeof(s.bytes)); + s.length = 16; + crypto->setKey(s); +} + +static bool cryptoKeyIsSentinel() +{ + if (crypto->key.length != 16) + return false; + for (int i = 0; i < 16; i++) + if (crypto->key.bytes[i] != kSentinelByte) + return false; + return true; +} + +static void expectCryptoKey(const uint8_t *expected, int len) +{ + TEST_ASSERT_EQUAL_INT(len, crypto->key.length); + if (len > 0) + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, crypto->key.bytes, (uint32_t)len); +} + +// Write a slot directly and re-run fixupChannel() so the hash cache tracks the edit, +// mirroring how the admin/config paths mutate channelFile. +static meshtastic_Channel &setSlot(uint8_t idx, meshtastic_Channel_Role role, const char *name, const uint8_t *psk, size_t pskLen) +{ + meshtastic_Channel &ch = channels.getByIndex(idx); + ch.index = idx; + ch.has_settings = true; + ch.role = role; + memset(&ch.settings, 0, sizeof(ch.settings)); + if (name) + strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); + if (psk && pskLen) + memcpy(ch.settings.psk.bytes, psk, pskLen); + ch.settings.psk.size = (pb_size_t)pskLen; + channels.fixupChannel(idx); + return ch; +} + +// Slot 0 as the canonical stock channel (1-byte PSK index 1, empty name -> preset name), +// independent of any USERPREFS_CHANNEL_0_* a build variant may bake into initDefaults(). +static void forceCanonicalDefaultSlot0() +{ + static const uint8_t defaultIndexPsk[1] = {0x01}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", defaultIndexPsk, 1); +} + +// ===================================================================================== +// Group 1: generateHash golden values and sensitivity +// ===================================================================================== + +void test_default_longfast_hash_is_golden() +{ + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); + // Cross-check the hand-derived constant against the reference algorithm on the + // expanded key (a 1-byte index-1 PSK expands to exactly defaultpsk). + TEST_ASSERT_EQUAL_UINT8((uint8_t)GOLDEN_LONGFAST_HASH, refHash("LongFast", defaultpsk, sizeof(defaultpsk))); + TEST_ASSERT_EQUAL_UINT8(GOLDEN_LONGFAST_NAME_XOR ^ GOLDEN_DEFAULTPSK_XOR, (uint8_t)GOLDEN_LONGFAST_HASH); +} + +void test_explicit_longfast_name_hashes_like_empty_name() +{ + // getName() substitutes the modem-preset display name for "" - so an explicit + // "LongFast" and the stock empty name MUST be wire-identical or the two devices + // silently stop decoding each other. + static const uint8_t defaultIndexPsk[1] = {0x01}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "LongFast", defaultIndexPsk, 1); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); +} + +void test_default_string_name_is_normalized() +{ + // fixupChannel() converts the legacy "Default" name to the "" short form. + static const uint8_t defaultIndexPsk[1] = {0x01}; + meshtastic_Channel &ch = setSlot(0, meshtastic_Channel_Role_PRIMARY, "Default", defaultIndexPsk, 1); + TEST_ASSERT_EQUAL_STRING("", ch.settings.name); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); +} + +void test_hash_differs_on_psk_only() +{ + // Same name, PSKs that differ in bytes AND xor -> different hashes. + static const uint8_t pskA[16] = {0x01}; + static const uint8_t pskB[16] = {0x02}; + setSlot(1, meshtastic_Channel_Role_SECONDARY, "alpha", pskA, sizeof(pskA)); + setSlot(2, meshtastic_Channel_Role_SECONDARY, "alpha", pskB, sizeof(pskB)); + TEST_ASSERT_TRUE(channels.getHash(1) >= 0); + TEST_ASSERT_TRUE(channels.getHash(2) >= 0); + TEST_ASSERT_NOT_EQUAL(channels.getHash(1), channels.getHash(2)); + TEST_ASSERT_EQUAL_UINT8(refHash("alpha", pskA, sizeof(pskA)), (uint8_t)channels.getHash(1)); + TEST_ASSERT_EQUAL_UINT8(refHash("alpha", pskB, sizeof(pskB)), (uint8_t)channels.getHash(2)); +} + +void test_hash_differs_on_name_only() +{ + static const uint8_t psk[16] = {0x01}; + setSlot(1, meshtastic_Channel_Role_SECONDARY, "alpha", psk, sizeof(psk)); + setSlot(2, meshtastic_Channel_Role_SECONDARY, "beta", psk, sizeof(psk)); + TEST_ASSERT_TRUE(channels.getHash(1) >= 0); + TEST_ASSERT_TRUE(channels.getHash(2) >= 0); + TEST_ASSERT_NOT_EQUAL(channels.getHash(1), channels.getHash(2)); +} + +void test_disabled_channel_has_invalid_hash() +{ + // Slot 3 was never configured: fixupChannel() in setUp left it DISABLED. + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(3).role); + TEST_ASSERT_EQUAL_INT16(-1, channels.getHash(3)); + // setActiveByIndex on it must refuse and must not touch the crypto key. + TEST_ASSERT_EQUAL_INT16(-1, channels.setActiveByIndex(3)); + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +// ===================================================================================== +// Group 2: getKey() PSK expansion and padding (observed via setActiveByIndex -> crypto->key, +// which is public under PIO_UNIT_TESTING) +// ===================================================================================== + +void test_psk_index_1_expands_to_defaultpsk() +{ + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.setActiveByIndex(0)); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +} + +void test_psk_index_2_bumps_last_byte() +{ + static const uint8_t psk[1] = {0x02}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, 1); + uint8_t expected[sizeof(defaultpsk)]; + memcpy(expected, defaultpsk, sizeof(defaultpsk)); + expected[sizeof(defaultpsk) - 1] = (uint8_t)(expected[sizeof(defaultpsk) - 1] + 1); // index 2 -> last byte +1 + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); + TEST_ASSERT_EQUAL_UINT8(refHash("LongFast", expected, sizeof(expected)), (uint8_t)channels.getHash(0)); +} + +void test_psk_index_0_disables_encryption() +{ + static const uint8_t psk[1] = {0x00}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, 1); + // Key length 0 = plaintext; the hash then covers the name alone. + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_NAME_XOR, channels.setActiveByIndex(0)); + TEST_ASSERT_EQUAL_INT8(0, crypto->key.length); +} + +void test_psk_index_255_boundary() +{ + static const uint8_t psk[1] = {0xFF}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, 1); + uint8_t expected[sizeof(defaultpsk)]; + memcpy(expected, defaultpsk, sizeof(defaultpsk)); + // last byte 0x01 + 0xFF - 1 = 0xFF: the full index range stays inside one uint8_t + expected[sizeof(defaultpsk) - 1] = 0xFF; + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); +} + +void test_short_key_pads_to_aes128() +{ + static const uint8_t psk[5] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, sizeof(psk)); + uint8_t expected[16] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5}; // bytes 5..15 zero-padded + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); +} + +void test_midsize_key_pads_to_aes256() +{ + uint8_t psk[24]; + for (size_t i = 0; i < sizeof(psk); i++) + psk[i] = (uint8_t)(0x40 + i); + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk, sizeof(psk)); + uint8_t expected[32] = {}; + memcpy(expected, psk, sizeof(psk)); // bytes 24..31 zero-padded + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(expected, sizeof(expected)); +} + +void test_exact_16_and_32_byte_keys_pass_through() +{ + uint8_t psk16[16]; + for (size_t i = 0; i < sizeof(psk16); i++) + psk16[i] = (uint8_t)(0x10 + i); + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk16, sizeof(psk16)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(psk16, sizeof(psk16)); + + uint8_t psk32[32]; + for (size_t i = 0; i < sizeof(psk32); i++) + psk32[i] = (uint8_t)(0x20 + i); + setSlot(0, meshtastic_Channel_Role_PRIMARY, "", psk32, sizeof(psk32)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(psk32, sizeof(psk32)); +} + +// ===================================================================================== +// Group 3: secondary key inheritance and the recursion guard +// ===================================================================================== + +void test_secondary_empty_psk_inherits_primary_key() +{ + setSlot(1, meshtastic_Channel_Role_SECONDARY, "second", nullptr, 0); + // Effective key is the primary's expanded key (defaultpsk); the hash mixes the + // secondary's OWN name with that inherited key: + // xorHash("second") = 's'^'e'^'c'^'o'^'n'^'d' = 0x10; 0x10 ^ 0x02 = 0x12 + TEST_ASSERT_EQUAL_INT16(0x12, channels.getHash(1)); + TEST_ASSERT_EQUAL_UINT8(refHash("second", defaultpsk, sizeof(defaultpsk)), (uint8_t)channels.getHash(1)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(1) >= 0); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +} + +void test_recursion_guard_primary_slot_marked_secondary() +{ + // Malformed config: the slot primaryIndex points at (0) is itself SECONDARY with no + // PSK. Without the chIndex != primaryIndex guard, getKey(0) would recurse into + // getKey(0) forever; the guarded path treats it as encryption-off instead. + setSlot(0, meshtastic_Channel_Role_SECONDARY, "", nullptr, 0); + TEST_ASSERT_EQUAL_UINT8(0, channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_NAME_XOR, channels.getHash(0)); // name-only hash + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_NAME_XOR, channels.setActiveByIndex(0)); + TEST_ASSERT_EQUAL_INT8(0, crypto->key.length); +} + +// ===================================================================================== +// Group 4: onConfigChanged() no-primary restore and setChannel() demotion +// ===================================================================================== + +void test_onconfigchanged_promotes_demoted_primary_slot_keeping_key() +{ + // Phone demotes every slot: the slot primaryIndex references is SECONDARY with real + // key material -> it must be promoted in place, NOT replaced with a default key. + static const uint8_t privatePsk[16] = {0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, + 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB}; + setSlot(0, meshtastic_Channel_Role_SECONDARY, "keep", privatePsk, sizeof(privatePsk)); + channels.onConfigChanged(); + + meshtastic_Channel &ch = channels.getByIndex(0); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, ch.role); + TEST_ASSERT_EQUAL_UINT8(0, channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL_UINT16(sizeof(privatePsk), ch.settings.psk.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privatePsk, ch.settings.psk.bytes, sizeof(privatePsk)); + TEST_ASSERT_TRUE(channels.setActiveByIndex(0) >= 0); + expectCryptoKey(privatePsk, sizeof(privatePsk)); +} + +void test_onconfigchanged_restores_default_when_all_disabled() +{ + // Every slot DISABLED (zeroed): promoting a zeroed slot would create a plaintext + // primary, so the restore must install the stock default channel instead. + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = MAX_NUM_CHANNELS; + channels.onConfigChanged(); + + meshtastic_Channel &ch = channels.getByIndex(channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, ch.role); + TEST_ASSERT_TRUE(ch.settings.psk.size >= 1); + TEST_ASSERT_TRUE(channels.setActiveByIndex(channels.getPrimaryIndex()) >= 0); + // The restored primary must never come up plaintext. + TEST_ASSERT_TRUE(crypto->key.length > 0); +#if !defined(USERPREFS_CHANNEL_0_PSK) && !defined(USERPREFS_CHANNEL_0_NAME) + // Stock build: the restored channel is exactly the default LongFast channel. + TEST_ASSERT_EQUAL_UINT8(0, channels.getPrimaryIndex()); + TEST_ASSERT_EQUAL_UINT16(1, ch.settings.psk.size); + TEST_ASSERT_EQUAL_UINT8(0x01, ch.settings.psk.bytes[0]); + TEST_ASSERT_EQUAL_INT16(GOLDEN_LONGFAST_HASH, channels.getHash(0)); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +#endif +} + +void test_setchannel_demotes_old_primary() +{ + static const uint8_t psk[1] = {0x02}; + meshtastic_Channel c = meshtastic_Channel_init_zero; + c.index = 1; + c.role = meshtastic_Channel_Role_PRIMARY; + c.has_settings = true; + strncpy(c.settings.name, "boss", sizeof(c.settings.name) - 1); + memcpy(c.settings.psk.bytes, psk, sizeof(psk)); + c.settings.psk.size = sizeof(psk); + + channels.setChannel(c); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_SECONDARY, channels.getByIndex(0).role); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, channels.getByIndex(1).role); + + // primaryIndex tracks the change only once onConfigChanged() re-scans. + channels.onConfigChanged(); + TEST_ASSERT_EQUAL_UINT8(1, channels.getPrimaryIndex()); +} + +// ===================================================================================== +// Group 5: decryptForHash() bounds - regression pin for #11046 (cfecef537). Pre-fix the +// bound was `>`, so chIndex == getNumChannels() read one past hashes[] on the hot decode +// path for every received packet. +// ===================================================================================== + +void test_decryptforhash_rejects_out_of_range_index() +{ + const ChannelIndex n = channels.getNumChannels(); + TEST_ASSERT_EQUAL_UINT8(MAX_NUM_CHANNELS, n); + TEST_ASSERT_FALSE(channels.decryptForHash(n, (ChannelHash)channels.getHash(0))); + TEST_ASSERT_FALSE(channels.decryptForHash((ChannelIndex)(n + 1), (ChannelHash)channels.getHash(0))); + TEST_ASSERT_FALSE(channels.decryptForHash((ChannelIndex)MAX_NUM_CHANNELS, 0x08)); + TEST_ASSERT_FALSE(channels.decryptForHash((ChannelIndex)255, 0x08)); + // A rejected index must not have touched the crypto key. + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +void test_decryptforhash_accepts_valid_index_and_hash() +{ + TEST_ASSERT_TRUE(channels.decryptForHash(0, (ChannelHash)GOLDEN_LONGFAST_HASH)); + expectCryptoKey(defaultpsk, sizeof(defaultpsk)); +} + +void test_decryptforhash_rejects_wrong_hash() +{ + TEST_ASSERT_FALSE(channels.decryptForHash(0, (ChannelHash)(GOLDEN_LONGFAST_HASH + 1))); + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +void test_decryptforhash_disabled_slot_matches_no_hash() +{ + // A DISABLED slot's cached hash is -1 (int16), which no 0-255 wire hash can equal. + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(3).role); + for (int h = 0; h <= 255; h++) + TEST_ASSERT_FALSE(channels.decryptForHash(3, (ChannelHash)h)); + TEST_ASSERT_TRUE(cryptoKeyIsSentinel()); +} + +// ===================================================================================== +// Group 6: Router perhapsDecode() same-hash fall-through. Two enabled channels can share +// a hash (it is one xor byte); the decoder must try each candidate and commit the one +// whose key authenticates a well-formed Data, rewriting p->channel from hash to INDEX - +// the value admin-channel authorization consumes downstream. +// +// Skipped on event builds: their decode path runs isBlockedEventCoordinatePacket() -> +// willUsePki(), which dereferences the nodeDB this suite deliberately never constructs +// (keeping it free of disk writes). +// ===================================================================================== + +#if !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + +// Same name + PSKs with equal xor but different bytes -> identical hash, different keys. +static const uint8_t kClashPskA[16] = {0x01}; +static const uint8_t kClashPskB[16] = {0x00, 0x01}; + +static uint8_t configureCollisionChannels() +{ + setSlot(1, meshtastic_Channel_Role_SECONDARY, "clash", kClashPskA, sizeof(kClashPskA)); + setSlot(2, meshtastic_Channel_Role_SECONDARY, "clash", kClashPskB, sizeof(kClashPskB)); + TEST_ASSERT_TRUE(channels.getHash(1) >= 0); + TEST_ASSERT_EQUAL_INT16(channels.getHash(1), channels.getHash(2)); + // Nonzero hash keeps perhapsDecode() off the PKI-candidate branch (p->channel == 0), + // which would dereference the nodeDB this suite deliberately never constructs. + TEST_ASSERT_TRUE(channels.getHash(1) != 0); + return (uint8_t)channels.getHash(1); +} + +static meshtastic_Data makeProbeData() +{ + meshtastic_Data d = meshtastic_Data_init_zero; + d.portnum = meshtastic_PortNum_POSITION_APP; + static const char probe[] = "collision-probe"; + memcpy(d.payload.bytes, probe, sizeof(probe)); + d.payload.size = sizeof(probe); + return d; +} + +// Encrypts with whatever key is currently loaded into the crypto engine. +static meshtastic_MeshPacket makeEncryptedPacket(uint8_t channelHash, const meshtastic_Data &d) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = 0x11223344; + p.to = NODENUM_BROADCAST; // broadcast: no unicast-only branches + p.id = 0xA5A5A5A5; + p.channel = channelHash; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = (pb_size_t)pb_encode_to_bytes(p.encrypted.bytes, sizeof(p.encrypted.bytes), &meshtastic_Data_msg, &d); + TEST_ASSERT_TRUE(p.encrypted.size > 0); + crypto->encryptPacket(p.from, p.id, p.encrypted.size, p.encrypted.bytes); + return p; +} + +void test_perhapsdecode_collision_selects_matching_psk() +{ + // is_licensed short-circuits the legacy-DM isToUs() check inside perhapsDecode(), + // which would otherwise dereference the absent nodeDB (restored in tearDown). + owner.is_licensed = true; + const uint8_t h = configureCollisionChannels(); + const meshtastic_Data d = makeProbeData(); + + TEST_ASSERT_TRUE(channels.setActiveByIndex(2) >= 0); // encrypt with slot 2's key + meshtastic_MeshPacket p = makeEncryptedPacket(h, d); + + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_SUCCESS, perhapsDecode(&p)); + // Hash slot 1 was tried first and rejected; the committed channel is the INDEX 2. + TEST_ASSERT_EQUAL_UINT8(2, p.channel); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant); + TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_POSITION_APP, p.decoded.portnum); + TEST_ASSERT_EQUAL_UINT16(d.payload.size, p.decoded.payload.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(d.payload.bytes, p.decoded.payload.bytes, d.payload.size); +} + +void test_perhapsdecode_wrong_key_is_decode_failure() +{ + owner.is_licensed = true; + const uint8_t h = configureCollisionChannels(); + + // Encrypt with a key belonging to NO configured channel; the hash still matches + // slots 1 and 2, so a channel was tried -> DECODE_FAILURE, not DECODE_OPAQUE. + CryptoKey stranger; + memset(stranger.bytes, 0x5A, sizeof(stranger.bytes)); + stranger.length = 16; + crypto->setKey(stranger); + meshtastic_MeshPacket p = makeEncryptedPacket(h, makeProbeData()); + + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_FAILURE, perhapsDecode(&p)); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, p.which_payload_variant); +} + +void test_perhapsdecode_unknown_hash_is_opaque() +{ + owner.is_licensed = true; + configureCollisionChannels(); + + // Find a nonzero wire hash no enabled channel produces. + int candidate = -1; + for (int c = 1; c < 256 && candidate < 0; c++) { + bool used = false; + for (ChannelIndex i = 0; i < channels.getNumChannels(); i++) + if (channels.getHash(i) == c) + used = true; + if (!used) + candidate = c; + } + TEST_ASSERT_TRUE(candidate > 0); + TEST_MSG_FMT("unknown-hash probe uses 0x%02x", (unsigned)candidate); + + TEST_ASSERT_TRUE(channels.setActiveByIndex(2) >= 0); + meshtastic_MeshPacket p = makeEncryptedPacket((uint8_t)candidate, makeProbeData()); + + // No channel matched at all: the packet stays opaque (relayable ciphertext). + TEST_ASSERT_EQUAL_INT(DecodeState::DECODE_OPAQUE, perhapsDecode(&p)); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, p.which_payload_variant); +} + +#endif // !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + +// --- Unity lifecycle --- + +void setUp(void) +{ + memset(&channelFile, 0, sizeof(channelFile)); + memset(&config, 0, sizeof(config)); + owner.is_licensed = false; + channels.initDefaults(); // 8 slots + default lora config; only slot 0 populated + // Pin the preset the golden hashes assume ("" -> "LongFast"), in case a variant + // build's USERPREFS_LORACONFIG_MODEM_PRESET overrode it inside initDefaults(). + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + channels.onConfigChanged(); // computes the hash cache and primaryIndex + forceCanonicalDefaultSlot0(); + armCryptoSentinel(); +} + +void tearDown(void) +{ + owner.is_licensed = false; +} + +CK_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + // perhapsDecode() takes cryptLock; normally Router's ctor allocates it, but this + // suite never constructs a Router (nor a NodeDB - it must stay disk-write free). + if (!cryptLock) + cryptLock = new concurrency::Lock(); + UNITY_BEGIN(); + + printf("\n=== generateHash golden values ===\n"); + RUN_TEST(test_default_longfast_hash_is_golden); + RUN_TEST(test_explicit_longfast_name_hashes_like_empty_name); + RUN_TEST(test_default_string_name_is_normalized); + RUN_TEST(test_hash_differs_on_psk_only); + RUN_TEST(test_hash_differs_on_name_only); + RUN_TEST(test_disabled_channel_has_invalid_hash); + + printf("\n=== getKey expansion and padding ===\n"); + RUN_TEST(test_psk_index_1_expands_to_defaultpsk); + RUN_TEST(test_psk_index_2_bumps_last_byte); + RUN_TEST(test_psk_index_0_disables_encryption); + RUN_TEST(test_psk_index_255_boundary); + RUN_TEST(test_short_key_pads_to_aes128); + RUN_TEST(test_midsize_key_pads_to_aes256); + RUN_TEST(test_exact_16_and_32_byte_keys_pass_through); + + printf("\n=== secondary inheritance and recursion guard ===\n"); + RUN_TEST(test_secondary_empty_psk_inherits_primary_key); + RUN_TEST(test_recursion_guard_primary_slot_marked_secondary); + + printf("\n=== onConfigChanged restore and setChannel ===\n"); + RUN_TEST(test_onconfigchanged_promotes_demoted_primary_slot_keeping_key); + RUN_TEST(test_onconfigchanged_restores_default_when_all_disabled); + RUN_TEST(test_setchannel_demotes_old_primary); + + printf("\n=== decryptForHash bounds (#11046) ===\n"); + RUN_TEST(test_decryptforhash_rejects_out_of_range_index); + RUN_TEST(test_decryptforhash_accepts_valid_index_and_hash); + RUN_TEST(test_decryptforhash_rejects_wrong_hash); + RUN_TEST(test_decryptforhash_disabled_slot_matches_no_hash); + +#if !USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + printf("\n=== perhapsDecode same-hash fall-through ===\n"); + RUN_TEST(test_perhapsdecode_collision_selects_matching_psk); + RUN_TEST(test_perhapsdecode_wrong_key_is_decode_failure); + RUN_TEST(test_perhapsdecode_unknown_hash_is_opaque); +#endif + + exit(UNITY_END()); +} + +CK_TEST_ENTRY void loop() {} diff --git a/test/test_hop_start_policy/test_main.cpp b/test/test_hop_start_policy/test_main.cpp new file mode 100644 index 000000000..84cc7a9c8 --- /dev/null +++ b/test/test_hop_start_policy/test_main.cpp @@ -0,0 +1,352 @@ +#include "MeshTypes.h" // Include BEFORE TestUtil.h (provides NodeNum, isFromUs) +#include "TestUtil.h" +#include + +#include "configuration.h" // MESHTASTIC_PREHOP_DROP +#include "mesh/NodeDB.h" // classifyHopStart, shouldDropPacketForPreHop, HopStartStatus +#include +#include + +// TEST_MESSAGE emits file:line:INFO lines visible at -vv; printf lines appear un-prefixed. +// TEST_MSG_FMT wraps TEST_MESSAGE for formatted per-case diagnostics. +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + +static constexpr NodeNum kLocalNode = 0x11111111; +static constexpr NodeNum kRemoteNode = 0x22222222; + +// shouldDropPacketForPreHop -> isFromUs -> nodeDB->getNodeNum(), so a real NodeDB must be live. +static NodeDB *testNodeDB = nullptr; + +// --------------------------------------------------------------------------- +// Packet builders +// --------------------------------------------------------------------------- + +// A still-encrypted packet as Router::perhapsHandleReceived sees it (Router.cpp:1598): the +// channel-encrypted bitfield is unreadable, so the union's decoded half is untouched garbage. +static meshtastic_MeshPacket makeEncrypted(NodeNum from, uint8_t hopStart, uint8_t hopLimit) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = NODENUM_BROADCAST; + p.id = 0x1000u + (uint32_t)hopStart * 16u + hopLimit; + p.hop_start = hopStart; + p.hop_limit = hopLimit; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 16; // opaque ciphertext; contents irrelevant to hop classification + return p; +} + +// A decoded packet as Router::handleReceived sees it post-decrypt (Router.cpp:1450). +static meshtastic_MeshPacket makeDecoded(NodeNum from, uint8_t hopStart, uint8_t hopLimit, bool hasBitfield) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = NODENUM_BROADCAST; + p.id = 0x2000u + (uint32_t)hopStart * 16u + hopLimit; + p.hop_start = hopStart; + p.hop_limit = hopLimit; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.has_bitfield = hasBitfield; + p.decoded.bitfield = hasBitfield ? 1 : 0; + return p; +} + +static void assertClassify(const meshtastic_MeshPacket &p, HopStartStatus expected, const char *label) +{ + HopStartStatus got = classifyHopStart(p); + TEST_MSG_FMT("%-44s hop_start=%u hop_limit=%u -> %d (expect %d)", label, (unsigned)p.hop_start, (unsigned)p.hop_limit, + (int)got, (int)expected); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)expected, (int)got, label); +} + +// The shared predicate Router::dispatchReceived uses to set skipHandle, so gate drift fails here. +// (The cancelSending side effect stays uncovered.) +static bool routerPostDecodeWouldSkip(const meshtastic_MeshPacket &p) +{ + return shouldSkipHandleForPostDecodeHop(p); +} + +// --------------------------------------------------------------------------- +// classifyHopStart truth table +// --------------------------------------------------------------------------- + +void test_classify_invalid_when_hop_start_below_hop_limit() +{ + TEST_MESSAGE("=== hop_start < hop_limit is provably corrupt on any payload variant ==="); + + assertClassify(makeEncrypted(kRemoteNode, 2, 5), HopStartStatus::INVALID, "encrypted 2/5"); + assertClassify(makeEncrypted(kRemoteNode, 0, 1), HopStartStatus::INVALID, "encrypted 0/1"); + assertClassify(makeEncrypted(kRemoteNode, 0, 3), HopStartStatus::INVALID, "encrypted 0/3 (not UNKNOWN: limit > 0)"); + // The bitfield cannot rescue an inconsistent pair - the guard runs before the zero-hop probe. + assertClassify(makeDecoded(kRemoteNode, 2, 5, true), HopStartStatus::INVALID, "decoded+bitfield 2/5"); + assertClassify(makeDecoded(kRemoteNode, 0, 3, true), HopStartStatus::INVALID, "decoded+bitfield 0/3"); + assertClassify(makeDecoded(kRemoteNode, 0, 3, false), HopStartStatus::INVALID, "decoded no-bitfield 0/3"); +} + +void test_classify_valid_when_hop_start_covers_hop_limit() +{ + TEST_MESSAGE("=== hop_start > 0 and >= hop_limit is VALID regardless of variant or bitfield ==="); + + assertClassify(makeEncrypted(kRemoteNode, 3, 3), HopStartStatus::VALID, "encrypted 3/3 (fresh broadcast)"); + assertClassify(makeEncrypted(kRemoteNode, 3, 0), HopStartStatus::VALID, "encrypted 3/0 (fully relayed)"); + assertClassify(makeEncrypted(kRemoteNode, 5, 2), HopStartStatus::VALID, "encrypted 5/2 (mid-relay)"); + assertClassify(makeDecoded(kRemoteNode, 3, 3, false), HopStartStatus::VALID, "decoded no-bitfield 3/3"); + assertClassify(makeDecoded(kRemoteNode, 1, 0, false), HopStartStatus::VALID, "decoded no-bitfield 1/0"); +} + +void test_classify_zero_hop_modern_beacon_valid() +{ + TEST_MESSAGE("=== 0/0 decoded with bitfield = modern zero-hop broadcast, VALID ==="); + + assertClassify(makeDecoded(kRemoteNode, 0, 0, true), HopStartStatus::VALID, "decoded+bitfield 0/0 (beacon)"); +} + +void test_classify_zero_hop_decoded_without_bitfield_unknown() +{ + TEST_MESSAGE("=== 0/0 decoded without bitfield = pre-2.3.0 origin, MISSING_OR_UNKNOWN ==="); + + assertClassify(makeDecoded(kRemoteNode, 0, 0, false), HopStartStatus::MISSING_OR_UNKNOWN, "decoded no-bitfield 0/0"); +} + +void test_classify_zero_hop_encrypted_is_unknown() +{ + TEST_MESSAGE("=== 0/0 encrypted: bitfield unreadable pre-decode, MISSING_OR_UNKNOWN ==="); + + assertClassify(makeEncrypted(kRemoteNode, 0, 0), HopStartStatus::MISSING_OR_UNKNOWN, "encrypted 0/0"); +} + +void test_classify_encrypted_variant_ignores_stale_union_bitfield() +{ + TEST_MESSAGE("=== stale decoded-union bytes must not leak through the variant check ==="); + + // Adversarial struct state: payload variant says encrypted, but the union's decoded half + // still claims has_bitfield (e.g. a reused pool packet). The variant tag must gate the read. + meshtastic_MeshPacket p = makeEncrypted(kRemoteNode, 0, 0); + p.decoded.has_bitfield = true; + p.decoded.bitfield = 1; + assertClassify(p, HopStartStatus::MISSING_OR_UNKNOWN, "encrypted 0/0 w/ stale union bitfield"); +} + +void test_classify_hop_cap_boundaries() +{ + TEST_MESSAGE("=== boundaries at the 3-bit wire cap (HOP_MAX=7) and uint8 extremes ==="); + + assertClassify(makeEncrypted(kRemoteNode, 7, 7), HopStartStatus::VALID, "encrypted 7/7 (max fresh)"); + assertClassify(makeEncrypted(kRemoteNode, 7, 0), HopStartStatus::VALID, "encrypted 7/0 (max relayed out)"); + assertClassify(makeEncrypted(kRemoteNode, 6, 7), HopStartStatus::INVALID, "encrypted 6/7 (one below limit)"); + // Above the wire cap: unreachable from radio (3-bit fields) but reachable via phone input, + // where hop fields are plain uint8 in the struct. + assertClassify(makeEncrypted(kRemoteNode, 7, 8), HopStartStatus::INVALID, "encrypted 7/8 (limit past cap)"); + assertClassify(makeEncrypted(kRemoteNode, 255, 255), HopStartStatus::VALID, "encrypted 255/255"); + assertClassify(makeEncrypted(kRemoteNode, 254, 255), HopStartStatus::INVALID, "encrypted 254/255"); +} + +// --------------------------------------------------------------------------- +// Pre-decode drop policy (Router.cpp:1598 gate) and post-decode re-check +// --------------------------------------------------------------------------- + +#if MESHTASTIC_PREHOP_DROP + +void test_predecode_drops_provably_corrupt_only() +{ + TEST_MESSAGE("=== pre-decode gate drops only INVALID; VALID passes ==="); + + TEST_ASSERT_TRUE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 2, 5)), "corrupt 2/5 must drop"); + TEST_ASSERT_TRUE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 0, 3)), "corrupt 0/3 must drop"); + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 3, 3)), "valid 3/3 must pass"); + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 5, 2)), "valid 5/2 must pass"); +} + +void test_predecode_keeps_unknown_encrypted() +{ + TEST_MESSAGE("=== REGRESSION (#10758): MISSING_OR_UNKNOWN must survive the pre-decode gate ==="); + TEST_MESSAGE("Pre-fix, every non-VALID verdict dropped here - silently discarding all encrypted"); + TEST_MESSAGE("traffic whose proving bitfield was still under the channel key."); + + meshtastic_MeshPacket p = makeEncrypted(kRemoteNode, 0, 0); + TEST_ASSERT_EQUAL_INT((int)HopStartStatus::MISSING_OR_UNKNOWN, (int)classifyHopStart(p)); + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(p), "unknown-yet packet dropped before decryption"); +} + +void test_predecode_from_us_exempt() +{ + TEST_MESSAGE("=== local-origin packets are never pre-hop dropped, even when corrupt ==="); + + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(kLocalNode, 2, 5)), "own node num exempt"); + // from == 0 also counts as us (isFromUs), e.g. phone-injected packets pre-numbering. + TEST_ASSERT_FALSE_MESSAGE(shouldDropPacketForPreHop(makeEncrypted(0, 2, 5)), "from==0 exempt"); +} + +void test_postdecode_recheck_catches_unknown() +{ + TEST_MESSAGE("=== the pre/post-decode asymmetry: UNKNOWN passes the gate, then skipHandle ==="); + + // Pre-decode the packet is opaque 0/0 -> kept; post-decode the absent bitfield proves a + // pre-hop-firmware origin -> Router.cpp:1450 sets skipHandle. This split IS the fix; a + // cleanup that collapses the two checks into one re-creates the mesh-wide drop. + meshtastic_MeshPacket preHopOrigin = makeDecoded(kRemoteNode, 0, 0, false); + TEST_ASSERT_FALSE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 0, 0))); + TEST_ASSERT_TRUE_MESSAGE(routerPostDecodeWouldSkip(preHopOrigin), "post-decode must exclude pre-hop origin"); + + meshtastic_MeshPacket modernBeacon = makeDecoded(kRemoteNode, 0, 0, true); + TEST_ASSERT_FALSE_MESSAGE(routerPostDecodeWouldSkip(modernBeacon), "modern zero-hop beacon must be handled"); + + meshtastic_MeshPacket ourOwn = makeDecoded(kLocalNode, 0, 0, false); + TEST_ASSERT_FALSE_MESSAGE(routerPostDecodeWouldSkip(ourOwn), "local-origin exempt post-decode too"); + + meshtastic_MeshPacket corrupt = makeDecoded(kRemoteNode, 2, 5, true); + TEST_ASSERT_TRUE_MESSAGE(routerPostDecodeWouldSkip(corrupt), "corrupt still excluded post-decode"); +} + +#else // !MESHTASTIC_PREHOP_DROP + +void test_prehop_disabled_never_drops() +{ + TEST_MESSAGE("=== MESHTASTIC_PREHOP_DROP=0: the gate is compiled out entirely ==="); + + TEST_ASSERT_FALSE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 2, 5))); + TEST_ASSERT_FALSE(shouldDropPacketForPreHop(makeEncrypted(kRemoteNode, 0, 0))); +} + +#endif // MESHTASTIC_PREHOP_DROP + +// --------------------------------------------------------------------------- +// Cross-check against getHopsAway +// --------------------------------------------------------------------------- + +void test_gethopsaway_agrees_with_classification() +{ + TEST_MESSAGE("=== getHopsAway yields a hop count iff classifyHopStart says VALID ==="); + + struct Case { + meshtastic_MeshPacket p; + const char *label; + }; + const Case cases[] = { + {makeEncrypted(kRemoteNode, 2, 5), "encrypted 2/5"}, + {makeEncrypted(kRemoteNode, 0, 3), "encrypted 0/3"}, + {makeEncrypted(kRemoteNode, 0, 0), "encrypted 0/0"}, + {makeEncrypted(kRemoteNode, 3, 3), "encrypted 3/3"}, + {makeEncrypted(kRemoteNode, 5, 2), "encrypted 5/2"}, + {makeEncrypted(kRemoteNode, 7, 0), "encrypted 7/0"}, + {makeDecoded(kRemoteNode, 0, 0, true), "decoded+bitfield 0/0"}, + {makeDecoded(kRemoteNode, 0, 0, false), "decoded no-bitfield 0/0"}, + {makeDecoded(kRemoteNode, 0, 3, true), "decoded+bitfield 0/3"}, + {makeDecoded(kRemoteNode, 4, 1, false), "decoded no-bitfield 4/1"}, + }; + + for (const Case &c : cases) { + const bool valid = classifyHopStart(c.p) == HopStartStatus::VALID; + const int8_t hops = getHopsAway(c.p, -1); + TEST_MSG_FMT("%-28s valid=%d hopsAway=%d", c.label, (int)valid, (int)hops); + if (valid) { + TEST_ASSERT_EQUAL_INT8_MESSAGE((int8_t)(c.p.hop_start - c.p.hop_limit), hops, c.label); + } else { + TEST_ASSERT_EQUAL_INT8_MESSAGE(-1, hops, c.label); + } + } +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +// Printed row and checked expectation come from one struct, so the summary cannot narrate a table +// the predicates no longer implement. Was TEST_MESSAGE-only, i.e. a case that could not fail. +void test_truth_table_summary() +{ +#if MESHTASTIC_PREHOP_DROP + constexpr bool kGate = true; +#else + constexpr bool kGate = false; +#endif + + struct Row { + meshtastic_MeshPacket p; + HopStartStatus expected; + bool preDrop; // shouldDropPacketForPreHop, gate compiled in + bool postSkip; // shouldSkipHandleForPostDecodeHop, ditto + const char *label; + }; + const Row rows[] = { + {makeDecoded(kRemoteNode, 2, 5, true), HopStartStatus::INVALID, true, true, + "hop_start0, >=limit | any variant | VALID | handled normally"}, + {makeDecoded(kRemoteNode, 0, 0, true), HopStartStatus::VALID, false, false, + "0/0 | decoded + bitfield | VALID | modern zero-hop beacon"}, + {makeDecoded(kRemoteNode, 0, 0, false), HopStartStatus::MISSING_OR_UNKNOWN, false, true, + "0/0 | decoded, no bitfield | UNKNOWN | kept pre-decode, skipHandle post-decode"}, + {makeEncrypted(kRemoteNode, 0, 0), HopStartStatus::MISSING_OR_UNKNOWN, false, true, + "0/0 | encrypted | UNKNOWN | kept pre-decode (bitfield unreadable)"}, + {makeDecoded(kLocalNode, 2, 5, true), HopStartStatus::INVALID, false, false, + "isFromUs | any | any | never dropped by pre-hop policy"}, + }; + + TEST_MESSAGE("=== classifyHopStart truth table ==="); + for (const Row &r : rows) { + TEST_MESSAGE(r.label); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)r.expected, (int)classifyHopStart(r.p), r.label); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)(kGate && r.preDrop), (int)shouldDropPacketForPreHop(r.p), r.label); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)(kGate && r.postSkip), (int)routerPostDecodeWouldSkip(r.p), r.label); + } +} + +// --------------------------------------------------------------------------- +// Unity lifecycle +// --------------------------------------------------------------------------- + +void setUp(void) +{ + if (!testNodeDB) + testNodeDB = new NodeDB(); + + config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + myNodeInfo.my_node_num = kLocalNode; + nodeDB = testNodeDB; +} + +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + + UNITY_BEGIN(); + + printf("\n=== classifyHopStart truth table ===\n"); + RUN_TEST(test_classify_invalid_when_hop_start_below_hop_limit); + RUN_TEST(test_classify_valid_when_hop_start_covers_hop_limit); + RUN_TEST(test_classify_zero_hop_modern_beacon_valid); + RUN_TEST(test_classify_zero_hop_decoded_without_bitfield_unknown); + RUN_TEST(test_classify_zero_hop_encrypted_is_unknown); + RUN_TEST(test_classify_encrypted_variant_ignores_stale_union_bitfield); + RUN_TEST(test_classify_hop_cap_boundaries); + + printf("\n=== Pre-hop drop policy ===\n"); +#if MESHTASTIC_PREHOP_DROP + RUN_TEST(test_predecode_drops_provably_corrupt_only); + RUN_TEST(test_predecode_keeps_unknown_encrypted); + RUN_TEST(test_predecode_from_us_exempt); + RUN_TEST(test_postdecode_recheck_catches_unknown); +#else + RUN_TEST(test_prehop_disabled_never_drops); +#endif + + printf("\n=== Cross-checks ===\n"); + RUN_TEST(test_gethopsaway_agrees_with_classification); + + printf("\n=== Summary ===\n"); + RUN_TEST(test_truth_table_summary); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index b67cf31ab..64ea0cd4d 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -86,8 +87,15 @@ class MockMeshService : public MeshService class MockNodeDB : public NodeDB { public: - meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override { return &emptyNode; } + // Per-NodeNum overlay on top of the shared node, so a test can make one endpoint known + // while another stays unknown; everything else keeps the shared-node semantics. + meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override + { + auto it = nodes_.find(n); + return it != nodes_.end() ? &it->second : &emptyNode; + } meshtastic_NodeInfoLite emptyNode = {}; + std::map nodes_; }; // Minimal RoutingModule needed to return values from sendAckNak. @@ -417,8 +425,10 @@ void setUp(void) // The shared MockNodeDB node is mutated by the XEdDSA policy tests (signer bit, public // key); reset it so state can't leak between tests. - if (mockNodeDB) + if (mockNodeDB) { mockNodeDB->emptyNode = meshtastic_NodeInfoLite(); + mockNodeDB->nodes_.clear(); + } router = mockRouter = new MockRouter(); service = mockMeshService = new MockMeshService(); @@ -758,7 +768,9 @@ void test_receiveIgnoresOwnPublishedMessages(void) TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty()); } -// Considers receiving one of our packets an acknowledgement of it being sent. +// Considers receiving one of our packets an acknowledgement of it being sent: hearing our own +// packet back on our own gateway topic synthesizes an implicit ACK, delivered locally through +// sendLocal() -> handleReceived() -> the phone queue, marked as arriving via MQTT transport. void test_receiveAcksOwnSentMessages(void) { meshtastic_MeshPacket p = decoded; @@ -766,13 +778,26 @@ void test_receiveAcksOwnSentMessages(void) unitTest->publish(&p, nodeDB->getNodeId().c_str()); - // FIXME: Better assertion for this test - // TEST_ASSERT_TRUE(mockRouter->packets_.empty()); - // TEST_ASSERT_EQUAL(1, mockRoutingModule->ackNacks_.size()); - // const auto &[err, to, idFrom, chIndex, hopLimit] = mockRoutingModule->ackNacks_.front(); - // TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, err); - // TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, to); - // TEST_ASSERT_EQUAL(p.id, idFrom); + // The implicit ACK is delivered locally, never enqueued as MQTT downlink ingress. + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); + + meshtastic_MeshPacket *ack = mockMeshService->getForPhone(); + TEST_ASSERT_NOT_NULL(ack); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, ack->which_payload_variant); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ROUTING_APP, ack->decoded.portnum); + TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, ack->to); + TEST_ASSERT_EQUAL(myNodeInfo.my_node_num, ack->from); + TEST_ASSERT_EQUAL(p.id, ack->decoded.request_id); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, ack->transport_mechanism); + + meshtastic_Routing routing = meshtastic_Routing_init_default; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(ack->decoded.payload.bytes, ack->decoded.payload.size, &meshtastic_Routing_msg, &routing)); + TEST_ASSERT_EQUAL(meshtastic_Routing_error_reason_tag, routing.which_variant); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, routing.error_reason); + + mockMeshService->releaseToPool(ack); + TEST_ASSERT_NULL(mockMeshService->getForPhone()); // exactly one ACK } // Should ignore our own messages from MQTT that were heard by other nodes. @@ -967,6 +992,208 @@ void test_receiveIgnoresInvalidHopLimit(void) TEST_ASSERT_TRUE(mockRouter->packets_.empty()); } +// =========================================================================== +// Downlink acceptance gates - shouldDropMqttDownlink + onReceiveProto policy +// =========================================================================== + +// hop_start above HOP_MAX is rejected even when hop_limit is valid. +void test_receiveIgnoresInvalidHopStart(void) +{ + meshtastic_MeshPacket p = decoded; + p.hop_start = 10; + p.hop_limit = 3; + + unitTest->publish(&p); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// The ignore_mqtt kill-switch drops every MQTT downlink. +void test_receiveDropsWhenIgnoreMqttSet(void) +{ + config.lora.ignore_mqtt = true; + + unitTest->publish(&decoded); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A sender listed in config.lora.ignore_incoming is dropped. +void test_receiveDropsSenderInIgnoreIncomingList(void) +{ + config.lora.ignore_incoming_count = 1; + config.lora.ignore_incoming[0] = decoded.from; + + unitTest->publish(&decoded); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A non-empty ignore list only drops matching senders - presence of the list alone must not drop. +void test_receiveAcceptsSenderNotInIgnoreIncomingList(void) +{ + config.lora.ignore_incoming_count = 2; + config.lora.ignore_incoming[0] = 99; + config.lora.ignore_incoming[1] = 100; + + unitTest->publish(&decoded); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); +} + +// A sender whose NodeDB entry carries the is_ignored bit is dropped (resurrect-ignored-node guard). +void test_receiveDropsNodeDbIgnoredSender(void) +{ + mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_IS_IGNORED_MASK; + + unitTest->publish(&decoded); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A packet claiming the broadcast address as its source is dropped. +void test_receiveDropsBroadcastSource(void) +{ + meshtastic_MeshPacket p = decoded; + p.from = NODENUM_BROADCAST; + + unitTest->publish(&p); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); + TEST_ASSERT_TRUE(mockRoutingModule->ackNacks_.empty()); +} + +// A broker cannot assert PKI authentication or a transport: every accepted downlink is laundered +// to pki_encrypted=false + TRANSPORT_MQTT + via_mqtt=true. pki_encrypted grants admin-level trust +// downstream, so a regression here is remote privilege escalation. +void test_receiveLaundersPkiAndTransportFields(void) +{ + meshtastic_MeshPacket p = decoded; + p.pki_encrypted = true; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + + unitTest->publish(&p); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); + const meshtastic_MeshPacket &r = mockRouter->packets_.front(); + TEST_ASSERT_FALSE(r.pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, r.transport_mechanism); + TEST_ASSERT_TRUE(r.via_mqtt); +} + +// PKI-topic envelopes are dropped when no channel has downlink enabled, even when addressed to us. +void test_receiveDropsPkiTopicWhenNoChannelHasDownlink(void) +{ + channelFile.channels[0].settings.downlink_enabled = false; + meshtastic_MeshPacket e = encrypted; + e.to = myNodeInfo.my_node_num; + + unitTest->publish(&e, "!87654321", "PKI"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// Any single downlink-enabled channel (here only a secondary) is enough to admit PKI envelopes. +void test_receiveAcceptsPkiTopicWithOnlySecondaryDownlink(void) +{ + channelFile.channels[0].settings.downlink_enabled = false; + channelFile.channels[1] = meshtastic_Channel{ + .index = 1, + .has_settings = true, + .settings = {.name = "second", .downlink_enabled = true}, + .role = meshtastic_Channel_Role_SECONDARY, + }; + channelFile.channels_count = 2; + channels.onConfigChanged(); + meshtastic_MeshPacket e = encrypted; + e.to = myNodeInfo.my_node_num; + + unitTest->publish(&e, "!87654321", "PKI"); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); +} + +// An encrypted PKI envelope not addressed to us needs both endpoints known with user info. +void test_receiveDropsPkiNotToUsWithUnknownEndpoints(void) +{ + unitTest->publish(&encrypted, "!87654321", "PKI"); // to=2; neither endpoint has user info + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +void test_receiveAcceptsPkiNotToUsWithKnownEndpoints(void) +{ + // MockNodeDB serves the same node for every NodeNum, so this marks both endpoints known. + mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; + + unitTest->publish(&encrypted, "!87654321", "PKI"); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); + const meshtastic_MeshPacket &r = mockRouter->packets_.front(); + TEST_ASSERT_TRUE(r.via_mqtt); + TEST_ASSERT_FALSE(r.pki_encrypted); // laundered even on the PKI topic + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT, r.transport_mechanism); +} + +// The endpoint gate is an AND: knowing only the sender (from=1) while the receiver (to=2) is +// unknown must still drop. Distinguishes && from || in the MQTT.cpp acceptance rule. +void test_receiveDropsPkiNotToUsWithOnlySenderKnown(void) +{ + mockNodeDB->nodes_[1].bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; // only from=1 known; to=2 stays unknown + + unitTest->publish(&encrypted, "!87654321", "PKI"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// An envelope naming a channel we do not have is dropped, even though getByName falls back to +// the primary channel - the case-sensitive global-id recheck must refuse the substitution. +void test_receiveDropsUnknownChannelName(void) +{ + unitTest->publish(&decoded, "!87654321", "nope"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// getByName matches case-insensitively, but the downlink gate compares case-sensitively; a +// mixed-case channel_id must not ride the primary channel's downlink permission. +void test_receiveDropsCaseMismatchedChannelName(void) +{ + unitTest->publish(&decoded, "!87654321", "TEST"); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A validly-decoding envelope missing channel_id is rejected before any gate runs. +void test_receiveRejectsEnvelopeWithoutChannelId(void) +{ + const meshtastic_ServiceEnvelope env = {.packet = const_cast(&decoded), + .channel_id = NULL, + .gateway_id = const_cast("!87654321")}; + uint8_t bytes[256]; + const size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env); + unitTest->deliverRaw("msh/2/e/test/!87654321", bytes, numBytes); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// Every strict prefix of a valid envelope must be rejected: either the truncated decode fails, or +// it succeeds with gateway_id (the last-encoded field) missing and the NULL check refuses it. +void test_receiveRejectsTruncatedEnvelope(void) +{ + const meshtastic_ServiceEnvelope env = {.packet = const_cast(&decoded), + .channel_id = const_cast("test"), + .gateway_id = const_cast("!87654321")}; + uint8_t bytes[256]; + const size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env); + TEST_ASSERT_TRUE(numBytes > 0); + + for (size_t n = 1; n < numBytes; n++) + unitTest->deliverRaw("msh/2/e/test/!87654321", bytes, n); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + // Publishing to a text channel. void test_publishTextMessageDirect(void) { @@ -1295,6 +1522,22 @@ void setup() #endif RUN_TEST(test_receiveIgnoresUnexpectedFields); RUN_TEST(test_receiveIgnoresInvalidHopLimit); + RUN_TEST(test_receiveIgnoresInvalidHopStart); + RUN_TEST(test_receiveDropsWhenIgnoreMqttSet); + RUN_TEST(test_receiveDropsSenderInIgnoreIncomingList); + RUN_TEST(test_receiveAcceptsSenderNotInIgnoreIncomingList); + RUN_TEST(test_receiveDropsNodeDbIgnoredSender); + RUN_TEST(test_receiveDropsBroadcastSource); + RUN_TEST(test_receiveLaundersPkiAndTransportFields); + RUN_TEST(test_receiveDropsPkiTopicWhenNoChannelHasDownlink); + RUN_TEST(test_receiveAcceptsPkiTopicWithOnlySecondaryDownlink); + RUN_TEST(test_receiveDropsPkiNotToUsWithUnknownEndpoints); + RUN_TEST(test_receiveAcceptsPkiNotToUsWithKnownEndpoints); + RUN_TEST(test_receiveDropsPkiNotToUsWithOnlySenderKnown); + RUN_TEST(test_receiveDropsUnknownChannelName); + RUN_TEST(test_receiveDropsCaseMismatchedChannelName); + RUN_TEST(test_receiveRejectsEnvelopeWithoutChannelId); + RUN_TEST(test_receiveRejectsTruncatedEnvelope); RUN_TEST(test_receiveFuzzServiceEnvelope); RUN_TEST(test_publishTextMessageDirect); RUN_TEST(test_publishTextMessageWithProxy); diff --git a/test/test_nodedb_boot_recovery/test_main.cpp b/test/test_nodedb_boot_recovery/test_main.cpp new file mode 100644 index 000000000..0c2f537df --- /dev/null +++ b/test/test_nodedb_boot_recovery/test_main.cpp @@ -0,0 +1,396 @@ +// NodeDB boot-recovery contract: an undecodable config.proto must freeze identity (no keygen, no +// overwrite), an absent one takes the fresh-install path, and a corrupt nodes.proto does neither. +// The tests are a ladder (state=per-suite): arrange /prefs, then "reboot" a fresh NodeDB. +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NBR_TEST_ENTRY extern "C" +#else +#define NBR_TEST_ENTRY +#endif + +#include "FSCommon.h" // defines FSCom; must precede the feature guard below + +// The identity-freeze contract only exists where there is a filesystem and boot keygen. +#if defined(FSCom) && !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/NodeDB.h" +#include "mesh/TypeConversions.h" +#include +#include +#include +#include + +// Friend seam declared in NodeDB.h (PIO_UNIT_TESTING): read the private degraded-boot flag. +// Never instantiated - constructing one would run the real boot sequence. +class NodeDBTestShim : public NodeDB +{ + public: + static bool decodeFailed(const NodeDB *db) { return db->configDecodeFailed; } +}; + +namespace +{ + +// --- Identity baseline captured after a healthy keyed boot --- +uint32_t baseNodeNum = 0; +uint8_t basePublicKey[32]; +uint8_t basePrivateKey[32]; +char baseLongName[sizeof(meshtastic_User::long_name)]; +std::vector goodConfigBytes; // byte-exact healthy config.proto for restore tests + +// --- File helpers (through FSCom so the tests stay agnostic about the mountpoint) --- + +bool readFileBytes(const char *path, std::vector &out) +{ + out.clear(); + File f = FSCom.open(path, FILE_O_READ); + if (!f) + return false; + uint8_t buf[512]; + size_t n; + while ((n = f.read(buf, sizeof(buf))) > 0) + out.insert(out.end(), buf, buf + n); + f.close(); + return true; +} + +void writeFileBytes(const char *path, const uint8_t *data, size_t len) +{ + FSCom.remove(path); // FILE_O_WRITE is append on some backends; start clean + File f = FSCom.open(path, FILE_O_WRITE); + TEST_ASSERT_TRUE_MESSAGE(f, path); + TEST_ASSERT_EQUAL_size_t(len, f.write(data, len)); + f.close(); +} + +// FNV-1a content fingerprint; answers only "did this file change?". 0 == missing file. +uint64_t fileFingerprint(const char *path) +{ + std::vector bytes; + if (!readFileBytes(path, bytes)) + return 0; + uint64_t h = 1469598103934665603ULL; + for (uint8_t b : bytes) { + h ^= b; + h *= 1099511628211ULL; + } + return h; +} + +// A varint tag of five 0xFF bytes overflows 32 bits, so nanopb fails deterministically. +const uint8_t kGarbage[32] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// --- Reboot helper --- + +// A real boot starts with a zeroed nodeDatabase; in-process the global retains the previous +// boot's vector (the decode callback appends, it does not clear), so reset it first. +void rebootNodeDB() +{ + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + NodeDB *rebooted = new NodeDB(); + delete nodeDB; + nodeDB = rebooted; +} + +void captureIdentityBaseline() +{ + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + baseNodeNum = myNodeInfo.my_node_num; + memcpy(basePublicKey, config.security.public_key.bytes, 32); + memcpy(basePrivateKey, config.security.private_key.bytes, 32); + strncpy(baseLongName, owner.long_name, sizeof(baseLongName)); + baseLongName[sizeof(baseLongName) - 1] = '\0'; + TEST_ASSERT_TRUE(readFileBytes(configFileName, goodConfigBytes)); + TEST_ASSERT_GREATER_THAN(1, goodConfigBytes.size()); +} + +// Persist a set region so boot keygen is unconditionally armed (generateCryptoKeyPair skips +// while region == UNSET unless the portduino sim-radio bypass applies), then reboot into the +// healthy keyed state every later test measures against. +void establishHealthyBaseline() +{ + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_CONFIG)); + rebootNodeDB(); + // Reboot once more so any boot-time coercion of the freshly saved config (preset clamp) + // has reached its fixpoint on disk before we fingerprint it as the "good" file. + rebootNodeDB(); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + captureIdentityBaseline(); +} + +void assertIdentityMatchesBaseline() +{ + TEST_ASSERT_EQUAL_UINT32(baseNodeNum, myNodeInfo.my_node_num); + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, config.security.public_key.bytes, 32); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePrivateKey, config.security.private_key.bytes, 32); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, owner.public_key.bytes, 32); +} + +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Healthy-boot identity --- + +// The #11001 renumber family: a keyed boot must mint NodeNum == crc32(public_key) once, and +// every subsequent reboot must reproduce the same NodeNum, keypair and owner identity. +static void test_firstBoot_establishesKeyedIdentity(void) +{ + TEST_MESSAGE("=== First keyed boot mints crc32(pubkey) identity ==="); + establishHealthyBaseline(); + + TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, 32), myNodeInfo.my_node_num); + // The minted identity is in the store of record: self entry present, carrying our key. + const meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum()); + TEST_ASSERT_NOT_NULL(self); + TEST_ASSERT_TRUE(nodeInfoLiteHasUser(self)); + TEST_ASSERT_EQUAL(32, self->public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, self->public_key.bytes, 32); + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); +} + +static void test_healthyReboot_preservesIdentity(void) +{ + TEST_MESSAGE("=== Plain reboot: identity byte-identical, config.proto not rewritten ==="); + const uint64_t fpBefore = fileFingerprint(configFileName); + TEST_ASSERT_NOT_EQUAL(0, fpBefore); + + rebootNodeDB(); + + assertIdentityMatchesBaseline(); + TEST_ASSERT_EQUAL_STRING(baseLongName, owner.long_name); + // A healthy boot has nothing to persist for config: the on-disk file is already the fixpoint. + TEST_ASSERT_EQUAL_UINT64(fpBefore, fileFingerprint(configFileName)); +} + +// --- Degraded boot: present-but-undecodable config --- + +static void test_corruptConfig_freezesIdentity_leavesFileUntouched(void) +{ + TEST_MESSAGE("=== Corrupt config.proto: frozen identity, radio silent, file untouched ==="); + writeFileBytes(configFileName, kGarbage, sizeof(kGarbage)); + const uint64_t fpGarbage = fileFingerprint(configFileName); + TEST_ASSERT_NOT_EQUAL(0, fpGarbage); + + rebootNodeDB(); + + TEST_ASSERT_TRUE(NodeDBTestShim::decodeFailed(nodeDB)); + // Radio silent until the operator restores a config. + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_FALSE(config.lora.tx_enabled); + // Keygen skipped: no replacement keypair minted into RAM... + TEST_ASSERT_EQUAL(0, config.security.private_key.size); + // ...and the identity carried by devicestate is untouched, so the NodeNum cannot move. + TEST_ASSERT_EQUAL_UINT32(baseNodeNum, myNodeInfo.my_node_num); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + TEST_ASSERT_EQUAL_MEMORY(basePublicKey, owner.public_key.bytes, 32); + // The boot must not have overwritten the (maybe transiently) corrupt file with defaults. + TEST_ASSERT_EQUAL_UINT64(fpGarbage, fileFingerprint(configFileName)); +} + +// Runs against the still-degraded NodeDB from the previous test: runtime reconfiguration +// (admin set_config -> saveToDisk) must not be permanently blocked by the boot freeze. +static void test_degradedBoot_runtimeConfigSaveStillPersists(void) +{ + TEST_MESSAGE("=== Degraded boot: an explicit runtime config save still lands ==="); + TEST_ASSERT_TRUE(NodeDBTestShim::decodeFailed(nodeDB)); + const uint64_t fpGarbage = fileFingerprint(configFileName); + + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_CONFIG)); + + TEST_ASSERT_NOT_EQUAL(fpGarbage, fileFingerprint(configFileName)); + // What landed is a decodable config again (the degraded-boot defaults). + static meshtastic_LocalConfig scratch; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(configFileName, meshtastic_LocalConfig_size, + sizeof(scratch), &meshtastic_LocalConfig_msg, &scratch)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, scratch.lora.region); +} + +static void test_restoredConfig_recoversOriginalIdentity(void) +{ + TEST_MESSAGE("=== Good config bytes restored: next boot is normal with the ORIGINAL identity ==="); + writeFileBytes(configFileName, goodConfigBytes.data(), goodConfigBytes.size()); + + rebootNodeDB(); + + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_TRUE(config.lora.tx_enabled); + assertIdentityMatchesBaseline(); +} + +// --- Absent config: fresh install, not a freeze --- + +static void test_absentConfig_takesFreshInstallPath(void) +{ + TEST_MESSAGE("=== Absent config.proto: OTHER_FAILURE -> defaults + fresh keypair ==="); + uint8_t previousPublicKey[32]; + memcpy(previousPublicKey, basePublicKey, 32); + TEST_ASSERT_TRUE(FSCom.remove(configFileName)); + + rebootNodeDB(); + + // No usable contents to protect, so this is NOT the frozen path. + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + + // Re-arm keygen (region gate) and reboot into the replacement identity. + establishHealthyBaseline(); // re-captures the baseline for the remaining tests + + // A fresh install mints a new keypair - and with it a new NodeNum, still crc32-derived. + // (This is the flip side of the DECODE_FAILED freeze: with the file genuinely gone there + // is no identity left to preserve.) + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_TRUE(memcmp(previousPublicKey, config.security.public_key.bytes, 32) != 0); + TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, 32), myNodeInfo.my_node_num); + TEST_ASSERT_TRUE(FSCom.exists(configFileName)); +} + +// --- Freeze is config-scoped --- + +static void test_corruptNodesDb_doesNotFreezeIdentity(void) +{ + TEST_MESSAGE("=== Corrupt nodes.proto alone: config loads, keygen runs, NodeNum kept ==="); + const uint64_t fpConfig = fileFingerprint(configFileName); + writeFileBytes(nodeDatabaseFileName, kGarbage, sizeof(kGarbage)); + + rebootNodeDB(); + + TEST_ASSERT_FALSE(NodeDBTestShim::decodeFailed(nodeDB)); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + assertIdentityMatchesBaseline(); + // The store rebuilt from defaults still contains us. + const meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum()); + TEST_ASSERT_NOT_NULL(self); + TEST_ASSERT_TRUE(nodeInfoLiteHasUser(self)); + TEST_ASSERT_EQUAL_UINT64(fpConfig, fileFingerprint(configFileName)); +} + +// --- Devicestate-loss owner recovery --- + +// The recovery block in loadFromDisk() fires when device.proto decodes but is below +// DEVICESTATE_MIN_VER: identity fields survive (my_node_num is in the decoded struct), the +// defaults overwrite the owner names, and the own-node entry in nodes.proto restores them. +static void test_oldDevicestate_recoversOwnerFromNodeDb(void) +{ + TEST_MESSAGE("=== Old-version devicestate: owner names recovered from own NodeDB entry ==="); + // Put the recoverable names into the store of record... + strncpy(owner.long_name, "Recovered Owner", sizeof(owner.long_name)); + strncpy(owner.short_name, "RCVR", sizeof(owner.short_name)); + meshtastic_NodeInfoLite *self = nodeDB->getMeshNode(nodeDB->getNodeNum()); + TEST_ASSERT_NOT_NULL(self); + TypeConversions::CopyUserToNodeInfoLite(self, owner); + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_NODEDATABASE)); + + // ...then persist a devicestate that is valid but too old, carrying DIFFERENT names, so a + // recovered name can only have come from the nodes.proto entry. + strncpy(owner.long_name, "Stale Devicestate", sizeof(owner.long_name)); + strncpy(owner.short_name, "STAL", sizeof(owner.short_name)); + devicestate.version = DEVICESTATE_MIN_VER - 1; + TEST_ASSERT_TRUE(nodeDB->saveToDisk(SEGMENT_DEVICESTATE)); + + rebootNodeDB(); + + TEST_ASSERT_EQUAL_STRING("Recovered Owner", owner.long_name); + TEST_ASSERT_EQUAL_STRING("RCVR", owner.short_name); + // Identity survives the devicestate discard: the NodeNum in the old file is carried over + // and keygen re-derives the same crc32(public_key) value. + assertIdentityMatchesBaseline(); + + // The recovery is re-persisted: the on-disk devicestate is current-version with the + // recovered names, not the stale ones. + static meshtastic_DeviceState saved; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(deviceStateFileName, meshtastic_DeviceState_size, + sizeof(saved), &meshtastic_DeviceState_msg, &saved)); + TEST_ASSERT_EQUAL(DEVICESTATE_CUR_VER, saved.version); + TEST_ASSERT_EQUAL_STRING("Recovered Owner", saved.owner.long_name); +} + +// --- loadProto classification --- + +// The wipe cascade lived in the difference between these verdicts: DECODE_FAILED is the only +// protected path, and loadProto never returns NOT_FOUND (an unopenable file is OTHER_FAILURE). +static void test_loadProto_classifiesFailuresDistinctly(void) +{ + TEST_MESSAGE("=== loadProto: absent=OTHER_FAILURE, garbage/truncated=DECODE_FAILED ==="); + const char *scratchPath = "/prefs/nbr_scratch.proto"; + static meshtastic_LocalConfig scratch; + + FSCom.remove(scratchPath); + TEST_ASSERT_EQUAL(LoadFileResult::OTHER_FAILURE, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + writeFileBytes(scratchPath, kGarbage, sizeof(kGarbage)); + TEST_ASSERT_EQUAL(LoadFileResult::DECODE_FAILED, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + // A torn write: a valid encoding minus its final byte always cuts the last field short. + TEST_ASSERT_GREATER_THAN(1, goodConfigBytes.size()); + writeFileBytes(scratchPath, goodConfigBytes.data(), goodConfigBytes.size() - 1); + TEST_ASSERT_EQUAL(LoadFileResult::DECODE_FAILED, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + // The unmodified bytes still decode - the failure above was the truncation, nothing else. + writeFileBytes(scratchPath, goodConfigBytes.data(), goodConfigBytes.size()); + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, nodeDB->loadProto(scratchPath, meshtastic_LocalConfig_size, sizeof(scratch), + &meshtastic_LocalConfig_msg, &scratch)); + + FSCom.remove(scratchPath); // leave nothing behind +} + +NBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + nodeDB = new NodeDB(); // first boot on the pristine per-suite sandbox + + UNITY_BEGIN(); + + printf("\n=== Healthy-boot identity ===\n"); + RUN_TEST(test_firstBoot_establishesKeyedIdentity); + RUN_TEST(test_healthyReboot_preservesIdentity); + + printf("\n=== Degraded boot (corrupt config) ===\n"); + RUN_TEST(test_corruptConfig_freezesIdentity_leavesFileUntouched); + RUN_TEST(test_degradedBoot_runtimeConfigSaveStillPersists); + RUN_TEST(test_restoredConfig_recoversOriginalIdentity); + + printf("\n=== Fresh install vs freeze scoping ===\n"); + RUN_TEST(test_absentConfig_takesFreshInstallPath); + RUN_TEST(test_corruptNodesDb_doesNotFreezeIdentity); + + printf("\n=== Devicestate recovery + loadProto classification ===\n"); + RUN_TEST(test_oldDevicestate_recoversOwnerFromNodeDb); + RUN_TEST(test_loadProto_classifiesFailuresDistinctly); + + exit(UNITY_END()); +} + +NBR_TEST_ENTRY void loop() {} + +#else // !FSCom || PKI excluded + +void setUp(void) {} +void tearDown(void) {} + +NBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +NBR_TEST_ENTRY void loop() {} + +#endif diff --git a/test/test_nodedb_identity_hygiene/test_main.cpp b/test/test_nodedb_identity_hygiene/test_main.cpp new file mode 100644 index 000000000..6a2f7eaf9 --- /dev/null +++ b/test/test_nodedb_identity_hygiene/test_main.cpp @@ -0,0 +1,512 @@ +// Identity hygiene for the remote-identity commit paths in NodeDB: updateUser() key pinning and +// addFromContact() guards (a keyless contact must never erase a stored key - the #11432 regression). +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define IH_TEST_ENTRY extern "C" +#else +#define IH_TEST_ENTRY +#endif + +#include "FSCommon.h" +#include "SPILock.h" +#include "mesh/NodeDB.h" +#include "support/MockMeshService.h" +#include +#include + +// Subclass shim: the friend declaration in NodeDB.h grants access to the +// private state these tests must seed/reset (duplicateWarned latch, warm-tier +// demotion). Declared at global scope so it matches `friend class NodeDBTestShim`. +class NodeDBTestShim : public NodeDB +{ + public: + void clearHot() + { + meshNodes->clear(); + numMeshNodes = 0; + } + + // keySeed == 0 means "no stored key"; otherwise a deterministic 32-byte pattern. + void push(NodeNum num, uint32_t lastHeard, uint8_t keySeed = 0, bool xeddsaSigned = false) + { + meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; + n.num = num; + n.last_heard = lastHeard; + nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_USER_MASK, true); + if (keySeed) { + n.public_key.size = 32; + memset(n.public_key.bytes, keySeed, 32); + n.public_key.bytes[0] = 0x01; // never all-zero (all-zero == "no key") + } + if (xeddsaSigned) + nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + meshNodes->push_back(n); + numMeshNodes = meshNodes->size(); + } + + // Index 0 is our own node; eviction scans treat it as self. + void seedSelf() { push(0x0BADF00D, 0xFFFFFFFFu); } + + void resetDuplicateWarned() { duplicateWarned = false; } + +#if WARM_NODE_COUNT > 0 + void runDemote() { demoteOldestHotNodesToWarm(); } +#endif +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; +MockMeshService *mockService = nullptr; + +meshtastic_User savedOwner; +meshtastic_LocalConfig savedConfig; + +constexpr NodeNum kPeer = 0xE1000001; + +// Same pattern as NodeDBTestShim::push so a "matching" user key really matches. +template void fillKey(KeyT &k, uint8_t seed) +{ + k.size = 32; + memset(k.bytes, seed, 32); + k.bytes[0] = 0x01; +} + +meshtastic_User makeUser(const char *longName, const char *shortName, uint8_t keySeed = 0) +{ + meshtastic_User u = meshtastic_User_init_zero; + strncpy(u.long_name, longName, sizeof(u.long_name) - 1); + strncpy(u.short_name, shortName, sizeof(u.short_name) - 1); + if (keySeed) + fillKey(u.public_key, keySeed); + return u; +} + +meshtastic_SharedContact makeContact(NodeNum num, const char *longName, const char *shortName, uint8_t keySeed = 0) +{ + meshtastic_SharedContact c = meshtastic_SharedContact_init_zero; + c.node_num = num; + c.has_user = true; + c.user = makeUser(longName, shortName, keySeed); + return c; +} + +void assertStoredKeyEquals(NodeNum num, uint8_t seed) +{ + const meshtastic_NodeInfoLite *info = db->getMeshNode(num); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_EQUAL(32, info->public_key.size); + uint8_t expected[32]; + memset(expected, seed, 32); + expected[0] = 0x01; + TEST_ASSERT_EQUAL_MEMORY(expected, info->public_key.bytes, 32); +} + +} // namespace + +// --- addFromContact --- + +// The #11432 regression: a stored 32-byte key plus a contact with has_user=true +// but no key must keep the stored key bit-for-bit while still merging the user +// fields (clients send add_contact before every DM, usually keyless). +static void test_contact_keyless_preserves_stored_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + db->addFromContact(makeContact(kPeer, "Alice", "AL")); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); // merge still applied + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(info)); // anti-eviction stamp for normal roles +} + +// The guard blocks erasure, not update: a contact carrying a different valid +// 32-byte key replaces the stored one (the QR contact-sharing flow). +static void test_contact_new_key_updates_stored_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x77)); + + assertStoredKeyEquals(kPeer, 0x77); +} + +// A manually-verified pin refuses the ENTIRE update from a non-verified contact +// whose key mismatches - name and key both stay untouched. +static void test_contact_verified_pin_blocks_mismatched_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + + db->addFromContact(makeContact(kPeer, "Mallory", "MA", /*keySeed=*/0x77)); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("", info->long_name); // refused wholesale, not just the key + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); // returned before the favorite stamp + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); +} + +// The verified pin also refuses a KEYLESS non-verified contact wholesale (a +// size mismatch is a key mismatch) - unlike the plain erasure guard below, +// which merges the user fields and only restores the key. +static void test_contact_verified_pin_blocks_keyless_unverified(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + + db->addFromContact(makeContact(kPeer, "Alice", "AL")); // keyless, not verified + + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); +} + +// A non-verified contact whose key MATCHES the verified pin may still update +// the user fields; the verified bit survives the merge. +static void test_contact_verified_pin_allows_matching_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + + db->addFromContact(makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42)); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); +} + +// contact.manually_verified sets the bit, and a later plain update (here via +// updateUser with the pinned key) must not clear it - CopyUserToNodeInfoLite +// only touches the user-derived bits. +static void test_contact_manually_verified_bit_survives_updates(void) +{ + meshtastic_SharedContact c = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42); + c.manually_verified = true; + db->addFromContact(c); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(db->getMeshNode(kPeer))); + + meshtastic_User u = makeUser("Alice2", "A2", /*keySeed=*/0x42); + TEST_ASSERT_TRUE(db->updateUser(kPeer, u)); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice2", info->long_name); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); + assertStoredKeyEquals(kPeer, 0x42); +} + +// should_ignore blocks the contact and drops its satellite data but keeps the +// stored public key: an ignored peer stays a verifiable identity. +static void test_contact_should_ignore_blocks_but_keeps_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(kPeer), NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_PositionLite pos = meshtastic_PositionLite_init_zero; + pos.latitude_i = 123456789; + db->nodePositions[kPeer] = pos; + TEST_ASSERT_TRUE(db->hasNodePosition(kPeer)); +#endif + + meshtastic_SharedContact c = makeContact(kPeer, "Blocked", "BL"); // keyless on purpose + c.should_ignore = true; + db->addFromContact(c); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(info)); + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + TEST_ASSERT_FALSE(db->hasNodePosition(kPeer)); +#endif + assertStoredKeyEquals(kPeer, 0x42); // key retained through the keyless ignore contact +} + +// CLIENT_BASE must not auto-favorite (is_favorite has special meaning there); +// the anti-eviction protection is a heard-now stamp instead. +static void test_contact_client_base_stamps_heard_not_favorite(void) +{ + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT_BASE; + + db->addFromContact(makeContact(kPeer, "Alice", "AL")); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_NOT_NULL(info); + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(info)); + // initializeTestEnvironment() set an NTP-quality RTC, so the stamp lands in last_heard. + TEST_ASSERT_NOT_EQUAL(0, info->last_heard); +} + +// A contact without a user payload must not merge fields or apply should_ignore to an +// existing node. (getOrCreateMeshNode still runs first, so an unknown num would be +// admitted as a blank row - that path is not covered here.) +static void test_contact_without_user_is_noop(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + const size_t countBefore = db->getNumMeshNodes(); + + meshtastic_SharedContact c = meshtastic_SharedContact_init_zero; + c.node_num = kPeer; + c.has_user = false; + c.should_ignore = true; + db->addFromContact(c); + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(info)); + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_UINT(countBefore, db->getNumMeshNodes()); // existing node: no new row admitted +} + +// --- updateUser --- + +#if !(MESHTASTIC_EXCLUDE_PKI) + +// A pinned 32-byte key is immutable against a NodeInfo carrying a different key. +static void test_updateuser_pinned_key_blocks_mismatch(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + meshtastic_User u = makeUser("Mallory", "MA", /*keySeed=*/0x77); + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); // dropped wholesale +} + +// ...and against a NodeInfo carrying NO key: unlike addFromContact, updateUser +// drops a keyless update for a pinned node entirely. +static void test_updateuser_keyless_nodeinfo_dropped_wholesale(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42); + + meshtastic_User u = makeUser("Alice", "AL"); + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); +} + +// First key for a node is accepted (TOFU) and the reach-channel is stamped. +static void test_updateuser_first_key_accepted(void) +{ + db->push(kPeer, 1000); + + meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42); + TEST_ASSERT_TRUE(db->updateUser(kPeer, u, /*channelIndex=*/3)); + + assertStoredKeyEquals(kPeer, 0x42); + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_EQUAL_STRING("Alice", info->long_name); + TEST_ASSERT_EQUAL(3, info->channel); +} + +// A remote node advertising OUR public key is refused with exactly one +// ClientNotification; the duplicateWarned latch silences the second attempt. +static void test_updateuser_own_key_advert_notifies_once(void) +{ + fillKey(owner.public_key, 0x5A); + meshtastic_User u = makeUser("Evil twin", "ET", /*keySeed=*/0x5A); + + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + TEST_ASSERT_EQUAL(1, mockService->notificationCount); + + TEST_ASSERT_FALSE(db->updateUser(kPeer, u)); + TEST_ASSERT_EQUAL(1, mockService->notificationCount); // latched +} + +// user.id is always re-derived from the node number, whatever the payload claims. +static void test_updateuser_id_derived_from_nodenum(void) +{ + meshtastic_User u = makeUser("Alice", "AL", /*keySeed=*/0x42); + strncpy(u.id, "!deadbeef", sizeof(u.id) - 1); + + TEST_ASSERT_TRUE(db->updateUser(kPeer, u)); + + char expected[16]; + snprintf(expected, sizeof(expected), "!%08x", (unsigned)kPeer); + TEST_ASSERT_EQUAL_STRING(expected, u.id); +} + +// A known XEdDSA signer's identity only changes via a signed update - even a +// same-key name change arriving unsigned is refused. +static void test_updateuser_unsigned_update_refused_for_hot_signer(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true); + meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42); + + TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/false)); + TEST_ASSERT_EQUAL_STRING("", db->getMeshNode(kPeer)->long_name); + + TEST_ASSERT_TRUE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true)); // signed control + TEST_ASSERT_EQUAL_STRING("New name", db->getMeshNode(kPeer)->long_name); +} + +// The key pin outranks the signature: a signed update still cannot rotate a +// pinned key (rotation goes through commitRemoteKey's proven paths instead). +static void test_updateuser_signed_update_cannot_rotate_pinned_key(void) +{ + db->push(kPeer, 1000, /*keySeed=*/0x42, /*xeddsaSigned=*/true); + + meshtastic_User u = makeUser("Rotated", "RO", /*keySeed=*/0x77); + TEST_ASSERT_FALSE(db->updateUser(kPeer, u, 0, /*xeddsaSigned=*/true)); + + assertStoredKeyEquals(kPeer, 0x42); +} + +#if WARM_NODE_COUNT > 0 +// The signer gate runs BEFORE getOrCreateMeshNode, so refusing an unsigned +// update for a warm-tier signer must not evict a hot node, must not re-admit +// the signer, and must not consume its warm record. +static void test_updateuser_warm_signer_refusal_does_not_evict(void) +{ + const NodeNum signerNum = 0xE2000000 + 3; + const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected demote to warm + for (int i = 1; i <= extra; i++) + db->push(0xE2000000 + i, /*lastHeard=*/i, /*keySeed=*/0x42); + nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + + db->runDemote(); + + TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // demoted out of hot + TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum)); + TEST_ASSERT_TRUE(db->isFull()); + const int hotBefore = (int)db->getNumMeshNodes(); + + meshtastic_User u = makeUser("New name", "NN", /*keySeed=*/0x42); + TEST_ASSERT_FALSE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/false)); + + TEST_ASSERT_EQUAL_INT(hotBefore, (int)db->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(signerNum)); // not re-admitted + TEST_ASSERT_TRUE(db->isKnownXeddsaSigner(signerNum)); // warm record intact (take() never ran) + + // Signed control: the same update signed is accepted and re-admits the + // signer from warm with its key and signer bit restored. + TEST_ASSERT_TRUE(db->updateUser(signerNum, u, 0, /*xeddsaSigned=*/true)); + const meshtastic_NodeInfoLite *back = db->getMeshNode(signerNum); + TEST_ASSERT_NOT_NULL(back); + TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(back)); + TEST_ASSERT_EQUAL_STRING("New name", back->long_name); + assertStoredKeyEquals(signerNum, 0x42); +} +#endif // WARM_NODE_COUNT > 0 + +#endif // !(MESHTASTIC_EXCLUDE_PKI) + +// --- persistence --- + +// The erasure guard's outcome must survive the disk round trip: after a keyless +// add_contact against a pinned key, a rebooted NodeDB still holds the full key +// (pre-#11432 the zeroed key was persisted, breaking DMs until re-exchange). +static void test_contact_key_guard_survives_reboot(void) +{ + // saveNodeDatabaseToDisk() skips keyless devices, so give ourselves a key. + fillKey(owner.public_key, 0x5A); + + meshtastic_SharedContact keyed = makeContact(kPeer, "Alice", "AL", /*keySeed=*/0x42); + keyed.manually_verified = true; + db->addFromContact(keyed); // persists + // The keyless pre-DM contact for a verified node also carries manually_verified + // (a non-verified keyless contact would be refused by the verified pin instead). + meshtastic_SharedContact keyless = makeContact(kPeer, "Al2", "A2"); + keyless.manually_verified = true; + db->addFromContact(keyless); // keyless merge; persists the guard result + assertStoredKeyEquals(kPeer, 0x42); + + // A real cold boot starts with a zeroed nodeDatabase global; in-process the decode + // callback appends on top of the previous boot's rows, so without this reset the + // lookups below would find the pre-reboot RAM row and the persistence claim is vacuous. + delete db; + db = nullptr; + nodeDB = nullptr; + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDatabase.positions.clear(); + nodeDatabase.telemetry.clear(); + nodeDatabase.environment.clear(); + nodeDatabase.status.clear(); + db = new NodeDBTestShim(); + nodeDB = db; + + const meshtastic_NodeInfoLite *info = db->getMeshNode(kPeer); + TEST_ASSERT_NOT_NULL_MESSAGE(info, "contact must survive the reload"); + assertStoredKeyEquals(kPeer, 0x42); + TEST_ASSERT_EQUAL_STRING("Al2", info->long_name); + TEST_ASSERT_TRUE(nodeInfoLiteIsKeyManuallyVerified(info)); // pin survives the reboot too +} + +// --- Unity lifecycle --- + +void setUp(void) +{ + savedOwner = owner; + savedConfig = config; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + owner.public_key.size = 0; + + mockService = new MockMeshService(); + service = mockService; + + db->clearHot(); + db->seedSelf(); + db->resetDuplicateWarned(); +} + +void tearDown(void) +{ + owner = savedOwner; + config = savedConfig; + service = nullptr; + delete mockService; + mockService = nullptr; +} + +IH_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); +#ifdef FSCom + // NodeDB and MessageStore bracket their FS writes with spiLock; nothing in the + // test environment creates it, so do it here (initSPI asserts it only runs once). + if (!spiLock) + initSPI(); +#endif + db = new NodeDBTestShim(); + nodeDB = db; + + UNITY_BEGIN(); + + printf("\n=== addFromContact guards ===\n"); + RUN_TEST(test_contact_keyless_preserves_stored_key); + RUN_TEST(test_contact_new_key_updates_stored_key); + RUN_TEST(test_contact_verified_pin_blocks_mismatched_key); + RUN_TEST(test_contact_verified_pin_blocks_keyless_unverified); + RUN_TEST(test_contact_verified_pin_allows_matching_key); + RUN_TEST(test_contact_manually_verified_bit_survives_updates); + RUN_TEST(test_contact_should_ignore_blocks_but_keeps_key); + RUN_TEST(test_contact_client_base_stamps_heard_not_favorite); + RUN_TEST(test_contact_without_user_is_noop); + +#if !(MESHTASTIC_EXCLUDE_PKI) + printf("\n=== updateUser key pinning ===\n"); + RUN_TEST(test_updateuser_pinned_key_blocks_mismatch); + RUN_TEST(test_updateuser_keyless_nodeinfo_dropped_wholesale); + RUN_TEST(test_updateuser_first_key_accepted); + RUN_TEST(test_updateuser_own_key_advert_notifies_once); + RUN_TEST(test_updateuser_id_derived_from_nodenum); + RUN_TEST(test_updateuser_unsigned_update_refused_for_hot_signer); + RUN_TEST(test_updateuser_signed_update_cannot_rotate_pinned_key); +#if WARM_NODE_COUNT > 0 + RUN_TEST(test_updateuser_warm_signer_refusal_does_not_evict); +#endif +#endif + + printf("\n=== persistence ===\n"); + RUN_TEST(test_contact_key_guard_survives_reboot); + + exit(UNITY_END()); +} +IH_TEST_ENTRY void loop() {} diff --git a/test/test_nodedb_legacy_migration/test_main.cpp b/test/test_nodedb_legacy_migration/test_main.cpp new file mode 100644 index 000000000..3f6a645f3 --- /dev/null +++ b/test/test_nodedb_legacy_migration/test_main.cpp @@ -0,0 +1,570 @@ +// The one-shot v24 -> v25 NodeDatabase migration every 2.7 -> 2.8 upgrader runs: each test +// hand-encodes a legacy /prefs/nodes.proto, cold-boots a real NodeDB, and asserts the migrated +// state (including sanitizeUtf8 of legacy names, which the later encode depends on). +#include "MeshTypes.h" // BEFORE TestUtil.h - provides MAX_NUM_NODES via mesh-pb-constants.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NDBM_TEST_ENTRY extern "C" +#else +#define NDBM_TEST_ENTRY +#endif + +#include "FSCommon.h" + +// The migration is a file-load path; without a filesystem there is nothing to drive. +#if defined(FSCom) + +#include "mesh/NodeDB.h" +#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" +#include "meshUtils.h" +#include +#include +#include +#include +#include +#include +#include + +// Exposes the private save path via the friend declaration in NodeDB.h, so the +// hostile-name test can prove the migrated store re-encodes cleanly. +class NodeDBTestShim : public NodeDB +{ + public: + bool saveDatabase() { return saveNodeDatabaseToDisk(); } +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; + +void fillKey(meshtastic_UserLite_public_key_t &key, uint8_t seed) +{ + key.size = 32; + for (int i = 0; i < 32; i++) + key.bytes[i] = (uint8_t)(i ^ seed); + key.bytes[0] = seed; // distinctive, never all-zero +} + +meshtastic_NodeInfoLite_Legacy makeLegacyNode(uint32_t num, uint32_t lastHeard) +{ + meshtastic_NodeInfoLite_Legacy n = meshtastic_NodeInfoLite_Legacy_init_zero; + n.num = num; + n.last_heard = lastHeard; + return n; +} + +void giveLegacyUser(meshtastic_NodeInfoLite_Legacy &n, const char *longName, const char *shortName) +{ + n.has_user = true; + strncpy(n.user.long_name, longName, sizeof(n.user.long_name)); + n.user.long_name[sizeof(n.user.long_name) - 1] = '\0'; + strncpy(n.user.short_name, shortName, sizeof(n.user.short_name)); + n.user.short_name[sizeof(n.user.short_name) - 1] = '\0'; +} + +/// Encode a legacy-shape NodeDatabase - exactly what a 2.7 device leaves +/// behind for the 2.8 boot to find. +std::vector encodeLegacyNodes(uint32_t version, const std::vector &nodes) +{ + // _init_zero brace-inits the embedded std::vector via its explicit + // (size_type, allocator) ctor, so default-construct instead (see + // NodeDBLegacyMigration.cpp). + meshtastic_NodeDatabase_Legacy legacyDb{}; + legacyDb.version = version; + legacyDb.nodes = nodes; + + size_t encodedSize = 0; + TEST_ASSERT_TRUE_MESSAGE(pb_get_encoded_size(&encodedSize, meshtastic_NodeDatabase_Legacy_fields, &legacyDb), + "sizing the legacy fixture must succeed"); + std::vector buf(encodedSize); + pb_ostream_t stream = pb_ostream_from_buffer(buf.data(), buf.size()); + TEST_ASSERT_TRUE_MESSAGE(pb_encode(&stream, meshtastic_NodeDatabase_Legacy_fields, &legacyDb), + "encoding the legacy fixture must succeed"); + buf.resize(stream.bytes_written); + return buf; +} + +void writeNodesBytes(const uint8_t *bytes, size_t len) +{ + FSCom.mkdir("/prefs"); + FSCom.remove(nodeDatabaseFileName); + auto f = FSCom.open(nodeDatabaseFileName, FILE_O_WRITE); + TEST_ASSERT_TRUE((bool)f); + const size_t wrote = f.write(bytes, len); + f.close(); + TEST_ASSERT_EQUAL_MESSAGE(len, wrote, "short write laying down the nodes.proto fixture"); +} + +void writeLegacyNodesFile(uint32_t version, const std::vector &nodes) +{ + const std::vector buf = encodeLegacyNodes(version, nodes); + writeNodesBytes(buf.data(), buf.size()); +} + +/// Overwrite a unique same-length placeholder inside an encoded fixture with +/// raw bytes. PB_VALIDATE_UTF8 makes pb_encode refuse invalid UTF-8, so a +/// hostile v24 name (written by pre-validation firmware) can only be produced +/// by patching the encoded bytes - the protobuf framing stays intact because +/// the length does not change. +void patchBytes(std::vector &buf, const char *placeholder, const char *raw, size_t n) +{ + TEST_ASSERT_EQUAL(strlen(placeholder), n); + auto it = std::search(buf.begin(), buf.end(), reinterpret_cast(placeholder), + reinterpret_cast(placeholder) + n); + TEST_ASSERT_TRUE_MESSAGE(it != buf.end(), "placeholder not found in encoded fixture"); + memcpy(&*it, raw, n); +} + +/// Simulate a process restart. A real cold boot starts with a zeroed +/// nodeDatabase global; in-process it still holds the previous boot's version +/// stamp and nodes, which would short-circuit the version-gate ladder. +void coldBoot() +{ + if (db) { + delete db; + db = nullptr; + nodeDB = nullptr; + } + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDatabase.positions.clear(); + nodeDatabase.telemetry.clear(); + nodeDatabase.environment.clear(); + nodeDatabase.status.clear(); + + db = new NodeDBTestShim(); + nodeDB = db; +} + +/// The migrated-store re-save (migrationSavePending) is skipped for keyless +/// devices, so every persistence assertion depends on boot keygen having run. +void assertBootKeygenRan() +{ + TEST_ASSERT_EQUAL_MESSAGE(32, owner.public_key.size, + "boot keygen did not run - persistence legs of this suite need an owner key"); +} + +/// True UTF-8 cleanliness check via the production validator: a second +/// sanitize pass over already-sanitized bytes must find nothing to replace. +void assertValidUtf8(const char *s, size_t width) +{ + char copy[64]; + TEST_ASSERT_TRUE(width < sizeof(copy)); + memcpy(copy, s, width); + TEST_ASSERT_FALSE_MESSAGE(sanitizeUtf8(copy, width), "migrated name still contains invalid UTF-8"); +} + +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Version-gate ladder (NodeDB.cpp loadFromDisk) --- + +// v24 with no nodes is still a migration: the version stamp must advance and +// the boot must complete with just ourself in the store. +static void test_emptyV24File_migratesToEmptyV25(void) +{ + writeLegacyNodesFile(24, {}); + coldBoot(); + + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(1, (int)db->getNumMeshNodes()); // self only, added by nodeDBSelfCare +} + +// version < DEVICESTATE_MIN_VER: discarded, never migrated. +static void test_versionBelowMin_discardsToDefaults(void) +{ + auto old = makeLegacyNode(0xF6000001, 1000); + giveLegacyUser(old, "Ancient", "OLD"); + writeLegacyNodesFile(DEVICESTATE_MIN_VER - 1, {old}); + coldBoot(); + + TEST_ASSERT_NULL(db->getMeshNode(0xF6000001)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(1, (int)db->getNumMeshNodes()); +} + +// Garbage bytes: the v25 decode fails, the version stays below MIN, and the +// boot lands on installDefaultNodeDatabase instead of crashing or migrating. +static void test_garbageNodesProto_installsDefaults(void) +{ + static const uint8_t garbage[] = {0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x13, 0x37, 0xC0, 0xFF, 0xEE}; + writeNodesBytes(garbage, sizeof(garbage)); + coldBoot(); + + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(1, (int)db->getNumMeshNodes()); +} + +// --- Field-by-field migration fidelity --- + +static void test_v24RoundTrip_migratesFieldsBitfieldAndSatellites(void) +{ + std::vector nodes; + + // Node A: every scalar populated, plus position + device_metrics. + auto a = makeLegacyNode(0xA1000001, 111111); + giveLegacyUser(a, "Alice Node", "AL"); + a.user.hw_model = meshtastic_HardwareModel_TBEAM; + a.user.role = meshtastic_Config_DeviceConfig_Role_TRACKER; + fillKey(a.user.public_key, 0x42); + a.snr = 7.25f; + a.channel = 2; + a.has_hops_away = true; + a.hops_away = 3; + a.next_hop = 0xAB; + a.has_position = true; + a.position.latitude_i = 375000000; + a.position.longitude_i = -1219876543; + a.position.altitude = 123; + a.position.time = 1700000000; + a.position.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; + a.position.precision_bits = 32; + a.has_device_metrics = true; + a.device_metrics.has_battery_level = true; + a.device_metrics.battery_level = 87; + a.device_metrics.has_voltage = true; + a.device_metrics.voltage = 3.7f; + nodes.push_back(a); + + // Node B: the legacy compatibility bools that must pack into the bitfield. + auto b = makeLegacyNode(0xA1000002, 222222); + giveLegacyUser(b, "Bob", "BB"); + b.via_mqtt = true; + b.is_favorite = true; + nodes.push_back(b); + + // Node C: blocked + licensed. + auto c = makeLegacyNode(0xA1000003, 333333); + giveLegacyUser(c, "Carol", "CC"); + c.is_ignored = true; + c.user.is_licensed = true; + nodes.push_back(c); + + // Node D: tri-state unmessagable present-and-set. + auto d = makeLegacyNode(0xA1000004, 444444); + giveLegacyUser(d, "Dave", "DD"); + d.user.has_is_unmessagable = true; + d.user.is_unmessagable = true; + nodes.push_back(d); + + // Node E: control - no key, no bools, no unmessagable tri-state. + auto e = makeLegacyNode(0xA1000005, 555555); + giveLegacyUser(e, "Erin", "EE"); + nodes.push_back(e); + + writeLegacyNodesFile(24, nodes); + coldBoot(); + + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + TEST_ASSERT_EQUAL_INT(6, (int)db->getNumMeshNodes()); // 5 migrated + self + + const meshtastic_NodeInfoLite *na = db->getMeshNode(0xA1000001); + TEST_ASSERT_NOT_NULL(na); + TEST_ASSERT_EQUAL_STRING("Alice Node", na->long_name); + TEST_ASSERT_EQUAL_STRING("AL", na->short_name); + TEST_ASSERT_EQUAL(meshtastic_HardwareModel_TBEAM, na->hw_model); + TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_TRACKER, na->role); + TEST_ASSERT_EQUAL_FLOAT(7.25f, na->snr); + TEST_ASSERT_EQUAL_UINT32(111111, na->last_heard); + TEST_ASSERT_EQUAL_UINT8(2, na->channel); + TEST_ASSERT_TRUE(na->has_hops_away); + TEST_ASSERT_EQUAL_UINT8(3, na->hops_away); + TEST_ASSERT_EQUAL_UINT8(0xAB, na->next_hop); + TEST_ASSERT_TRUE(nodeInfoLiteHasUser(na)); + TEST_ASSERT_FALSE(nodeInfoLiteViaMqtt(na)); + TEST_ASSERT_FALSE(nodeInfoLiteIsFavorite(na)); + TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(na)); + TEST_ASSERT_FALSE(nodeInfoLiteIsLicensed(na)); + + // Satellite routing: position and device_metrics land in the maps, not the header. +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_PositionLite pos; + TEST_ASSERT_TRUE(db->copyNodePosition(0xA1000001, pos)); + TEST_ASSERT_EQUAL_INT32(375000000, pos.latitude_i); + TEST_ASSERT_EQUAL_INT32(-1219876543, pos.longitude_i); + TEST_ASSERT_EQUAL_INT32(123, pos.altitude); + TEST_ASSERT_EQUAL_UINT32(1700000000, pos.time); + TEST_ASSERT_EQUAL(meshtastic_Position_LocSource_LOC_INTERNAL, pos.location_source); + TEST_ASSERT_EQUAL_UINT32(32, pos.precision_bits); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + meshtastic_DeviceMetrics dm; + TEST_ASSERT_TRUE(db->copyNodeTelemetry(0xA1000001, dm)); + TEST_ASSERT_TRUE(dm.has_battery_level); + TEST_ASSERT_EQUAL_UINT32(87, dm.battery_level); + TEST_ASSERT_TRUE(dm.has_voltage); + TEST_ASSERT_EQUAL_FLOAT(3.7f, dm.voltage); +#endif + + const meshtastic_NodeInfoLite *nb = db->getMeshNode(0xA1000002); + TEST_ASSERT_NOT_NULL(nb); + TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nb)); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(nb)); + TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(nb)); + + const meshtastic_NodeInfoLite *nc = db->getMeshNode(0xA1000003); + TEST_ASSERT_NOT_NULL(nc); + TEST_ASSERT_TRUE(nodeInfoLiteIsIgnored(nc)); + TEST_ASSERT_TRUE(nodeInfoLiteIsLicensed(nc)); + TEST_ASSERT_FALSE(nodeInfoLiteViaMqtt(nc)); + + const meshtastic_NodeInfoLite *nd = db->getMeshNode(0xA1000004); + TEST_ASSERT_NOT_NULL(nd); + TEST_ASSERT_TRUE(nodeInfoLiteHasIsUnmessagable(nd)); + TEST_ASSERT_TRUE(nodeInfoLiteIsUnmessagable(nd)); + + const meshtastic_NodeInfoLite *ne = db->getMeshNode(0xA1000005); + TEST_ASSERT_NOT_NULL(ne); + TEST_ASSERT_FALSE(nodeInfoLiteHasIsUnmessagable(ne)); + TEST_ASSERT_FALSE(nodeInfoLiteIsUnmessagable(ne)); + TEST_ASSERT_EQUAL(0, ne->public_key.size); + + // public_key survives byte-identical, and the public lookup API finds it. + TEST_ASSERT_EQUAL(32, na->public_key.size); + meshtastic_UserLite_public_key_t expected; + fillKey(expected, 0x42); + TEST_ASSERT_EQUAL_MEMORY(expected.bytes, na->public_key.bytes, 32); + meshtastic_NodeInfoLite_public_key_t got = {0, {0}}; + TEST_ASSERT_TRUE(db->copyPublicKey(0xA1000001, got)); + TEST_ASSERT_EQUAL(32, got.size); + TEST_ASSERT_EQUAL_MEMORY(expected.bytes, got.bytes, 32); +} + +// has_position=false / has_device_metrics=false entries must not seed +// zero-position ghosts in the satellite maps. +static void test_absentSubmessages_noSatelliteGhostRows(void) +{ + auto a = makeLegacyNode(0xC3000001, 1000); + giveLegacyUser(a, "NoPos", "NP"); + auto b = makeLegacyNode(0xC3000002, 2000); + giveLegacyUser(b, "NoTel", "NT"); + writeLegacyNodesFile(24, {a, b}); + coldBoot(); + + TEST_ASSERT_NOT_NULL(db->getMeshNode(0xC3000001)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(0xC3000002)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + TEST_ASSERT_FALSE(db->hasNodePosition(0xC3000001)); + TEST_ASSERT_FALSE(db->hasNodePosition(0xC3000002)); + TEST_ASSERT_TRUE(db->snapshotPositionNodeNums(0).empty()); +#endif +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + TEST_ASSERT_FALSE(db->hasNodeTelemetry(0xC3000001)); + TEST_ASSERT_TRUE(db->snapshotTelemetryNodeNums(0).empty()); +#endif +} + +// --- sanitizeUtf8 firewall (hostile v24 names) --- + +// The truncation firewall: a wide-but-VALID v24 long_name (UserLite allows 40 +// bytes) whose 25-byte slim copy cuts a multi-byte sequence in half. Without +// migration's sanitizeUtf8, the orphaned lead byte makes the next +// saveNodeDatabaseToDisk() fail its PB_VALIDATE_UTF8 encode - and a failed +// save is what triggers saveToDisk()'s fsFormat() wipe on device. +static void test_truncatedWideName_sanitizedAndReencodable(void) +{ + // 23 ASCII bytes then Euro signs straddling the 24-byte truncation boundary. + std::string straddle(23, 'a'); + straddle += "\xE2\x82\xAC\xE2\x82\xAC"; // two Euro signs, 29 bytes total - valid UTF-8 in v24 + auto s = makeLegacyNode(0xB2000002, 2000); + giveLegacyUser(s, straddle.c_str(), "OK"); + + writeLegacyNodesFile(24, {s}); + coldBoot(); + + const meshtastic_NodeInfoLite *ns = db->getMeshNode(0xB2000002); + TEST_ASSERT_NOT_NULL(ns); + std::string expected(23, 'a'); + expected += '?'; // orphaned 0xE2 lead byte after the cut, replaced by sanitizeUtf8 + TEST_ASSERT_EQUAL_STRING(expected.c_str(), ns->long_name); + assertValidUtf8(ns->long_name, sizeof(ns->long_name)); + + // The firewall itself: the migrated store must encode and re-decode. + assertBootKeygenRan(); + TEST_ASSERT_TRUE_MESSAGE(db->saveDatabase(), "sanitized store must re-encode without a nanopb failure"); + meshtastic_NodeDatabase reloaded{}; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &reloaded)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, reloaded.version); +} + +// Raw invalid UTF-8 inside a v24 name (written by pre-PB_VALIDATE_UTF8 +// firmware): nanopb refuses to decode that node and the legacy callback drops +// it, but the rest of the file must still migrate and the boot must still +// complete and re-save. One poisoned node must never cost the whole database. +static void test_rawInvalidUtf8Node_droppedWithoutBreakingMigration(void) +{ + static const char kPlaceholderLong[] = "Bad0(nameXXzzYY"; // 15 ASCII bytes, patched below + static const char kHostileLong[] = "Bad\xC3" + "(name\xFF\xFE" + "zz\xE2\x82"; // invalid leads + truncated tail, same 15 bytes + + auto h = makeLegacyNode(0xB2000001, 1000); + giveLegacyUser(h, kPlaceholderLong, "HN"); + + auto good = makeLegacyNode(0xB2000003, 3000); + giveLegacyUser(good, "Good Node", "GN"); + + std::vector buf = encodeLegacyNodes(24, {h, good}); + patchBytes(buf, kPlaceholderLong, kHostileLong, 15); + writeNodesBytes(buf.data(), buf.size()); + coldBoot(); + + // The poisoned node is gone (its num was consumed before the failing name, + // so no partial-decode fragment can carry it either)... + TEST_ASSERT_NULL(db->getMeshNode(0xB2000001)); + // ...while its well-formed sibling in the same file migrated intact. + const meshtastic_NodeInfoLite *ng = db->getMeshNode(0xB2000003); + TEST_ASSERT_NOT_NULL(ng); + TEST_ASSERT_EQUAL_STRING("Good Node", ng->long_name); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, nodeDatabase.version); + + // And the migrated store still persists cleanly. + assertBootKeygenRan(); + TEST_ASSERT_TRUE(db->saveDatabase()); + meshtastic_NodeDatabase reloaded{}; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &reloaded)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, reloaded.version); +} + +// --- Capacity --- + +// A legacy file from a larger-cap build migrates at most MAX_NUM_NODES entries +// in file order; no OOB under ASan (the getOrCreate boot-loop family guard). +static void test_overCapLegacyFile_truncatesToMaxNumNodes(void) +{ + const int maxNodes = MAX_NUM_NODES; + const int extra = 20; + std::vector nodes; + nodes.reserve(maxNodes + extra); + for (int i = 0; i < maxNodes + extra; i++) { + auto n = makeLegacyNode(0xE5000000u + i, (uint32_t)(i + 1)); // ascending: index 0 is oldest + char ln[16], sn[5]; + snprintf(ln, sizeof(ln), "n%d", i); + snprintf(sn, sizeof(sn), "%02d", i % 100); + giveLegacyUser(n, ln, sn); // users required: keyless/userless entries are purged by cleanupMeshDB + nodes.push_back(n); + } + writeLegacyNodesFile(24, nodes); + coldBoot(); + + // Exactly the hot cap: file entries 0..max-1 migrated, the tail dropped, + // then nodeDBSelfCare evicted one old migrated node to admit self. Which + // of the oldest is the victim is an eviction-policy detail; only the + // counts and the cap boundary are contract here. + TEST_ASSERT_EQUAL_INT(maxNodes, (int)db->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(0xE5000000u + maxNodes)); // first beyond the cap: dropped + TEST_ASSERT_NULL(db->getMeshNode(0xE5000000u + maxNodes + extra - 1)); // last beyond the cap: dropped + TEST_ASSERT_NOT_NULL(db->getMeshNode(0xE5000000u + maxNodes - 1)); // last within the cap: kept + TEST_ASSERT_NOT_NULL(db->getMeshNode(db->getNodeNum())); // self admitted + int survivors = 0; + for (int i = 0; i < maxNodes; i++) { + if (db->getMeshNode(0xE5000000u + i)) + survivors++; + } + TEST_ASSERT_EQUAL_INT_MESSAGE(maxNodes - 1, survivors, "exactly one within-cap node should have been evicted for self"); +} + +// --- Full boot ladder persistence --- + +// The deferred migrationSavePending re-save must land: after the boot, +// the on-disk nodes.proto is v25 with the migrated node, key, and satellite. +static void test_fullBootLadder_persistsMigratedV25(void) +{ + auto a = makeLegacyNode(0xD4000001, 4000); + giveLegacyUser(a, "Persist Me", "PM"); + fillKey(a.user.public_key, 0x77); + a.has_position = true; + a.position.latitude_i = 101010101; + a.position.longitude_i = -202020202; + writeLegacyNodesFile(24, {a}); + coldBoot(); + + assertBootKeygenRan(); + + meshtastic_NodeDatabase reloaded{}; + TEST_ASSERT_EQUAL(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &reloaded)); + TEST_ASSERT_EQUAL_UINT32(DEVICESTATE_CUR_VER, reloaded.version); + + const meshtastic_NodeInfoLite *persisted = nullptr; + for (const auto &n : reloaded.nodes) { + if (n.num == 0xD4000001) + persisted = &n; + } + TEST_ASSERT_NOT_NULL_MESSAGE(persisted, "migrated node must survive the v25 re-save"); + TEST_ASSERT_EQUAL_STRING("Persist Me", persisted->long_name); + TEST_ASSERT_TRUE(persisted->bitfield & NODEINFO_BITFIELD_HAS_USER_MASK); + TEST_ASSERT_EQUAL(32, persisted->public_key.size); + meshtastic_UserLite_public_key_t expected; + fillKey(expected, 0x77); + TEST_ASSERT_EQUAL_MEMORY(expected.bytes, persisted->public_key.bytes, 32); + +#if !MESHTASTIC_EXCLUDE_POSITIONDB + // With the decode targets disarmed (steady state), satellite entries land in + // the struct's own vectors - so this asserts the on-disk projection directly. + bool posFound = false; + for (const auto &e : reloaded.positions) { + if (e.num == 0xD4000001 && e.has_position) { + posFound = true; + TEST_ASSERT_EQUAL_INT32(101010101, e.position.latitude_i); + TEST_ASSERT_EQUAL_INT32(-202020202, e.position.longitude_i); + } + } + TEST_ASSERT_TRUE_MESSAGE(posFound, "satellite position must survive the v25 re-save"); +#endif +} + +NDBM_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + // First boot on the empty sandbox: installs defaults, runs keygen, and + // persists the base config files every later cold boot reloads. + coldBoot(); + + UNITY_BEGIN(); + + printf("\n=== Version-gate ladder ===\n"); + RUN_TEST(test_emptyV24File_migratesToEmptyV25); + RUN_TEST(test_versionBelowMin_discardsToDefaults); + RUN_TEST(test_garbageNodesProto_installsDefaults); + + printf("\n=== Migration fidelity ===\n"); + RUN_TEST(test_v24RoundTrip_migratesFieldsBitfieldAndSatellites); + RUN_TEST(test_absentSubmessages_noSatelliteGhostRows); + + printf("\n=== sanitizeUtf8 firewall ===\n"); + RUN_TEST(test_truncatedWideName_sanitizedAndReencodable); + RUN_TEST(test_rawInvalidUtf8Node_droppedWithoutBreakingMigration); + + printf("\n=== Capacity and persistence ===\n"); + RUN_TEST(test_overCapLegacyFile_truncatesToMaxNumNodes); + RUN_TEST(test_fullBootLadder_persistsMigratedV25); + + exit(UNITY_END()); +} +NDBM_TEST_ENTRY void loop() {} + +#else // !FSCom - no filesystem, nothing to migrate + +void setUp(void) {} +void tearDown(void) {} + +NDBM_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} +NDBM_TEST_ENTRY void loop() {} + +#endif diff --git a/test/test_nodedb_v25_roundtrip/test_main.cpp b/test/test_nodedb_v25_roundtrip/test_main.cpp new file mode 100644 index 000000000..93b15a955 --- /dev/null +++ b/test/test_nodedb_v25_roundtrip/test_main.cpp @@ -0,0 +1,691 @@ +// Round-trip fidelity of the v25 slim NodeDB persistence cycle: snr_q4 quantization and its +// HAS_SNR sentinel, satellite-map projection/rehydration and eviction, the keyless-device write +// skip, and resetNodes() compaction. Each test saves, cold-boots a real NodeDB, and reads back. +#include "MeshTypes.h" // BEFORE TestUtil.h - provides MAX_SATELLITE_NODES via mesh-pb-constants.h +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define NDBR_TEST_ENTRY extern "C" +#else +#define NDBR_TEST_ENTRY +#endif + +#include "FSCommon.h" + +// This is a disk round-trip suite; without a filesystem there is nothing to pin. +#if defined(FSCom) + +#include "mesh/NodeDB.h" +#include +#include +#include +#include + +// Friend declared in NodeDB.h (PIO_UNIT_TESTING): exposes the private save path so +// the tests drive exactly the gate under test, without saveToDisk()'s format-retry. +class NodeDBTestShim : public NodeDB +{ + public: + bool saveDatabase() { return saveNodeDatabaseToDisk(); } +}; + +namespace +{ + +NodeDBTestShim *db = nullptr; + +/// Simulate a process restart. A real cold boot starts with a zeroed nodeDatabase +/// global; in-process the decode callback would append on top of the previous +/// boot's rows, duplicating every node. +void coldBoot() +{ + if (db) { + delete db; + db = nullptr; + nodeDB = nullptr; + } + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDatabase.positions.clear(); + nodeDatabase.telemetry.clear(); + nodeDatabase.environment.clear(); + nodeDatabase.status.clear(); + + db = new NodeDBTestShim(); + nodeDB = db; +} + +meshtastic_User makeUser(uint32_t num, uint8_t seed) +{ + meshtastic_User u = meshtastic_User_init_zero; + snprintf(u.id, sizeof(u.id), "!%08x", num); + snprintf(u.long_name, sizeof(u.long_name), "Node %02X", seed); + snprintf(u.short_name, sizeof(u.short_name), "N%02X", seed); + u.hw_model = meshtastic_HardwareModel_TBEAM; + u.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + u.public_key.size = 32; + for (int i = 0; i < 32; i++) + u.public_key.bytes[i] = (uint8_t)(i ^ seed ^ 0x5A); + return u; +} + +/// Give the node a user so it survives the next boot's cleanupMeshDB() purge - +/// userless, non-ignored rows are dropped on load, which is itself part of the cycle. +meshtastic_NodeInfoLite *addUserNode(uint32_t num, uint8_t seed, uint8_t channelIndex = 0) +{ + meshtastic_User u = makeUser(num, seed); + nodeDB->updateUser(num, u, channelIndex); + meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(num); + TEST_ASSERT_NOT_NULL_MESSAGE(info, "updateUser must admit the node"); + return info; +} + +/// A packet as the real over-the-air RX path shapes it: decoded, TRANSPORT_LORA, +/// modern-sender bitfield, rx_time and rx_rssi present. +meshtastic_MeshPacket makeRxPacket(uint32_t from) +{ + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = from; + mp.to = nodeDB->getNodeNum(); + mp.id = 0x1000u + (from & 0xFFFu); + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.has_bitfield = true; // modern sender: hop_start is trustworthy + mp.has_rx_time = true; + mp.rx_time = 1700000000; + mp.hop_start = 3; + mp.hop_limit = 3; + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + mp.has_rx_rssi = true; + mp.rx_rssi = -80; + return mp; +} + +void heardOverLoRa(uint32_t from, float snr) +{ + meshtastic_MeshPacket mp = makeRxPacket(from); + mp.rx_snr = snr; + nodeDB->updateFrom(mp); +} + +meshtastic_StatusMessage makeStatus(const char *text) +{ + meshtastic_StatusMessage st = meshtastic_StatusMessage_init_zero; + snprintf(st.status, sizeof(st.status), "%s", text); + return st; +} + +bool readFileBytes(const char *path, std::vector &out) +{ + auto f = FSCom.open(path, FILE_O_READ); + if (!f) + return false; + out.resize(f.size()); + if (!out.empty() && f.read(out.data(), out.size()) != out.size()) { + f.close(); + return false; + } + f.close(); + return true; +} + +void decodeNodesFile(meshtastic_NodeDatabase &out) +{ + // _init_zero brace-inits the embedded std::vector via its (size_type) ctor, + // so callers pass a default-constructed struct; decode targets are disarmed in + // steady state, so satellite entries land in the struct's own vectors - this + // reads the on-disk projection directly. + TEST_ASSERT_EQUAL_MESSAGE(LoadFileResult::LOAD_SUCCESS, + db->loadProto(nodeDatabaseFileName, db->getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase), + &meshtastic_NodeDatabase_msg, &out), + "nodes.proto must decode"); +} + +void assertTempVectorsEmpty(const char *when) +{ + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.positions.empty(), when); + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.telemetry.empty(), when); + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.environment.empty(), when); + TEST_ASSERT_TRUE_MESSAGE(nodeDatabase.status.empty(), when); +} + +void clearAllSatellites() +{ + auto wipe = [](const std::vector &nums) { + for (NodeNum n : nums) + nodeDB->eraseNodeSatellites(n); + }; + wipe(nodeDB->snapshotPositionNodeNums(0)); + wipe(nodeDB->snapshotTelemetryNodeNums(0)); + wipe(nodeDB->snapshotEnvironmentNodeNums(0)); + wipe(nodeDB->snapshotStatusNodeNums(0)); +} + +} // namespace + +void setUp(void) {} +void tearDown(void) {} + +// --- Environment preconditions --- + +// Every persistence leg depends on boot keygen having produced an owner key +// (keyless devices deliberately skip the nodes.proto write - tested below). +static void test_identityReady_saveUnlocked(void) +{ + TEST_ASSERT_EQUAL_MESSAGE(32, owner.public_key.size, "boot keygen did not run - this suite needs an owner key"); + TEST_ASSERT_NOT_NULL(db->getMeshNode(db->getNodeNum())); +} + +// --- updateFrom SNR admission gates (in-RAM policy feeding the persisted bit) --- + +static void test_updateFrom_snrTransportGates(void) +{ + const uint32_t A = 0x52000001, B = 0x52000002, C = 0x52000003; + + // Genuine RF reception of a 0 dB packet: stored, and HAS_SNR says so. + heardOverLoRa(A, 0.0f); + const meshtastic_NodeInfoLite *na = db->getMeshNode(A); + TEST_ASSERT_NOT_NULL(na); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(na), "a measured 0 dB must be recorded as known"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, na->snr); + + // Broker-delivered MQTT packet: rx_snr is not our measurement, never recorded. + meshtastic_MeshPacket mp = makeRxPacket(B); + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + mp.via_mqtt = true; + mp.rx_snr = 7.5f; + nodeDB->updateFrom(mp); + const meshtastic_NodeInfoLite *nb = db->getMeshNode(B); + TEST_ASSERT_NOT_NULL(nb); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(nb), "MQTT-transport SNR must not be recorded"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, nb->snr); + TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nb)); + + // TRANSPORT_LORA without has_rx_rssi (the PhoneAPI-replay shape): not recorded. + mp = makeRxPacket(C); + mp.has_rx_rssi = false; + mp.rx_rssi = 0; + mp.rx_snr = 6.0f; + nodeDB->updateFrom(mp); + const meshtastic_NodeInfoLite *nc = db->getMeshNode(C); + TEST_ASSERT_NOT_NULL(nc); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(nc), "replay-shaped packets must not mint a measurement"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, nc->snr); + + // An MQTT-origin packet a gateway rebroadcast onto LoRa: we measured that one. + mp = makeRxPacket(B); + mp.via_mqtt = true; + mp.rx_snr = -3.5f; + nodeDB->updateFrom(mp); + nb = db->getMeshNode(B); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(nb)); + TEST_ASSERT_EQUAL_FLOAT(-3.5f, nb->snr); +} + +// --- snr_q4 quantization + HAS_SNR sentinel through a real save/boot cycle --- + +static void test_snrQuantization_roundTripsThroughDisk(void) +{ + const uint32_t N1 = 0x53000001; // |SNR| < 0.25 dB: rounds to -1, not truncated to the sentinel + const uint32_t N2 = 0x53000002; // measured 0.0 dB: the #11271 sentinel collision + const uint32_t N3 = 0x53000003; // rounds TO 0 yet stays a known measurement + const uint32_t N4 = 0x53000004; // legacy record: snr set, HAS_SNR clear (compat branch) + const uint32_t N5 = 0x53000005; // never measured + const uint32_t N6 = 0x53000006; // plain quantization: 7.9 -> 32/4 = 8.0 + + addUserNode(N1, 0x01); + heardOverLoRa(N1, -0.2f); + addUserNode(N2, 0x02); + heardOverLoRa(N2, 0.0f); + addUserNode(N3, 0x03); + heardOverLoRa(N3, 0.1f); + meshtastic_NodeInfoLite *legacy = addUserNode(N4, 0x04); + legacy->snr = 3.0f; // pre-HAS_SNR store shape: value present, bit clear + addUserNode(N5, 0x05); + addUserNode(N6, 0x06); + heardOverLoRa(N6, 7.9f); + + TEST_ASSERT_TRUE(db->saveDatabase()); + coldBoot(); + + const meshtastic_NodeInfoLite *n = db->getMeshNode(N1); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(n)); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE(-0.25f, n->snr, "lroundf(-0.8) = -1 -> -0.25 dB (rounding, not truncation)"); + + n = db->getMeshNode(N2); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(n), "a genuine 0 dB reading must come back as known, not unknown"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); + + n = db->getMeshNode(N3); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasSnr(n), "a measurement that quantizes to 0 is still a measurement"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); + + n = db->getMeshNode(N4); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_FALSE(nodeInfoLiteHasSnr(n)); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE(3.0f, n->snr, "legacy snr_q4 without the bit must decode via the compat branch"); + + n = db->getMeshNode(N5); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasSnr(n), "snr_q4 = 0 with the bit clear is unambiguously unknown"); + TEST_ASSERT_EQUAL_FLOAT(0.0f, n->snr); + + n = db->getMeshNode(N6); + TEST_ASSERT_NOT_NULL(n); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(n)); + TEST_ASSERT_EQUAL_FLOAT(8.0f, n->snr); +} + +// --- Full header + satellite-map projection/rehydration cycle --- + +static void test_fullRoundTrip_headerAndSatelliteFidelity(void) +{ + const uint32_t P = 0x54000001; // position + const uint32_t T = 0x54000002; // device telemetry + const uint32_t E = 0x54000003; // environment + status + const uint32_t M = 0x54000004; // bitfield bools + hops + + addUserNode(P, 0x11, /*channelIndex=*/2); + heardOverLoRa(P, 5.5f); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.latitude_i = 375000000; + pos.longitude_i = -1219876543; + pos.altitude = 123; + pos.time = 1700000200; + pos.location_source = meshtastic_Position_LocSource_LOC_INTERNAL; + pos.precision_bits = 32; + nodeDB->updatePosition(P, pos); +#endif + + addUserNode(T, 0x12); +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + meshtastic_Telemetry tel = meshtastic_Telemetry_init_zero; + tel.which_variant = meshtastic_Telemetry_device_metrics_tag; + tel.variant.device_metrics.has_battery_level = true; + tel.variant.device_metrics.battery_level = 87; + tel.variant.device_metrics.has_voltage = true; + tel.variant.device_metrics.voltage = 3.7f; + tel.variant.device_metrics.has_channel_utilization = true; + tel.variant.device_metrics.channel_utilization = 12.5f; + tel.variant.device_metrics.has_air_util_tx = true; + tel.variant.device_metrics.air_util_tx = 1.5f; + tel.variant.device_metrics.has_uptime_seconds = true; + tel.variant.device_metrics.uptime_seconds = 3600; + nodeDB->updateTelemetry(T, tel); +#endif + + addUserNode(E, 0x13); +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + meshtastic_Telemetry env = meshtastic_Telemetry_init_zero; + env.which_variant = meshtastic_Telemetry_environment_metrics_tag; + env.variant.environment_metrics.has_temperature = true; + env.variant.environment_metrics.temperature = 21.5f; + env.variant.environment_metrics.has_relative_humidity = true; + env.variant.environment_metrics.relative_humidity = 40.5f; + env.variant.environment_metrics.has_barometric_pressure = true; + env.variant.environment_metrics.barometric_pressure = 1013.25f; + nodeDB->updateTelemetry(E, env); +#endif +#if !MESHTASTIC_EXCLUDE_STATUSDB + nodeDB->setNodeStatus(E, makeStatus("on the tower")); +#endif + + meshtastic_NodeInfoLite *m = addUserNode(M, 0x14); + meshtastic_MeshPacket mp = makeRxPacket(M); + mp.via_mqtt = true; // gateway rebroadcast: bit stored, SNR still ours + mp.hop_start = 5; + mp.hop_limit = 2; // hops_away = 3 + mp.rx_snr = 2.0f; + nodeDB->updateFrom(mp); + m = db->getMeshNode(M); + nodeInfoLiteSetBit(m, NODEINFO_BITFIELD_IS_MUTED_MASK, true); + + TEST_ASSERT_TRUE(db->saveDatabase()); + assertTempVectorsEmpty("temp vectors must be cleared after the save projection"); + + coldBoot(); + assertTempVectorsEmpty("armed decode must route entries into the maps, not the temp vectors"); + + // Header fidelity + const meshtastic_NodeInfoLite *np = db->getMeshNode(P); + TEST_ASSERT_NOT_NULL(np); + TEST_ASSERT_EQUAL_STRING("Node 11", np->long_name); + TEST_ASSERT_EQUAL_STRING("N11", np->short_name); + TEST_ASSERT_EQUAL(meshtastic_HardwareModel_TBEAM, np->hw_model); + TEST_ASSERT_EQUAL_UINT8(2, np->channel); + TEST_ASSERT_EQUAL_UINT32(1700000000, np->last_heard); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(np)); + TEST_ASSERT_EQUAL_FLOAT(5.5f, np->snr); + meshtastic_User expected = makeUser(P, 0x11); + TEST_ASSERT_EQUAL(32, np->public_key.size); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(expected.public_key.bytes, np->public_key.bytes, 32, + "public key must survive byte-identical"); + + const meshtastic_NodeInfoLite *nm = db->getMeshNode(M); + TEST_ASSERT_NOT_NULL(nm); + TEST_ASSERT_TRUE(nodeInfoLiteViaMqtt(nm)); + TEST_ASSERT_TRUE(nodeInfoLiteIsMuted(nm)); + TEST_ASSERT_TRUE(nm->has_hops_away); + TEST_ASSERT_EQUAL_UINT8(3, nm->hops_away); + TEST_ASSERT_TRUE(nodeInfoLiteHasSnr(nm)); + TEST_ASSERT_EQUAL_FLOAT(2.0f, nm->snr); + + // Satellite rehydration - identical values, and only where they were written. +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_PositionLite gotPos; + TEST_ASSERT_TRUE(db->copyNodePosition(P, gotPos)); + TEST_ASSERT_EQUAL_INT32(375000000, gotPos.latitude_i); + TEST_ASSERT_EQUAL_INT32(-1219876543, gotPos.longitude_i); + TEST_ASSERT_EQUAL_INT32(123, gotPos.altitude); + TEST_ASSERT_EQUAL_UINT32(1700000200, gotPos.time); + TEST_ASSERT_EQUAL(meshtastic_Position_LocSource_LOC_INTERNAL, gotPos.location_source); + TEST_ASSERT_EQUAL_UINT32(32, gotPos.precision_bits); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(T), "no position was ever written for T"); +#endif + +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + meshtastic_DeviceMetrics gotDm; + TEST_ASSERT_TRUE(db->copyNodeTelemetry(T, gotDm)); + TEST_ASSERT_TRUE(gotDm.has_battery_level); + TEST_ASSERT_EQUAL_UINT32(87, gotDm.battery_level); + TEST_ASSERT_TRUE(gotDm.has_voltage); + TEST_ASSERT_EQUAL_FLOAT(3.7f, gotDm.voltage); + TEST_ASSERT_TRUE(gotDm.has_channel_utilization); + TEST_ASSERT_EQUAL_FLOAT(12.5f, gotDm.channel_utilization); + TEST_ASSERT_TRUE(gotDm.has_air_util_tx); + TEST_ASSERT_EQUAL_FLOAT(1.5f, gotDm.air_util_tx); + TEST_ASSERT_TRUE(gotDm.has_uptime_seconds); + TEST_ASSERT_EQUAL_UINT32(3600, gotDm.uptime_seconds); + TEST_ASSERT_FALSE(db->hasNodeTelemetry(P)); +#endif + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + meshtastic_EnvironmentMetrics gotEnv; + TEST_ASSERT_TRUE(db->copyNodeEnvironment(E, gotEnv)); + TEST_ASSERT_TRUE(gotEnv.has_temperature); + TEST_ASSERT_EQUAL_FLOAT(21.5f, gotEnv.temperature); + TEST_ASSERT_TRUE(gotEnv.has_relative_humidity); + TEST_ASSERT_EQUAL_FLOAT(40.5f, gotEnv.relative_humidity); + TEST_ASSERT_TRUE(gotEnv.has_barometric_pressure); + TEST_ASSERT_EQUAL_FLOAT(1013.25f, gotEnv.barometric_pressure); +#endif + +#if !MESHTASTIC_EXCLUDE_STATUSDB + meshtastic_StatusMessage gotSt; + TEST_ASSERT_TRUE(db->copyNodeStatus(E, gotSt)); + TEST_ASSERT_EQUAL_STRING("on the tower", gotSt.status); + TEST_ASSERT_FALSE(db->hasNodeStatus(P)); +#endif +} + +// --- Keyless-save skip (part of the PKI-DM key-amnesia diagnosis) --- + +static void test_keylessDevice_skipsNodesProtoWrite(void) +{ +#if MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI + TEST_IGNORE_MESSAGE("keyless-save gate compiled out on this build"); +#else + std::vector before; + TEST_ASSERT_TRUE_MESSAGE(readFileBytes(nodeDatabaseFileName, before), "nodes.proto must exist before the gate check"); + + const meshtastic_User_public_key_t savedKey = owner.public_key; + const bool savedLicensed = owner.is_licensed; + owner.public_key.size = 0; + owner.is_licensed = false; + + // Returning success on the skip matters: a false here would propagate into + // saveToDisk()'s fsFormat() whole-FS wipe. + TEST_ASSERT_TRUE_MESSAGE(db->saveDatabase(), "keyless save must report success"); + + std::vector after; + TEST_ASSERT_TRUE(readFileBytes(nodeDatabaseFileName, after)); + TEST_ASSERT_TRUE_MESSAGE(before == after, "keyless save must leave nodes.proto byte-identical"); + + owner.public_key = savedKey; + owner.is_licensed = savedLicensed; + + // Control: with the key restored, the same call writes. + addUserNode(0x55000001, 0x55); + TEST_ASSERT_TRUE(db->saveDatabase()); + TEST_ASSERT_TRUE(readFileBytes(nodeDatabaseFileName, after)); + TEST_ASSERT_FALSE_MESSAGE(before == after, "keyed save must rewrite nodes.proto"); +#endif +} + +// --- Live satellite-cap eviction policy --- + +#if !MESHTASTIC_EXCLUDE_STATUSDB +static void test_satelliteCap_evictionPolicy(void) +{ + if ((size_t)MAX_NUM_NODES < (size_t)MAX_SATELLITE_NODES + 8) + TEST_IGNORE_MESSAGE("hot cap too small to own a full satellite map on this build"); + + clearAllSatellites(); + TEST_ASSERT_EQUAL_UINT(0, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + const NodeNum self = nodeDB->getNodeNum(); + meshtastic_NodeInfoLite *selfRow = nodeDB->getOrCreateMeshNode(self); + TEST_ASSERT_NOT_NULL(selfRow); + selfRow->last_heard = 0; // stalest possible: only the identity exemption can protect it + nodeDB->setNodeStatus(self, makeStatus("self")); + + // Fill to exactly the cap with hot-owned entries; owner i heard at 1000+i. + const size_t owners = (size_t)MAX_SATELLITE_NODES - 1; + const NodeNum ownerBase = 0x60000000u; + for (size_t i = 0; i < owners; i++) { + meshtastic_NodeInfoLite *info = nodeDB->getOrCreateMeshNode(ownerBase + i); + TEST_ASSERT_NOT_NULL(info); + info->last_heard = 1000 + (uint32_t)i; + nodeDB->setNodeStatus(ownerBase + i, makeStatus("owned")); + } + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + // (a) At cap, a new entry evicts the stalest-by-owner victim - never self, + // even though self ranks stalest of all. + const NodeNum orphan1 = 0x60FFFF01u; + nodeDB->setNodeStatus(orphan1, makeStatus("new")); + TEST_ASSERT_TRUE_MESSAGE(db->hasNodeStatus(self), "self must never be evicted"); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodeStatus(ownerBase + 0), "stalest owner must be the victim"); + TEST_ASSERT_TRUE(db->hasNodeStatus(ownerBase + 1)); + TEST_ASSERT_TRUE(db->hasNodeStatus(orphan1)); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + // (b) Orphans (owner absent from the hot store) are evicted before any owner, + // however stale the owner: orphan1 (recency 0) loses to owner1 (1001). + const NodeNum orphan2 = 0x60FFFF02u; + nodeDB->setNodeStatus(orphan2, makeStatus("new2")); + TEST_ASSERT_FALSE_MESSAGE(db->hasNodeStatus(orphan1), "orphan must be evicted before any owned entry"); + TEST_ASSERT_TRUE(db->hasNodeStatus(ownerBase + 1)); + TEST_ASSERT_TRUE(db->hasNodeStatus(orphan2)); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + + // (c) Updating an existing key at cap must not evict anything. + nodeDB->setNodeStatus(ownerBase + 1, makeStatus("updated")); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotStatusNodeNums(0).size()); + TEST_ASSERT_TRUE_MESSAGE(db->hasNodeStatus(orphan2), "update-in-place must not trigger eviction"); + meshtastic_StatusMessage got; + TEST_ASSERT_TRUE(db->copyNodeStatus(ownerBase + 1, got)); + TEST_ASSERT_EQUAL_STRING("updated", got.status); +} +#endif // !MESHTASTIC_EXCLUDE_STATUSDB + +// --- Boot-time trim of an over-cap nodes.proto (capacity downgrade / foreign file) --- + +#if !MESHTASTIC_EXCLUDE_POSITIONDB +static void test_bootTrim_overCapSatellitesHealedOnDisk(void) +{ + const size_t overBy = 10; + const NodeNum base = 0x70000000u; + + // Craft a v25 nodes.proto whose position store exceeds this build's cap, as a + // larger-cap build (or a peer backup) would leave behind. + meshtastic_NodeDatabase crafted{}; + crafted.version = DEVICESTATE_CUR_VER; + for (size_t i = 0; i < (size_t)MAX_SATELLITE_NODES + overBy; i++) { + meshtastic_NodePositionEntry e = meshtastic_NodePositionEntry_init_zero; + e.num = base + (uint32_t)i; + e.has_position = true; + e.position.latitude_i = (int32_t)(1000 + i); + e.position.time = 1000 + (uint32_t)i; + crafted.positions.push_back(e); + } + size_t craftedSize = 0; + TEST_ASSERT_TRUE(pb_get_encoded_size(&craftedSize, meshtastic_NodeDatabase_fields, &crafted)); + TEST_ASSERT_TRUE(db->saveProto(nodeDatabaseFileName, craftedSize, &meshtastic_NodeDatabase_msg, &crafted, false)); + + coldBoot(); + + // Trimmed in RAM to exactly the cap; all entries were orphans, so the + // lowest-recency victims (here: the lowest-numbered) went first. + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_SATELLITE_NODES, (unsigned)nodeDB->snapshotPositionNodeNums(0).size()); + TEST_ASSERT_TRUE(db->hasNodePosition(base + (uint32_t)MAX_SATELLITE_NODES + (uint32_t)overBy - 1)); + TEST_ASSERT_FALSE(db->hasNodePosition(base)); + + // And healed on disk: nodeDBSelfCare rewrote the store once during the boot. + meshtastic_NodeDatabase reloaded{}; + decodeNodesFile(reloaded); + size_t persisted = 0; + for (const auto &e : reloaded.positions) + if (e.has_position) + persisted++; + TEST_ASSERT_EQUAL_UINT_MESSAGE((unsigned)MAX_SATELLITE_NODES, (unsigned)persisted, + "boot must rewrite the over-cap store trimmed"); +} +#endif // !MESHTASTIC_EXCLUDE_POSITIONDB + +// --- resetNodes(keepFavorites): no ghost rows above numMeshNodes --- + +static void test_resetNodesKeepFavorites_compactsWithoutGhostRows(void) +{ + const uint32_t F1 = 0x71000001, F2 = 0x71000002, F3 = 0x71000003, F4 = 0x71000004; + addUserNode(F1, 0x21); + addUserNode(F2, 0x22); + addUserNode(F3, 0x23); + addUserNode(F4, 0x24); + TEST_ASSERT_TRUE(nodeDB->set_favorite(true, F2)); + TEST_ASSERT_TRUE(nodeDB->set_favorite(true, F4)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.latitude_i = 111; + pos.longitude_i = 222; + nodeDB->updatePosition(F1, pos); + nodeDB->updatePosition(F2, pos); +#endif + + nodeDB->resetNodes(/*keepFavorites=*/true); + + // RAM: self + the two favorites, compacted into contiguous low slots. + TEST_ASSERT_EQUAL_INT(3, (int)nodeDB->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(F1)); + TEST_ASSERT_NULL(db->getMeshNode(F3)); + const meshtastic_NodeInfoLite *f2 = db->getMeshNode(F2); + const meshtastic_NodeInfoLite *f4 = db->getMeshNode(F4); + TEST_ASSERT_NOT_NULL(f2); + TEST_ASSERT_NOT_NULL(f4); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(f2)); + TEST_ASSERT_TRUE(nodeInfoLiteIsFavorite(f4)); +#if !MESHTASTIC_EXCLUDE_POSITIONDB + TEST_ASSERT_FALSE_MESSAGE(db->hasNodePosition(F1), "non-favorite satellites must be dropped"); + TEST_ASSERT_TRUE_MESSAGE(db->hasNodePosition(F2), "favorite satellites must survive"); +#endif + + // Disk: resetNodes saved; the serialized store must carry the favorites in + // the low slots and NOTHING above numMeshNodes - a zeroed-in-place favorite + // would be invisible to every scan yet still serialized (the ghost bug). + meshtastic_NodeDatabase reloaded{}; + decodeNodesFile(reloaded); + TEST_ASSERT_TRUE(reloaded.nodes.size() >= 3); + size_t liveRows = 0; + bool sawF2 = false, sawF4 = false, sawSelf = false; + for (size_t i = 0; i < reloaded.nodes.size(); i++) { + const meshtastic_NodeInfoLite &row = reloaded.nodes[i]; + if (row.num == 0) + continue; + liveRows++; + TEST_ASSERT_TRUE_MESSAGE(i < 3, "live row serialized above numMeshNodes: a ghost entry"); + if (row.num == F2) + sawF2 = true; + if (row.num == F4) + sawF4 = true; + if (row.num == nodeDB->getNodeNum()) + sawSelf = true; + } + TEST_ASSERT_EQUAL_UINT(3, (unsigned)liveRows); + TEST_ASSERT_TRUE(sawSelf); + TEST_ASSERT_TRUE(sawF2); + TEST_ASSERT_TRUE(sawF4); +} + +NDBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); +#if defined(ARCH_PORTDUINO) + // The stalest-owner eviction case needs hot capacity above the satellite cap + // (the real large-flash topology). Set before the first NodeDB so every boot + // in this suite sees one consistent cap. + portduino_config.MaxNodes = (int)MAX_SATELLITE_NODES + 50; +#endif + // First boot on the empty sandbox: installs defaults, runs keygen, and + // persists the base config files every later cold boot reloads. + coldBoot(); + +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + // Boot keygen is region-gated on real radios (simradio bypasses the gate); + // if this environment blocked it, set a region and mint the identity now so + // the persistence legs run instead of cascading off a locked save. + if (owner.public_key.size != 32) { + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + nodeDB->generateCryptoKeyPair(nullptr); + } +#endif + + UNITY_BEGIN(); + + printf("\n=== Preconditions ===\n"); + RUN_TEST(test_identityReady_saveUnlocked); + + printf("\n=== updateFrom SNR gates ===\n"); + RUN_TEST(test_updateFrom_snrTransportGates); + + printf("\n=== snr_q4 + HAS_SNR round trip ===\n"); + RUN_TEST(test_snrQuantization_roundTripsThroughDisk); + + printf("\n=== Satellite projection/rehydration ===\n"); + RUN_TEST(test_fullRoundTrip_headerAndSatelliteFidelity); + + printf("\n=== Keyless-save gate ===\n"); + RUN_TEST(test_keylessDevice_skipsNodesProtoWrite); + + printf("\n=== Satellite caps ===\n"); +#if !MESHTASTIC_EXCLUDE_STATUSDB + RUN_TEST(test_satelliteCap_evictionPolicy); +#endif +#if !MESHTASTIC_EXCLUDE_POSITIONDB + RUN_TEST(test_bootTrim_overCapSatellitesHealedOnDisk); +#endif + + printf("\n=== resetNodes ghost rows ===\n"); + RUN_TEST(test_resetNodesKeepFavorites_compactsWithoutGhostRows); + + exit(UNITY_END()); +} +NDBR_TEST_ENTRY void loop() {} + +#else // !FSCom - no filesystem, nothing to round-trip + +void setUp(void) {} +void tearDown(void) {} + +NDBR_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} +NDBR_TEST_ENTRY void loop() {} + +#endif diff --git a/test/test_observer/test_main.cpp b/test/test_observer/test_main.cpp new file mode 100644 index 000000000..88998575f --- /dev/null +++ b/test/test_observer/test_main.cpp @@ -0,0 +1,378 @@ +// Unit tests for src/Observer.h: notification order, the nonzero-return abort chain, +// CallbackObserver dispatch, ~Observer auto-detach, and list mutation from inside onNotify. +#include "Arduino.h" +#include "Observer.h" +#include "TestUtil.h" +#include +#include +#include + +// Tags of observers in the order their onNotify ran, e.g. "ABC". Cleared in setUp. +static std::string callOrder; + +// An observer that records its calls and can optionally mutate observer lists from inside +// onNotify - the mid-notify hazard the detach/attach-during-notify tests drive. +class RecordingObserver : public Observer +{ + public: + explicit RecordingObserver(char _tag) : tag(_tag) {} + + char tag; + int returnCode = 0; + int calls = 0; + int lastArg = 0; + + // When set, onNotify detaches detachWho from detachFrom before returning. + Observer *detachWho = nullptr; + Observable *detachFrom = nullptr; + + // When set, onNotify attaches attachWho to attachTo before returning. + Observer *attachWho = nullptr; + Observable *attachTo = nullptr; + + protected: + int onNotify(int arg) override + { + callOrder += tag; + calls++; + lastArg = arg; + if (detachWho && detachFrom) + detachWho->unobserve(detachFrom); + if (attachWho && attachTo) + attachWho->observe(attachTo); + return returnCode; + } +}; + +// Target class for the CallbackObserver member-pointer dispatch tests. +class CallbackTarget +{ + public: + int calls = 0; + int lastArg = 0; + + int handle(int arg) + { + calls++; + lastArg = arg; + return 0; + } + + int handleAbort(int arg) + { + calls++; + lastArg = arg; + return 42; + } +}; + +// --- basic delivery --- + +void test_notify_with_no_observers_returns_zero() +{ + Observable subject; + TEST_ASSERT_EQUAL(0, subject.notifyObservers(99)); +} + +void test_notify_order_and_arg() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(42)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); // insertion order + TEST_ASSERT_EQUAL(42, a.lastArg); + TEST_ASSERT_EQUAL(42, b.lastArg); + TEST_ASSERT_EQUAL(42, c.lastArg); + + // Delivery is not one-shot: a second notify reaches everyone again. + TEST_ASSERT_EQUAL(0, subject.notifyObservers(43)); + TEST_ASSERT_EQUAL_STRING("ABCABC", callOrder.c_str()); + TEST_ASSERT_EQUAL(2, b.calls); + TEST_ASSERT_EQUAL(43, b.lastArg); +} + +// --- abort contract --- + +void test_nonzero_return_aborts_chain_and_propagates() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + + b.returnCode = 7; + TEST_ASSERT_EQUAL(7, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); + TEST_ASSERT_EQUAL(0, c.calls); // chain stopped before C + + // Clearing the abort restores full delivery. + b.returnCode = 0; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); +} + +// --- CallbackObserver --- + +void test_callback_observer_dispatches_member_function() +{ + Observable subject; + CallbackTarget target; + CallbackObserver cb(&target, &CallbackTarget::handle); + cb.observe(&subject); + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1234)); + TEST_ASSERT_EQUAL(1, target.calls); + TEST_ASSERT_EQUAL(1234, target.lastArg); +} + +void test_callback_observer_return_code_aborts_chain() +{ + Observable subject; + CallbackTarget target; + CallbackObserver cb(&target, &CallbackTarget::handleAbort); + RecordingObserver after('X'); + cb.observe(&subject); + after.observe(&subject); + + TEST_ASSERT_EQUAL(42, subject.notifyObservers(5)); + TEST_ASSERT_EQUAL(1, target.calls); + TEST_ASSERT_EQUAL(0, after.calls); // callback's abort code stopped the chain +} + +// --- lifecycle: destructor auto-detach --- + +void test_destroyed_observer_is_not_notified() +{ + Observable subject; + RecordingObserver a('A'), c('C'); + a.observe(&subject); + RecordingObserver *b = new RecordingObserver('B'); + b->observe(&subject); + c.observe(&subject); + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); + + delete b; // ~Observer must remove it from the observable's list + + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); // ASan-clean: no dangling pointer left behind + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); +} + +void test_observer_watching_two_observables_detaches_from_both() +{ + Observable subject1; + Observable subject2; + { + RecordingObserver x('X'); + x.observe(&subject1); + x.observe(&subject2); // re-target onto a second observable: both now deliver + subject1.notifyObservers(1); + subject2.notifyObservers(2); + TEST_ASSERT_EQUAL(2, x.calls); + TEST_ASSERT_EQUAL(2, x.lastArg); + } // x destroyed here - must have detached from both observables + + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject1.notifyObservers(3)); + TEST_ASSERT_EQUAL(0, subject2.notifyObservers(4)); + TEST_ASSERT_EQUAL_STRING("", callOrder.c_str()); +} + +// --- duplicate observe / unobserve semantics --- + +void test_duplicate_observe_delivers_twice_and_unobserve_removes_all() +{ + Observable subject; + RecordingObserver a('A'); + a.observe(&subject); + a.observe(&subject); // current semantics: second observe means double delivery + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(9)); + TEST_ASSERT_EQUAL_STRING("AA", callOrder.c_str()); + TEST_ASSERT_EQUAL(2, a.calls); + + // One unobserve removes every entry (std::list::remove semantics), not just one. + a.unobserve(&subject); + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(10)); + TEST_ASSERT_EQUAL_STRING("", callOrder.c_str()); + TEST_ASSERT_EQUAL(2, a.calls); +} + +void test_unobserve_of_never_observed_observable_is_noop() +{ + Observable subject; + RecordingObserver a('A'), stranger('S'); + a.observe(&subject); + + stranger.unobserve(&subject); // never attached: must be a safe no-op + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("A", callOrder.c_str()); + TEST_ASSERT_EQUAL(0, stranger.calls); +} + +// --- list mutation from inside onNotify (the safe cases) --- + +void test_detach_of_earlier_observer_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + b.detachWho = &a; // B removes already-visited A mid-notify + b.detachFrom = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); // A was visited before removal; C unaffected + + b.detachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("BC", callOrder.c_str()); // A stays detached +} + +void test_detach_of_later_observer_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + a.detachWho = &c; // A removes not-yet-visited C mid-notify + a.detachFrom = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); // iteration stays valid, C never called + TEST_ASSERT_EQUAL(0, c.calls); + + a.detachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); +} + +// Tightest safe case: removing the node the iterator will step to next. std::list relinks A's +// next pointer when B's node is erased, so ++iterator lands on C. +void test_detach_of_immediately_next_observer_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + a.detachWho = &b; + a.detachFrom = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); + TEST_ASSERT_EQUAL(0, b.calls); + + a.detachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); +} + +// Self-detach is only safe when the observer also aborts the chain: returning nonzero exits +// before the iterator is advanced past the node unobserve() just erased. PhoneAPI is the one +// observer in the tree that does this (onNotify -> checkConnectionTimeout -> close() -> +// unobserve, returning -1), and its -1 is load-bearing, not incidental. A self-detaching +// observer that returned 0 would walk a freed node - not covered here, because asserting that +// would be asserting UB; notifyObservers() has to be hardened before it can be tested. +void test_self_detach_with_abort_during_notify() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + b.detachWho = &b; + b.detachFrom = &subject; + b.returnCode = -1; + + TEST_ASSERT_EQUAL(-1, subject.notifyObservers(1)); + TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); // C never runs: the chain aborted + TEST_ASSERT_EQUAL(0, c.calls); + + b.detachWho = nullptr; + b.returnCode = 0; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str()); +} + +void test_attach_during_notify_is_safe_and_delivers_next_time() +{ + Observable subject; + RecordingObserver a('A'), b('B'), c('C'), d('D'); + a.observe(&subject); + b.observe(&subject); + c.observe(&subject); + a.attachWho = &d; // A appends D mid-notify (push_back never invalidates list iterators) + a.attachTo = &subject; + + TEST_ASSERT_EQUAL(0, subject.notifyObservers(1)); + // The pre-existing observers all ran, in order. Whether the same pass also reaches the + // freshly appended D is deliberately not asserted - a hardened notifyObservers that + // snapshots the list would legitimately change that, and it should not go red for it. + TEST_ASSERT_EQUAL_STRING("ABC", callOrder.substr(0, 3).c_str()); + + a.attachWho = nullptr; + callOrder.clear(); + TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); + TEST_ASSERT_EQUAL_STRING("ABCD", callOrder.c_str()); // D is a full participant from now on +} + +// --- Unity lifecycle --- + +void setUp(void) +{ + callOrder.clear(); +} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Basic delivery ===\n"); + RUN_TEST(test_notify_with_no_observers_returns_zero); + RUN_TEST(test_notify_order_and_arg); + + printf("\n=== Abort contract ===\n"); + RUN_TEST(test_nonzero_return_aborts_chain_and_propagates); + + printf("\n=== CallbackObserver ===\n"); + RUN_TEST(test_callback_observer_dispatches_member_function); + RUN_TEST(test_callback_observer_return_code_aborts_chain); + + printf("\n=== Lifecycle ===\n"); + RUN_TEST(test_destroyed_observer_is_not_notified); + RUN_TEST(test_observer_watching_two_observables_detaches_from_both); + + printf("\n=== Duplicate observe / unobserve ===\n"); + RUN_TEST(test_duplicate_observe_delivers_twice_and_unobserve_removes_all); + RUN_TEST(test_unobserve_of_never_observed_observable_is_noop); + + printf("\n=== Mutation during notify (safe cases) ===\n"); + RUN_TEST(test_detach_of_earlier_observer_during_notify); + RUN_TEST(test_detach_of_later_observer_during_notify); + RUN_TEST(test_detach_of_immediately_next_observer_during_notify); + RUN_TEST(test_self_detach_with_abort_during_notify); + RUN_TEST(test_attach_during_notify_is_safe_and_delivers_next_time); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_phone_api_config_dump/test_main.cpp b/test/test_phone_api_config_dump/test_main.cpp new file mode 100644 index 000000000..2dff75d6e --- /dev/null +++ b/test/test_phone_api_config_dump/test_main.cpp @@ -0,0 +1,574 @@ +// PhoneAPI::getFromRadio() config-dump sequence, asserted on decoded FromRadio protobufs: the +// order client apps depend on, the heartbeat preempt, SPECIAL_NONCE_ONLY_* jumps, mid-dump +// restart, and the post-complete drain reaching idle. +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "Channels.h" +#include "CryptoEngine.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "PhoneAPI.h" +#include "Router.h" +#include "mesh-pb-constants.h" +#include "meshtastic/admin.pb.h" +#include +#include +#include + +// File-scope flag in PhoneAPI.cpp: set by a client heartbeat, cleared by the queueStatus reply. +extern bool heartbeatReceived; + +namespace +{ +constexpr uint32_t FULL_DUMP_NONCE = 0x51C0FFEE; +constexpr uint32_t SECOND_NONCE = 0x0DDBA11; +constexpr NodeNum SEEDED_NODE_A = 0x00000A01; +constexpr NodeNum SEEDED_NODE_B = 0x00000A02; + +constexpr unsigned NUM_SINGLETON_PREFIX = 5; // my_info, deviceuiConfig, own node_info, metadata, region_presets +constexpr unsigned NUM_CONFIG_MESSAGES = _meshtastic_AdminMessage_ConfigType_MAX + 1; +constexpr unsigned NUM_MODULE_CONFIG_MESSAGES = _meshtastic_AdminMessage_ModuleConfigType_MAX + 1; + +// STATE_SEND_CONFIG iterates config_state over the AdminMessage ConfigType enum but emits +// Config oneof tags: a proto bump that grows one without the other makes a config message +// carry inner variant 0. The static_asserts turn that drift into a compile error here. +const pb_size_t kExpectedConfigVariants[] = { + meshtastic_Config_device_tag, meshtastic_Config_position_tag, meshtastic_Config_power_tag, + meshtastic_Config_network_tag, meshtastic_Config_display_tag, meshtastic_Config_lora_tag, + meshtastic_Config_bluetooth_tag, meshtastic_Config_security_tag, meshtastic_Config_sessionkey_tag, + meshtastic_Config_device_ui_tag, +}; +static_assert(sizeof(kExpectedConfigVariants) / sizeof(kExpectedConfigVariants[0]) == NUM_CONFIG_MESSAGES, + "AdminMessage ConfigType enum and Config oneof diverged - update PhoneAPI's STATE_SEND_CONFIG and this list"); + +const pb_size_t kExpectedModuleConfigVariants[] = { + meshtastic_ModuleConfig_mqtt_tag, + meshtastic_ModuleConfig_serial_tag, + meshtastic_ModuleConfig_external_notification_tag, + meshtastic_ModuleConfig_store_forward_tag, + meshtastic_ModuleConfig_range_test_tag, + meshtastic_ModuleConfig_telemetry_tag, + meshtastic_ModuleConfig_canned_message_tag, + meshtastic_ModuleConfig_audio_tag, + meshtastic_ModuleConfig_remote_hardware_tag, + meshtastic_ModuleConfig_neighbor_info_tag, + meshtastic_ModuleConfig_ambient_lighting_tag, + meshtastic_ModuleConfig_detection_sensor_tag, + meshtastic_ModuleConfig_paxcounter_tag, + meshtastic_ModuleConfig_statusmessage_tag, + meshtastic_ModuleConfig_traffic_management_tag, + meshtastic_ModuleConfig_tak_tag, +#if !MESHTASTIC_EXCLUDE_BEACON + meshtastic_ModuleConfig_mesh_beacon_tag, +#else + 0, // beacon compiled out: the slot still ships, as an empty ModuleConfig +#endif +}; +static_assert(sizeof(kExpectedModuleConfigVariants) / sizeof(kExpectedModuleConfigVariants[0]) == NUM_MODULE_CONFIG_MESSAGES, + "AdminMessage ModuleConfigType enum and ModuleConfig oneof diverged - update STATE_SEND_MODULECONFIG and this " + "list"); + +/// PhoneAPI over a permanently-connected fake transport. +class PhoneAPITestShim : public PhoneAPI +{ + protected: + bool checkIsConnected() override { return true; } +}; + +/// Concrete Router with no radio interface: getQueueStatus() reports an all-zero queue. +class TestRouter : public Router +{ + public: + // Router's ctor allocated the global cryptLock; nothing else frees it. + ~TestRouter() + { + delete cryptLock; + cryptLock = nullptr; + } +}; + +// Saved-global fixture, template test_event_channel_phone_api. Restored in tearDown() rather +// than by RAII because a failed TEST_ASSERT longjmps out of the test without running destructors. +struct GlobalState { + MeshService *service; + Router *router; + NodeDB *nodeDB; + concurrency::Lock *cryptLock; + meshtastic_MyNodeInfo myNodeInfo; + Channels channels; + meshtastic_ChannelFile channelFile; + meshtastic_LocalConfig config; + meshtastic_LocalModuleConfig moduleConfig; + meshtastic_DeviceState deviceState; +}; + +GlobalState *savedState = nullptr; +MeshService *mockService = nullptr; +TestRouter *testRouter = nullptr; +NodeDB *testNodeDB = nullptr; +PhoneAPITestShim *api = nullptr; + +/// Give every channel slot a distinct index so the dump's 0..7 ordering is observable. +void configureTestChannels() +{ + channelFile = meshtastic_ChannelFile_init_default; + channelFile.channels_count = MAX_NUM_CHANNELS; + for (pb_size_t i = 0; i < MAX_NUM_CHANNELS; i++) { + channelFile.channels[i].index = (int8_t)i; + channelFile.channels[i].has_settings = true; + channelFile.channels[i].role = i == 0 ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY; + } + channels.onConfigChanged(); +} + +/// Create a remote node in the scratch NodeDB the way received traffic would. +void seedRemoteNode(NodeNum num) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + p.from = num; + p.to = NODENUM_BROADCAST; + nodeDB->updateFrom(p); +} + +bool sendToRadio(const meshtastic_ToRadio &message) +{ + uint8_t encoded[meshtastic_ToRadio_size] = {}; + const size_t encodedSize = + pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ToRadio_msg, const_cast(&message)); + TEST_ASSERT_GREATER_THAN_UINT(0, encodedSize); + return api->handleToRadio(encoded, encodedSize); +} + +void startHandshake(uint32_t nonce) +{ + meshtastic_ToRadio request = meshtastic_ToRadio_init_zero; + request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag; + request.want_config_id = nonce; + sendToRadio(request); +} + +void sendPlainHeartbeat() +{ + meshtastic_ToRadio hb = meshtastic_ToRadio_init_zero; + hb.which_payload_variant = meshtastic_ToRadio_heartbeat_tag; + hb.heartbeat = meshtastic_Heartbeat_init_zero; // nonce 0 = plain keepalive, expects a queueStatus reply + sendToRadio(hb); +} + +/// One decoded FromRadio pulled off the wire; zero-length reads return false. +bool readOneFromRadio(meshtastic_FromRadio &out) +{ + uint8_t buf[meshtastic_FromRadio_size]; + const size_t len = api->getFromRadio(buf); + if (len == 0) + return false; + out = meshtastic_FromRadio_init_zero; + TEST_ASSERT_TRUE_MESSAGE(pb_decode_from_bytes(buf, len, &meshtastic_FromRadio_msg, &out), + "device emitted an undecodable FromRadio"); + return true; +} + +/// Everything the dump emitted, in order, as decoded facts rather than internals. +struct DumpTranscript { + std::vector variants; // outer which_payload_variant per message + std::vector configVariants; // inner variant of each FromRadio.config + std::vector moduleConfigVariants; // inner variant of each FromRadio.moduleConfig + std::vector channelIndices; + std::vector nodeNums; + unsigned fileInfoCount = 0; + unsigned queueStatusCount = 0; + uint32_t completeId = 0; + bool sawComplete = false; +}; + +/// Pull messages until config_complete_id; false if the stream stalls or overruns the cap. +bool drainUntilComplete(DumpTranscript &t, unsigned maxMessages = 600) +{ + for (unsigned i = 0; i < maxMessages; i++) { + meshtastic_FromRadio msg; + if (!readOneFromRadio(msg)) + return false; + t.variants.push_back(msg.which_payload_variant); + switch (msg.which_payload_variant) { + case meshtastic_FromRadio_config_tag: + t.configVariants.push_back(msg.config.which_payload_variant); + break; + case meshtastic_FromRadio_moduleConfig_tag: + t.moduleConfigVariants.push_back(msg.moduleConfig.which_payload_variant); + break; + case meshtastic_FromRadio_channel_tag: + t.channelIndices.push_back(msg.channel.index); + break; + case meshtastic_FromRadio_node_info_tag: + t.nodeNums.push_back(msg.node_info.num); + break; + case meshtastic_FromRadio_fileInfo_tag: + t.fileInfoCount++; + break; + case meshtastic_FromRadio_queueStatus_tag: + t.queueStatusCount++; + break; + case meshtastic_FromRadio_config_complete_id_tag: + t.completeId = msg.config_complete_id; + t.sawComplete = true; + return true; + default: + break; + } + } + return false; +} + +unsigned countVariant(const DumpTranscript &t, pb_size_t tag) +{ + unsigned n = 0; + for (pb_size_t v : t.variants) + if (v == tag) + n++; + return n; +} + +/// Assert the two non-self node records are the seeded pair (DB iteration order not pinned). +void assertSeededPair(uint32_t first, uint32_t second) +{ + const bool inOrder = first == SEEDED_NODE_A && second == SEEDED_NODE_B; + const bool swapped = first == SEEDED_NODE_B && second == SEEDED_NODE_A; + TEST_ASSERT_TRUE_MESSAGE(inOrder || swapped, "other node_infos are not the seeded pair"); +} + +// --- Tests --- + +// The full documented sequence, section by section, ending in the nonce echo. Also pins the +// channel section: exactly MAX_NUM_CHANNELS messages, indices 0..7 in order, between +// region_presets and the first config. +void test_full_want_config_dump_emits_documented_sequence() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(FULL_DUMP_NONCE); + + DumpTranscript t; + TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "dump stalled before config_complete_id"); + + const pb_size_t expectedPrefix[NUM_SINGLETON_PREFIX] = { + meshtastic_FromRadio_my_info_tag, meshtastic_FromRadio_deviceuiConfig_tag, meshtastic_FromRadio_node_info_tag, + meshtastic_FromRadio_metadata_tag, meshtastic_FromRadio_region_presets_tag}; + TEST_ASSERT_GREATER_OR_EQUAL_UINT(NUM_SINGLETON_PREFIX, t.variants.size()); + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX; i++) + TEST_ASSERT_EQUAL_UINT_MESSAGE(expectedPrefix[i], t.variants[i], "header sequence changed"); + + // Bound the raw indexing below: header + channels + configs + moduleConfigs + 2 seeded + // node_infos + complete is the minimum a full dump can be. + TEST_ASSERT_GREATER_OR_EQUAL_UINT( + NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 3, t.variants.size()); + + // Channel section: contiguous, complete, ordered. + const size_t channelStart = NUM_SINGLETON_PREFIX; + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++) { + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_channel_tag, t.variants[channelStart + i]); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)i, t.channelIndices[i], "channels must arrive as indices 0..7 in order"); + } + + const size_t configStart = channelStart + MAX_NUM_CHANNELS; + for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++) + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_tag, t.variants[configStart + i]); + + const size_t moduleStart = configStart + NUM_CONFIG_MESSAGES; + for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++) + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_moduleConfig_tag, t.variants[moduleStart + i]); + + // Other node_infos follow the module configs; the own record was already sent in the header. + const size_t nodesStart = moduleStart + NUM_MODULE_CONFIG_MESSAGES; + TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size()); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + assertSeededPair(t.nodeNums[1], t.nodeNums[2]); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart]); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart + 1]); + + // Everything between the node_infos and the completion id is file manifest (count is + // whatever the sandbox filesystem holds, so only the position is asserted). + for (size_t i = nodesStart + 2; i + 1 < t.variants.size(); i++) + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_fileInfo_tag, t.variants[i]); + + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_complete_id_tag, t.variants.back()); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(FULL_DUMP_NONCE, t.completeId, "config_complete_id must echo the request nonce"); + + // Singletons exactly once, and no stray preempts. + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_my_info_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_metadata_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_region_presets_tag)); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag)); + TEST_ASSERT_EQUAL_UINT(0, t.queueStatusCount); + TEST_ASSERT_EQUAL_UINT(NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 2 + + t.fileInfoCount + 1, + t.variants.size()); +} + +// Guards the ConfigType-enum-to-oneof-tag iteration: a desync emits a config message whose +// inner variant is 0, which every phone app decodes as an empty Config. +void test_config_section_inner_variants_match_config_type_enum() +{ + startHandshake(FULL_DUMP_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size()); + for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++) { + TEST_ASSERT_NOT_EQUAL_MESSAGE(0, t.configVariants[i], + "config with inner variant 0: ConfigType enum drifted from the Config oneof"); + TEST_ASSERT_EQUAL_UINT(kExpectedConfigVariants[i], t.configVariants[i]); + } +} + +// Same closed-set guard for the module config section (the drift class already happened once, +// for statusmessage). +void test_module_config_section_inner_variants_match_module_config_type_enum() +{ + startHandshake(FULL_DUMP_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++) { + if (kExpectedModuleConfigVariants[i] != 0) + TEST_ASSERT_NOT_EQUAL_MESSAGE( + 0, t.moduleConfigVariants[i], + "moduleConfig with inner variant 0: ModuleConfigType enum drifted from the ModuleConfig oneof"); + TEST_ASSERT_EQUAL_UINT(kExpectedModuleConfigVariants[i], t.moduleConfigVariants[i]); + } +} + +// SPECIAL_NONCE_ONLY_NODES jumps straight to the node stream: own record, others, completion - +// no headers, channels, configs, or manifest. +void test_only_nodes_nonce_sends_nodes_then_complete() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(SPECIAL_NONCE_ONLY_NODES); + + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(4, t.variants.size()); // own + 2 seeded + complete + TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size()); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + assertSeededPair(t.nodeNums[1], t.nodeNums[2]); + TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_NODES, t.completeId); + + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_my_info_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_metadata_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_region_presets_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_channel_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_config_tag)); + TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_moduleConfig_tag)); + TEST_ASSERT_EQUAL_UINT(0, t.fileInfoCount); +} + +// SPECIAL_NONCE_ONLY_CONFIG delivers the full config but skips the non-self node DB, and must +// not arm the post-complete satellite replay. +void test_only_config_nonce_skips_other_nodeinfos() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(SPECIAL_NONCE_ONLY_CONFIG); + + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_node_info_tag)); // own record only + TEST_ASSERT_EQUAL_UINT(1, t.nodeNums.size()); + TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, countVariant(t, meshtastic_FromRadio_channel_tag)); + TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size()); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_CONFIG, t.completeId); + + // ONLY_CONFIG skips node/satellite sync entirely: the stream must be idle immediately. + uint8_t buf[meshtastic_FromRadio_size]; + TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf)); + TEST_ASSERT_FALSE(api->available()); +} + +// A keepalive heartbeat mid-dump preempts exactly one read with a queueStatus, then the dump +// resumes where it left off; the flag self-clears so nothing repeats or restarts. +void test_heartbeat_mid_dump_preempts_once_then_resumes() +{ + startHandshake(FULL_DUMP_NONCE); + + // Pull the first three header messages, leaving the machine about to send metadata. + meshtastic_FromRadio msg; + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant); + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_deviceuiConfig_tag, msg.which_payload_variant); + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, msg.which_payload_variant); + + sendPlainHeartbeat(); + + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_queueStatus_tag, msg.which_payload_variant, + "heartbeat must be answered with a queueStatus before the dump continues"); + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_metadata_tag, msg.which_payload_variant, + "dump must resume exactly where the heartbeat preempted it"); + + DumpTranscript rest; + TEST_ASSERT_TRUE(drainUntilComplete(rest)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, rest.queueStatusCount, "heartbeat flag must self-clear after one reply"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, countVariant(rest, meshtastic_FromRadio_my_info_tag), + "heartbeat must not restart the dump"); + TEST_ASSERT_EQUAL_UINT32(FULL_DUMP_NONCE, rest.completeId); +} + +// Disconnect mid-dump, then a fresh handshake: the machine restarts from my_info with the new +// nonce and every section is delivered exactly once. +void test_close_mid_dump_then_reconnect_restarts_clean() +{ + seedRemoteNode(SEEDED_NODE_A); + startHandshake(FULL_DUMP_NONCE); + + meshtastic_FromRadio msg; + for (unsigned i = 0; i < 5; i++) + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + + api->close(); + TEST_ASSERT_FALSE(api->isConnected()); + uint8_t buf[meshtastic_FromRadio_size]; + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api->getFromRadio(buf), "a closed connection must emit nothing"); + + startHandshake(SECOND_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, t.variants[0]); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size()); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag)); + TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId); +} + +// A new want_config while a dump is in flight (no disconnect) also restarts the machine, and +// stale mid-section progress must not leak into the new dump. +void test_rehandshake_mid_dump_restarts_from_my_info() +{ + startHandshake(FULL_DUMP_NONCE); + + // Read into the middle of the config section (5 headers + 8 channels + 7 configs). + meshtastic_FromRadio msg; + for (unsigned i = 0; i < NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + 7; i++) + TEST_ASSERT_TRUE(readOneFromRadio(msg)); + + startHandshake(SECOND_NONCE); + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, t.variants[0], "re-handshake must restart from my_info"); + TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size()); + for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++) + TEST_ASSERT_EQUAL_INT((int)i, t.channelIndices[i]); + TEST_ASSERT_EQUAL_UINT_MESSAGE(NUM_CONFIG_MESSAGES, t.configVariants.size(), + "stale config_state leaked into the restarted dump"); + TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size()); + TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId); +} + +// After config_complete_id the trailing satellite replay must reach idle in bounded reads - a +// drain loop keyed on available() must terminate (the infinite-drain regression class). +void test_dump_reaches_idle_after_complete() +{ + seedRemoteNode(SEEDED_NODE_A); + seedRemoteNode(SEEDED_NODE_B); + startHandshake(FULL_DUMP_NONCE); + + DumpTranscript t; + TEST_ASSERT_TRUE(drainUntilComplete(t)); + + uint8_t buf[meshtastic_FromRadio_size]; + bool idle = false; + for (unsigned i = 0; i < 8 && !idle; i++) { + if (!api->available()) + idle = true; + else + api->getFromRadio(buf); // replay drain: empty phases must advance toward idle + } + TEST_ASSERT_TRUE_MESSAGE(idle, "post-complete drain never went idle: available() stuck true"); + TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf)); +} + +} // namespace + +void setUp(void) +{ + savedState = + new GlobalState{service, router, nodeDB, cryptLock, myNodeInfo, channels, channelFile, config, moduleConfig, devicestate}; + + service = mockService = new MeshService(); + // A real boot starts with a zeroed nodeDatabase; in-process the global retains the previous + // test's vector (the decode callback appends, it does not clear), so reset it first. + nodeDatabase.version = 0; + nodeDatabase.nodes.clear(); + nodeDB = testNodeDB = new NodeDB(); + configureTestChannels(); + cryptLock = nullptr; // Router's ctor asserts this is unset before allocating its own. + router = testRouter = new TestRouter(); + api = new PhoneAPITestShim(); + heartbeatReceived = false; +} + +void tearDown(void) +{ + delete api; // dtor runs close(), which still needs the mock service installed + api = nullptr; + delete testRouter; // ~TestRouter() deletes the cryptLock its ctor allocated + testRouter = nullptr; + delete testNodeDB; + testNodeDB = nullptr; + delete mockService; + mockService = nullptr; + heartbeatReceived = false; + + service = savedState->service; + router = savedState->router; + nodeDB = savedState->nodeDB; + cryptLock = savedState->cryptLock; // ~TestRouter() nulled it; hand the saved router its own back + myNodeInfo = savedState->myNodeInfo; + channels = savedState->channels; + channelFile = savedState->channelFile; + config = savedState->config; + moduleConfig = savedState->moduleConfig; + devicestate = savedState->deviceState; + delete savedState; + savedState = nullptr; +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== want_config dump sequence ===\n"); + RUN_TEST(test_full_want_config_dump_emits_documented_sequence); + RUN_TEST(test_config_section_inner_variants_match_config_type_enum); + RUN_TEST(test_module_config_section_inner_variants_match_module_config_type_enum); + + printf("\n=== special nonces ===\n"); + RUN_TEST(test_only_nodes_nonce_sends_nodes_then_complete); + RUN_TEST(test_only_config_nonce_skips_other_nodeinfos); + + printf("\n=== preemption and restart ===\n"); + RUN_TEST(test_heartbeat_mid_dump_preempts_once_then_resumes); + RUN_TEST(test_close_mid_dump_then_reconnect_restarts_clean); + RUN_TEST(test_rehandshake_mid_dump_restarts_from_my_info); + RUN_TEST(test_dump_reaches_idle_after_complete); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_reliable_ack_matrix/test_main.cpp b/test/test_reliable_ack_matrix/test_main.cpp new file mode 100644 index 000000000..635b97903 --- /dev/null +++ b/test/test_reliable_ack_matrix/test_main.cpp @@ -0,0 +1,853 @@ +// ReliableRouter ACK/NAK decision matrix: which ACK or NAK sniffReceived() emits per inbound +// shape, retransmission bookkeeping, the #11502 implicit ACK for our own overheard opaque DM +// (Group 5b drives the real OPAQUE_RELAY_ONLY ingress path), and the pending-timer extensions. +// Harness copied from test_nexthop_routing (ReliableRouterTestShim + MockRoutingModule). + +#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc. +#include "TestUtil.h" +#include + +#include "airtime.h" +#include "configuration.h" +#include "gps/RTC.h" +#include "mesh/Channels.h" +#include "mesh/NodeDB.h" +#include "mesh/RadioInterface.h" +#include "mesh/ReliableRouter.h" +#include "mesh/Throttle.h" +#include "modules/RoutingModule.h" +#include +#include +#include +#include +#include +#include + +static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11 +static constexpr NodeNum kRemoteNode = 0x22222222; +static constexpr NodeNum kThirdNode = 0x33333333; + +// --------------------------------------------------------------------------- +// MockNodeDB - inject sender records with a controlled public-key size, so the PKI_UNKNOWN_PUBKEY +// vs NO_CHANNEL discrimination in sniffReceived() can be driven per test. +// --------------------------------------------------------------------------- +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num, uint8_t publicKeySize = 0) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + node.last_heard = getTime(); + node.public_key.size = publicKeySize; + if (publicKeySize) + memset(node.public_key.bytes, 0x5C, publicKeySize); + nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_HAS_USER_MASK, true); + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + std::vector testNodes; +}; + +// --------------------------------------------------------------------------- +// Test shim - expose the protected sniff/filter entry points and the pending/route-health state. +// --------------------------------------------------------------------------- +class ReliableRouterTestShim : public ReliableRouter +{ + public: + ReliableRouterTestShim() : ReliableRouter() {} + + using NextHopRouter::findRouteHealth; + using NextHopRouter::noteRouteFailure; + using NextHopRouter::noteRouteLearned; + + size_t pendingCount() const { return pending.size(); } + + void seedRetry(const meshtastic_MeshPacket &p, uint8_t attempts) + { + auto *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + startRetransmission(copy, attempts); + } + + void sniffForTest(const meshtastic_MeshPacket *p, const meshtastic_Routing *routing) + { + ReliableRouter::sniffReceived(p, routing); + } + + bool filterForTest(const meshtastic_MeshPacket *p) { return ReliableRouter::shouldFilterReceived(p); } + + bool hasPending(NodeNum from, PacketId id) { return findPendingPacket(from, id) != nullptr; } + + uint32_t pendingNextTx(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + return entry->nextTxMsec; + } + + const meshtastic_MeshPacket *pendingPacket(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(entry); + return entry->packet; + } + + void clearPendingForTest() + { + while (!pending.empty()) + stopRetransmission(pending.begin()->first); + } + + void resetRouteHealthForTest() + { + for (auto &h : routeHealth) + h = RouteHealth{}; + } +}; + +// Capture radio with a configurable per-packet airtime, so the pending-timer extension loops +// (which are no-ops with a 0-returning stub) become observable. +class TimedCaptureRadio : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + bool cancelSending(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + cancelCount++; + return false; + } + + bool findInTxQueue(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + return false; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return packetTimeMsec; + } + + void reset() + { + sentPackets.clear(); + cancelCount = 0; + packetTimeMsec = 0; + } + + std::vector sentPackets; + uint32_t cancelCount = 0; + uint32_t packetTimeMsec = 0; +}; + +class MockRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, + bool ackWantsAck = false) override + { + ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck); + } + + std::list> ackNaks; +}; + +class ScopedAirTimeFixture +{ + public: + ScopedAirTimeFixture() : previous(airTime) { airTime = &instance; } + ~ScopedAirTimeFixture() { airTime = previous; } + + private: + AirTime instance; + AirTime *previous; +}; + +static MockNodeDB *mockNodeDB = nullptr; +static ReliableRouterTestShim *reliableShim = nullptr; +static TimedCaptureRadio *radio = nullptr; +static MockRoutingModule *mockRoutingModule = nullptr; +static std::unique_ptr airTimeFixture; +static PacketId nextTestPacketId = 0x7A000000; + +// --------------------------------------------------------------------------- +// Packet builders +// --------------------------------------------------------------------------- + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, NodeNum from, NodeNum to, uint8_t channel, + bool wantAck = false) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = to; + p.id = nextTestPacketId++; + p.channel = channel; + p.hop_start = 3; + p.hop_limit = 3; // hop_start == hop_limit -> getHopsAway() == 0 ("heard directly") + p.relay_node = 0x22; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.want_ack = wantAck; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = portnum; + return p; +} + +static meshtastic_MeshPacket makeEncryptedToUs(uint8_t channel, bool wantAck) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kRemoteNode; + p.to = kLocalNode; + p.id = nextTestPacketId++; + p.channel = channel; + p.hop_start = 3; + p.hop_limit = 3; + p.relay_node = 0x22; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.want_ack = wantAck; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 32; + return p; +} + +static void expectSingleAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId id, ChannelIndex chIndex, uint8_t hopLimit, + bool ackWantsAck) +{ + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + const auto &ack = mockRoutingModule->ackNaks.front(); + TEST_ASSERT_EQUAL_INT(err, std::get<0>(ack)); + TEST_ASSERT_EQUAL_HEX32(to, std::get<1>(ack)); + TEST_ASSERT_EQUAL_HEX32(id, std::get<2>(ack)); + TEST_ASSERT_EQUAL_UINT8(chIndex, std::get<3>(ack)); + TEST_ASSERT_EQUAL_UINT8(hopLimit, std::get<4>(ack)); + TEST_ASSERT_EQUAL(ackWantsAck, std::get<5>(ack)); +} + +static void configureChannels() +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel primary = meshtastic_Channel_init_default; + primary.index = 0; + primary.has_settings = true; + primary.role = meshtastic_Channel_Role_PRIMARY; + strncpy(primary.settings.name, "primary", sizeof(primary.settings.name) - 1); + + meshtastic_Channel secondary = meshtastic_Channel_init_default; + secondary.index = 1; + secondary.has_settings = true; + secondary.role = meshtastic_Channel_Role_SECONDARY; + strncpy(secondary.settings.name, "second", sizeof(secondary.settings.name) - 1); + secondary.settings.psk.size = 32; + memset(secondary.settings.psk.bytes, 0xAB, secondary.settings.psk.size); + + channelFile.channels[0] = primary; + channelFile.channels[1] = secondary; + channels.onConfigChanged(); +} + +void setUp(void) +{ + myNodeInfo.my_node_num = kLocalNode; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + config.lora.override_duty_cycle = true; + config.lora.hop_limit = 3; // keep getHopLimitForResponse() deterministic across tests + config.security.private_key.size = 0; + owner.is_licensed = false; + // Keep our own key unset: the PKI_UNKNOWN_PUBKEY NAK handler dereferences nodeInfoModule (a null + // global here) only when owner.public_key.size == 32. + owner.public_key.size = 0; + mockNodeDB->clearTestNodes(); + reliableShim->clearPendingForTest(); + reliableShim->resetRouteHealthForTest(); + radio->reset(); + mockRoutingModule->ackNaks.clear(); + configureChannels(); +} + +void tearDown(void) {} + +// =========================================================================== +// Group 1 - want_ack ACK variants (decoded packets to us) +// =========================================================================== + +void test_text_dm_want_ack_gets_want_ack_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + TEST_ASSERT_NOT_EQUAL(0, expectedHop); // must be distinguishable from the 0-hop ACK branch + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true); +} + +void test_text_reply_still_gets_want_ack_ack(void) +{ + // shouldSuccessAckWithWantAck() runs before the response branch, so a text DM that is itself a + // reply still gets the reliable want-ack ACK (not the 0-hop response treatment). + auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.reply_id = 0x1234; + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/true); +} + +void test_nontext_dm_want_ack_gets_plain_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + TEST_ASSERT_NOT_EQUAL(0, expectedHop); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, expectedHop, /*ackWantsAck=*/false); +} + +void test_response_heard_directly_gets_zero_hop_ack(void) +{ + // A response (request_id set) heard at 0 hops: the original sender cannot overhear an implicit + // ACK, so we ACK - but only with hop limit 0. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.request_id = 0x4242; + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); +} + +void test_response_relayed_gets_no_ack(void) +{ + // A relayed response with no next-hop addressing already got its implicit ACK from the + // rebroadcast; ACKing again would only burn airtime. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.request_id = 0x4242; + p.hop_limit = 2; // hop_start 3 -> 1 hop away + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +void test_response_relayed_via_next_hop_gets_zero_hop_ack(void) +{ + // Relayed, but directed at a next_hop: the immediate relayer retransmits until stopped, so a + // 0-hop ACK is still required. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/true); + p.decoded.request_id = 0x4242; + p.hop_limit = 2; + p.next_hop = 0x77; + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); +} + +void test_broadcast_want_ack_gets_no_ack(void) +{ + // 0-hop reliability is unicast-only: a want_ack broadcast is never ACKed (isToUs() is false). + auto p = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, 0, /*wantAck=*/true); + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +// =========================================================================== +// Group 2 - undecodable want_ack NAKs (encrypted packets to us) +// =========================================================================== + +void test_pki_unknown_sender_gets_pki_unknown_pubkey_nak(void) +{ + // channel==0 + sender absent from NodeDB -> the PKI key-amnesia NAK, on the primary channel. + auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +void test_pki_keyless_sender_record_gets_pki_unknown_pubkey_nak(void) +{ + // The sender is in the DB but we hold no key for it - same NAK as a fully unknown node. + mockNodeDB->addNode(kRemoteNode, /*publicKeySize=*/0); + auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +void test_pki_known_key_sender_gets_no_channel_nak(void) +{ + // Discriminator: with the sender's key on hand an undecodable channel-0 want_ack packet is NOT a + // key problem, so it falls through to the generic NO_CHANNEL NAK. + mockNodeDB->addNode(kRemoteNode, /*publicKeySize=*/32); + auto p = makeEncryptedToUs(/*channel=*/0, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NO_CHANNEL, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +void test_unknown_channel_hash_gets_no_channel_nak(void) +{ + // Nonzero channel hash we cannot decode -> NO_CHANNEL on the primary channel (not the hash). + auto p = makeEncryptedToUs(/*channel=*/0x5A, /*wantAck=*/true); + uint8_t expectedHop = mockRoutingModule->getHopLimitForResponse(p); + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NO_CHANNEL, kRemoteNode, p.id, channels.getPrimaryIndex(), expectedHop, + /*ackWantsAck=*/false); +} + +// =========================================================================== +// Group 3 - no want_ack, but we are the addressed next hop +// =========================================================================== + +void test_next_hop_addressed_to_us_gets_zero_hop_ack(void) +{ + // We were the addressed next hop: a 0-hop ACK stops the relayer's retransmissions even though + // the packet itself did not ask for an ACK. + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + p.next_hop = 0x11; // our last byte + p.hop_limit = 1; + + reliableShim->sniffForTest(&p, nullptr); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kRemoteNode, p.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); +} + +void test_next_hop_with_hop_limit_zero_gets_no_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + p.next_hop = 0x11; + p.hop_limit = 0; + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +void test_next_hop_other_byte_gets_no_ack(void) +{ + auto p = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + p.next_hop = 0x22; // someone else's byte + p.hop_limit = 1; + + reliableShim->sniffForTest(&p, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +// =========================================================================== +// Group 4 - explicit ACK/NAK vs pending retransmissions, MQTT gate, route health +// =========================================================================== + +void test_explicit_ack_stops_retransmissions_and_clears_route_failures(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + reliableShim->noteRouteLearned(kRemoteNode, 0xAB, millis()); + reliableShim->noteRouteFailure(kRemoteNode); + reliableShim->noteRouteFailure(kRemoteNode); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + + auto ack = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + ack.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_NONE; + + reliableShim->sniffForTest(&ack, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); + // The end-to-end ACK proves the route to its sender works -> noteRouteSuccess clears failures. + RouteHealth *h = reliableShim->findRouteHealth(kRemoteNode); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures); +} + +void test_nak_stops_retransmissions_but_keeps_route_failures(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + reliableShim->noteRouteLearned(kRemoteNode, 0xAB, millis()); + reliableShim->noteRouteFailure(kRemoteNode); + reliableShim->noteRouteFailure(kRemoteNode); + + auto nak = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + nak.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_MAX_RETRANSMIT; + + reliableShim->sniffForTest(&nak, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); + // A NAK is not a delivery success: the failure count must survive. + RouteHealth *h = reliableShim->findRouteHealth(kRemoteNode); + TEST_ASSERT_NOT_NULL(h); + TEST_ASSERT_EQUAL_UINT8(2, h->consecutiveFailures); +} + +void test_pki_unknown_pubkey_nak_stops_retransmissions(void) +{ + // The remote lost our key: its PKI_UNKNOWN_PUBKEY NAK must still clear the pending record. + // owner.public_key.size == 0 (setUp) keeps the NodeInfo re-send branch (a nodeInfoModule + // dereference, null in this harness) out of the path. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto nak = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + nak.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY; + + reliableShim->sniffForTest(&nak, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_own_ack_echo_via_mqtt_keeps_retransmissions(void) +{ + // An implicit ACK that is our own traffic echoed back via MQTT must not stop LoRa retries. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto echo = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kLocalNode, kLocalNode, 1); + echo.decoded.request_id = original.id; + echo.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + + reliableShim->sniffForTest(&echo, nullptr); + + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + TEST_ASSERT_TRUE(reliableShim->hasPending(kLocalNode, original.id)); +} + +void test_own_ack_echo_via_lora_stops_retransmissions(void) +{ + // Control for the MQTT gate: the identical from-us echo via LoRa does stop the retries. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto echo = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kLocalNode, kLocalNode, 1); + echo.decoded.request_id = original.id; + echo.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + + reliableShim->sniffForTest(&echo, nullptr); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_remote_ack_via_mqtt_still_stops_retransmissions(void) +{ + // The gate is scoped to from-us echoes: a genuine end-to-end ACK arriving over MQTT counts. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto ack = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + ack.decoded.request_id = original.id; + ack.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_NONE; + + reliableShim->sniffForTest(&ack, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +// =========================================================================== +// Group 5 - implicit ACK for our own overheard DM through shouldFilterReceived. This is the +// pre-existing route (a decodable copy still in encrypted wire form reaches it); the #11502 +// opaque short-circuit is exercised separately in Group 5b. +// =========================================================================== + +void test_overheard_own_dm_rebroadcast_mints_implicit_ack(void) +{ + // The implicit ACK is minted from the header alone (from/id), so this route must work on a + // still-encrypted packet, and the LoRa copy stops the retransmissions. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = kLocalNode; + overheard.to = kRemoteNode; + overheard.id = original.id; + overheard.hop_start = 3; + overheard.hop_limit = 2; + overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + overheard.encrypted.size = 32; + + reliableShim->filterForTest(&overheard); + + // ACK is addressed to us (so it reaches the phone) on the pending copy's channel. + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_overheard_own_dm_via_mqtt_acks_but_keeps_retransmissions(void) +{ + // The MQTT copy still surfaces "Delivered to mesh" but must not cancel the LoRa retries. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + meshtastic_MeshPacket overheard = meshtastic_MeshPacket_init_zero; + overheard.from = kLocalNode; + overheard.to = kRemoteNode; + overheard.id = original.id; + overheard.hop_start = 3; + overheard.hop_limit = 2; + overheard.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; + overheard.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + overheard.encrypted.size = 32; + + reliableShim->filterForTest(&overheard); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_overheard_foreign_packet_mints_no_implicit_ack(void) +{ + // Someone else's traffic must never mint an ACK, even with a colliding packet id. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + meshtastic_MeshPacket foreign = meshtastic_MeshPacket_init_zero; + foreign.from = kRemoteNode; + foreign.to = kThirdNode; + foreign.id = original.id; + foreign.hop_start = 3; + foreign.hop_limit = 2; + foreign.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + foreign.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + foreign.encrypted.size = 32; + + reliableShim->filterForTest(&foreign); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +// =========================================================================== +// Group 5b - the real #11502 wiring: an overheard own DM under a channel hash we cannot decode +// (a PKI DM we sent) is OPAQUE_RELAY_ONLY in Router::perhapsHandleReceived and returns BEFORE +// shouldFilterReceived; the fix is the isFromUs branch there. Driven through the public ingress +// queue (enqueueReceivedMessage + runOnce), so deleting that branch fails these tests. +// =========================================================================== + +// An encrypted copy of our own DM under an unknown channel hash: not to us (no PKI attempt), no +// hash match -> DECODE_OPAQUE -> OPAQUE_RELAY_ONLY. hop_limit > 0 so the opaque relay does not +// short-circuit before the ACK branch. +static meshtastic_MeshPacket makeOpaqueOwnOverheard(PacketId id, meshtastic_MeshPacket_TransportMechanism transport) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kLocalNode; + p.to = kRemoteNode; + p.id = id; + p.channel = 0x5A; + p.hop_start = 3; + p.hop_limit = 2; + p.transport_mechanism = transport; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 32; + memset(p.encrypted.bytes, 0xC3, p.encrypted.size); + return p; +} + +static void ingressOverheard(const meshtastic_MeshPacket &p) +{ + meshtastic_MeshPacket *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + reliableShim->enqueueReceivedMessage(copy); + reliableShim->runOnce(); +} + +void test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA)); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + +void test_ingress_opaque_own_dm_mqtt_acks_but_keeps_retries(void) +{ + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + ingressOverheard(makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT)); + + expectSingleAckNak(meshtastic_Routing_Error_NONE, kLocalNode, original.id, 1, /*hopLimit=*/0, /*ackWantsAck=*/false); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_ingress_opaque_foreign_packet_mints_no_implicit_ack(void) +{ + // The isFromUs guard on the opaque branch: someone else's opaque traffic with a colliding id + // is relayed but never ACKed. + auto original = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + + auto foreign = makeOpaqueOwnOverheard(original.id, meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA); + foreign.from = kRemoteNode; + foreign.to = kThirdNode; + ingressOverheard(foreign); + + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +// =========================================================================== +// Group 6 - pending-timer airtime extension in send() and shouldFilterReceived() +// =========================================================================== + +void test_send_extends_other_pending_deadlines_not_own(void) +{ + // While we transmit packet B we cannot hear an (implicit) ACK for pending A, so A's deadline + // must move out by B's airtime. B's own fresh record must not be self-extended. + radio->packetTimeMsec = 50000; // dwarfs any real time elapsed inside the test + + auto a = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(a, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + uint32_t aBefore = reliableShim->pendingNextTx(kLocalNode, a.id); + + auto b = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, NODENUM_BROADCAST, 0, /*wantAck=*/true); + auto *allocated = packetPool.allocCopy(b); + TEST_ASSERT_NOT_NULL(allocated); + TEST_ASSERT_EQUAL_INT(ERRNO_OK, reliableShim->send(allocated)); + + TEST_ASSERT_EQUAL_UINT32(2, reliableShim->pendingCount()); + TEST_ASSERT_EQUAL_UINT32(aBefore + 50000, reliableShim->pendingNextTx(kLocalNode, a.id)); + + // B's deadline is millis-at-set + getRetransmissionMsec(B); a self-extension would push it a + // further 50s out, past anything the wall clock could account for. + uint32_t bTx = reliableShim->pendingNextTx(kLocalNode, b.id); + uint32_t retrans = radio->getRetransmissionMsec(reliableShim->pendingPacket(kLocalNode, b.id)); + // Via Throttle rather than a bare millis() compare, per the house deadline rule. + TEST_ASSERT_TRUE_MESSAGE(Throttle::deadlinePassed(bTx - retrans), "own record must not be extended by its own send"); +} + +void test_receive_extends_all_pending_deadlines(void) +{ + // While receiving any packet we cannot hear an ACK either: every pending deadline moves out by + // the received packet's airtime. + radio->packetTimeMsec = 40000; + + auto a = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(a, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + auto b = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kThirdNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(b, NextHopRouter::NUM_RELIABLE_UNICAST_ATTEMPTS); + uint32_t aBefore = reliableShim->pendingNextTx(kLocalNode, a.id); + uint32_t bBefore = reliableShim->pendingNextTx(kLocalNode, b.id); + + auto inbound = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kLocalNode, 1, /*wantAck=*/false); + reliableShim->filterForTest(&inbound); + + TEST_ASSERT_EQUAL_UINT32(aBefore + 40000, reliableShim->pendingNextTx(kLocalNode, a.id)); + TEST_ASSERT_EQUAL_UINT32(bBefore + 40000, reliableShim->pendingNextTx(kLocalNode, b.id)); +} + +// =========================================================================== + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + airTimeFixture = std::make_unique(); + mockNodeDB = new MockNodeDB(); + nodeDB = mockNodeDB; + reliableShim = new ReliableRouterTestShim(); + + auto capture = std::make_unique(); + radio = capture.get(); + reliableShim->addInterface(std::move(capture)); + + mockRoutingModule = new MockRoutingModule(); + routingModule = mockRoutingModule; + + printf("\n=== want_ack ACK variants ===\n"); + RUN_TEST(test_text_dm_want_ack_gets_want_ack_ack); + RUN_TEST(test_text_reply_still_gets_want_ack_ack); + RUN_TEST(test_nontext_dm_want_ack_gets_plain_ack); + RUN_TEST(test_response_heard_directly_gets_zero_hop_ack); + RUN_TEST(test_response_relayed_gets_no_ack); + RUN_TEST(test_response_relayed_via_next_hop_gets_zero_hop_ack); + RUN_TEST(test_broadcast_want_ack_gets_no_ack); + + printf("\n=== undecodable want_ack NAKs ===\n"); + RUN_TEST(test_pki_unknown_sender_gets_pki_unknown_pubkey_nak); + RUN_TEST(test_pki_keyless_sender_record_gets_pki_unknown_pubkey_nak); + RUN_TEST(test_pki_known_key_sender_gets_no_channel_nak); + RUN_TEST(test_unknown_channel_hash_gets_no_channel_nak); + + printf("\n=== next-hop 0-hop ACK without want_ack ===\n"); + RUN_TEST(test_next_hop_addressed_to_us_gets_zero_hop_ack); + RUN_TEST(test_next_hop_with_hop_limit_zero_gets_no_ack); + RUN_TEST(test_next_hop_other_byte_gets_no_ack); + + printf("\n=== ACK/NAK vs pending retransmissions ===\n"); + RUN_TEST(test_explicit_ack_stops_retransmissions_and_clears_route_failures); + RUN_TEST(test_nak_stops_retransmissions_but_keeps_route_failures); + RUN_TEST(test_pki_unknown_pubkey_nak_stops_retransmissions); + RUN_TEST(test_own_ack_echo_via_mqtt_keeps_retransmissions); + RUN_TEST(test_own_ack_echo_via_lora_stops_retransmissions); + RUN_TEST(test_remote_ack_via_mqtt_still_stops_retransmissions); + + printf("\n=== implicit ACK for our own overheard DM ===\n"); + RUN_TEST(test_overheard_own_dm_rebroadcast_mints_implicit_ack); + RUN_TEST(test_overheard_own_dm_via_mqtt_acks_but_keeps_retransmissions); + RUN_TEST(test_overheard_foreign_packet_mints_no_implicit_ack); + + printf("\n=== implicit ACK through the opaque ingress short-circuit (#11502) ===\n"); + RUN_TEST(test_ingress_opaque_own_dm_lora_mints_implicit_ack_and_stops_retries); + RUN_TEST(test_ingress_opaque_own_dm_mqtt_acks_but_keeps_retries); + RUN_TEST(test_ingress_opaque_foreign_packet_mints_no_implicit_ack); + + printf("\n=== pending-timer airtime extension ===\n"); + RUN_TEST(test_send_extends_other_pending_deadlines_not_own); + RUN_TEST(test_receive_extends_all_pending_deadlines); + + int result = UNITY_END(); + airTimeFixture.reset(); + exit(result); +} + +void loop() {} diff --git a/test/test_routing_response_hops/test_main.cpp b/test/test_routing_response_hops/test_main.cpp new file mode 100644 index 000000000..53bacd979 --- /dev/null +++ b/test/test_routing_response_hops/test_main.cpp @@ -0,0 +1,273 @@ +// RoutingModule::getHopLimitForResponse - the hop budget stamped on every reply/ACK/NAK - and +// MeshModule::setReplyTo() applying it, driven through getHopsAway()'s sentinel rules. + +#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc. +#include "TestUtil.h" +#include // exit(), needed on both guard branches +#include + +// Event mode compiles out the uncapped long-path branch and swaps the configured limit for the +// event hop limit; this suite pins the standard-mode branches only (the event cap is covered by +// test_default's event-mode group). +#if !USERPREFS_EVENT_MODE + +#include "configuration.h" +#include "mesh/MeshModule.h" +#include "mesh/NodeDB.h" +#include "modules/RoutingModule.h" +#include + +static constexpr NodeNum kRequester = 0x22222222; + +static RoutingModule *testRoutingModule = nullptr; + +// A received request packet whose hop fields we control. Decoded packets carry the bitfield flag +// that getHopsAway() uses to decide whether hop_start==0 is genuine or a legacy-firmware zero. +static meshtastic_MeshPacket makeRequest(uint8_t hopStart, uint8_t hopLimit, bool decoded = true, bool hasBitfield = true) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = kRequester; + p.to = 0x11111111; + p.id = 0xABCD1234; + p.hop_start = hopStart; + p.hop_limit = hopLimit; + if (decoded) { + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.has_bitfield = hasBitfield; + } else { + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 8; + } + return p; +} + +static meshtastic_MeshPacket makeReply() +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + return p; +} + +void setUp(void) +{ + config.lora.hop_limit = 3; +} + +void tearDown(void) {} + +// =========================================================================== +// Group 1 - unknown hop distance: every unreliable-header shape must fall back +// to the configured limit, never to a value derived from the bogus fields. +// =========================================================================== + +void test_encrypted_hop_start_zero_falls_back_to_configured_limit(void) +{ + // Encrypted packet: the bitfield is unreadable, so hop_start==0 cannot be trusted. + auto request = makeRequest(0, 0, /*decoded=*/false); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_decoded_legacy_no_bitfield_falls_back_to_configured_limit(void) +{ + // Pre-2.3.0 senders never populate hop_start and pre-2.5.0 senders never set the bitfield. + auto request = makeRequest(0, 0, /*decoded=*/true, /*hasBitfield=*/false); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_forged_hop_start_below_hop_limit_falls_back_to_configured_limit(void) +{ + // hop_start < hop_limit is impossible for an honest sender; getHopsAway() rejects it. + auto request = makeRequest(2, 5); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_hostile_hop_start_wraps_negative_falls_back_to_configured_limit(void) +{ + // hop_start is 3 bits on the wire but 8 bits via local injection: 255 - 0 narrows to + // int8_t -1 in getHopsAway(), which lands in the same "unknown" fallback (any + // hop_start - hop_limit >= 128 reads as negative). + auto request = makeRequest(255, 0); + TEST_ASSERT_EQUAL_UINT8(3, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 2 - known hop distance: hopsUsed + 2 margin, its clamp boundary, and +// the intentionally uncapped long-path branch. +// =========================================================================== + +void test_direct_neighbor_response_gets_two_hop_margin(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(3, 3); // 0 hops used + TEST_ASSERT_EQUAL_UINT8(2, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_two_hops_used_gets_margin_of_two(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(3, 1); // 2 hops used + TEST_ASSERT_EQUAL_UINT8(4, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_margin_just_below_boundary_still_applies(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(7, 3); // 4 hops used: 4 + 2 = 6 < 7 + TEST_ASSERT_EQUAL_UINT8(6, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_margin_at_boundary_clamps_to_configured_limit(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(7, 2); // 5 hops used: 5 + 2 == 7, not < 7 -> clamp + TEST_ASSERT_EQUAL_UINT8(7, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_hops_equal_to_limit_returns_limit(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(7, 0); // 7 hops used == limit: not "more than", no margin room + TEST_ASSERT_EQUAL_UINT8(7, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_long_path_exceeds_configured_limit_uncapped(void) +{ + // Intentional exceed: a request that took more hops than our configured limit gets a + // response with the same hop count, otherwise the reply dies short of the requester. + auto request = makeRequest(7, 0); // 7 hops used, configured limit 3 + TEST_ASSERT_EQUAL_UINT8(7, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 3 - zero-hop requester +// =========================================================================== + +void test_zero_hop_requester_gets_zero_hop_response(void) +{ + // hop_start==0 with the bitfield present is a genuine "0 hops requested": the sender is + // modern firmware that deliberately sent direct-only, so the response stays local too. + auto request = makeRequest(0, 0, /*decoded=*/true, /*hasBitfield=*/true); + TEST_ASSERT_EQUAL_UINT8(0, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 4 - configured-limit edges through Default::getConfiguredOrDefaultHopLimit +// =========================================================================== + +void test_config_above_hop_max_clamps_to_hop_max(void) +{ + config.lora.hop_limit = 10; // out-of-range config (protobuf allows up to 255) + auto request = makeRequest(0, 0, /*decoded=*/false); + TEST_ASSERT_EQUAL_UINT8(HOP_MAX, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_config_zero_yields_zero_for_unknown_hops(void) +{ + // Pins current behavior: getConfiguredOrDefaultHopLimit(0) passes the zero through (no + // default substitution), so an unknown-distance requester gets a 0-hop response. + config.lora.hop_limit = 0; + auto request = makeRequest(0, 0, /*decoded=*/false); + TEST_ASSERT_EQUAL_UINT8(0, testRoutingModule->getHopLimitForResponse(request)); +} + +void test_config_zero_known_hops_returns_hops_used(void) +{ + // With a zero configured limit, any known hop count is "more than the limit" and is used + // as-is - a zero config does not strand replies to multi-hop requesters. + config.lora.hop_limit = 0; + auto request = makeRequest(3, 1); // 2 hops used + TEST_ASSERT_EQUAL_UINT8(2, testRoutingModule->getHopLimitForResponse(request)); +} + +// =========================================================================== +// Group 5 - setReplyTo() stamps the computed hop limit onto reply packets +// =========================================================================== + +void test_setreplyto_stamps_computed_hop_limit_and_reply_fields(void) +{ + config.lora.hop_limit = 7; + auto request = makeRequest(3, 1); // 2 hops used -> response hop limit 4 + request.channel = 2; + request.want_ack = true; + + auto reply = makeReply(); + setReplyTo(&reply, request); + + TEST_ASSERT_EQUAL_HEX32(kRequester, reply.to); + TEST_ASSERT_EQUAL_UINT8(2, reply.channel); + TEST_ASSERT_EQUAL_UINT8(4, reply.hop_limit); + TEST_ASSERT_TRUE(reply.want_ack); + TEST_ASSERT_EQUAL_HEX32(request.id, reply.decoded.request_id); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_Priority_RELIABLE, reply.priority); +} + +void test_setreplyto_preserves_existing_priority(void) +{ + auto request = makeRequest(0, 0, /*decoded=*/false); // unknown hops -> configured limit 3 + request.want_ack = false; + + auto reply = makeReply(); + reply.priority = meshtastic_MeshPacket_Priority_ACK; + setReplyTo(&reply, request); + + TEST_ASSERT_EQUAL_UINT8(3, reply.hop_limit); + TEST_ASSERT_FALSE(reply.want_ack); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_Priority_ACK, reply.priority); +} + +// =========================================================================== + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + testRoutingModule = new RoutingModule(); + routingModule = testRoutingModule; // setReplyTo() reaches the module through the global + + printf("\n=== unknown hop distance falls back to configured limit ===\n"); + RUN_TEST(test_encrypted_hop_start_zero_falls_back_to_configured_limit); + RUN_TEST(test_decoded_legacy_no_bitfield_falls_back_to_configured_limit); + RUN_TEST(test_forged_hop_start_below_hop_limit_falls_back_to_configured_limit); + RUN_TEST(test_hostile_hop_start_wraps_negative_falls_back_to_configured_limit); + + printf("\n=== known hop distance: margin, clamp, uncapped long path ===\n"); + RUN_TEST(test_direct_neighbor_response_gets_two_hop_margin); + RUN_TEST(test_two_hops_used_gets_margin_of_two); + RUN_TEST(test_margin_just_below_boundary_still_applies); + RUN_TEST(test_margin_at_boundary_clamps_to_configured_limit); + RUN_TEST(test_hops_equal_to_limit_returns_limit); + RUN_TEST(test_long_path_exceeds_configured_limit_uncapped); + + printf("\n=== zero-hop requester ===\n"); + RUN_TEST(test_zero_hop_requester_gets_zero_hop_response); + + printf("\n=== configured-limit edges ===\n"); + RUN_TEST(test_config_above_hop_max_clamps_to_hop_max); + RUN_TEST(test_config_zero_yields_zero_for_unknown_hops); + RUN_TEST(test_config_zero_known_hops_returns_hops_used); + + printf("\n=== setReplyTo integration ===\n"); + RUN_TEST(test_setreplyto_stamps_computed_hop_limit_and_reply_fields); + RUN_TEST(test_setreplyto_preserves_existing_priority); + + exit(UNITY_END()); +} + +void loop() {} + +#else // USERPREFS_EVENT_MODE + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +void loop() {} + +#endif diff --git a/test/test_rtc/test_main.cpp b/test/test_rtc/test_main.cpp index 02cad01c4..c03358d0a 100644 --- a/test/test_rtc/test_main.cpp +++ b/test/test_rtc/test_main.cpp @@ -1,5 +1,7 @@ #include "TestUtil.h" +#include "UptimeClock.h" #include "gps/RTC.h" +#include #include #include #include @@ -16,12 +18,51 @@ static const uint32_t kAllowedDriftSeconds = 2; static const time_t kUptimeSeconds = 21; // what gettimeofday() returns on RP2040 without a real clock +// Mirrors FORTY_YEARS in RTC.h, which is only visible when BUILD_EPOCH is defined. BUILD_EPOCH is +// injected by bin/platformio-custom.py into the src/ build (projenv) but not into test sources, so +// this TU cannot #ifdef on it; the bounds tests below probe for it at runtime instead. +static const uint64_t kFortyYears = 40ULL * 365 * SEC_PER_DAY; + +#define MSG_BUF_LEN 200 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + // A clearly-valid wall-clock epoch, safely inside any BUILD_EPOCH validity window. static time_t makeValidEpoch() { return time(NULL) + SEC_PER_DAY; } +static struct timeval makeTv(time_t secs) +{ + struct timeval tv; + tv.tv_sec = secs; + tv.tv_usec = 0; + return tv; +} + +// Freeze the injected uptime clock at baseMs. perhapsSetRTC() anchors timeStartMs64 at the fake +// "now", so while the clock is frozen getTime() returns the applied epoch exactly - no drift +// tolerance needed. Reset the wrap carry first: a prior test may have published a larger instant, +// and stepping the clock backwards past a published snapshot reads as a ~49.7-day wrap. +static void beginFakeClock(uint32_t baseMs) +{ + Time::resetMonotonicForTests(); + Time::setTestMillis(baseMs); + Time::serviceMonotonic(); +} + +// Step the injected clock the way the firmware does: every advance is followed by a publish. +static void advanceFakeClock(uint32_t deltaMs) +{ + Time::advanceTestMillis(deltaMs); + Time::serviceMonotonic(); +} + void setUp(void) { resetRTCStateForTests(); @@ -29,6 +70,8 @@ void setUp(void) void tearDown(void) { + Time::useRealClock(); // don't leak the fake clock into later tests or other suites + Time::resetMonotonicForTests(); resetRTCStateForTests(); } @@ -68,6 +111,316 @@ static void test_readFromRTC_initializes_time_when_no_better_source(void) TEST_ASSERT_UINT32_WITHIN(kAllowedDriftSeconds, (uint32_t)systemEpoch, getTime()); } +// --- perhapsSetRTC(timeval) quality arbitration --- + +// FromNet/Device sources are always rejected below a higher quality, and the rejection must +// leave quality and the running clock untouched (the #9828 mesh-time-poisoning family). NTP +// below GPS is rejected only while the 30-min drift throttle (stamped by the GPS set) is live; +// after it expires, NTP deliberately replaces even GPS-quality time (RTC.cpp drift-correction +// branch) - both halves are pinned here. +static void test_downgrade_rejected_state_untouched(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); + + struct timeval poison = makeTv(gpsEpoch + 777); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &poison)); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityDevice, &poison)); + // NTP below GPS: within 30 minutes of the GPS set (which stamped the drift throttle), rejected. + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityNTP, &poison)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + + // Time still tracks the GPS epoch, not the rejected one. + advanceFakeClock(5 * 1000); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 5, getTime()); + + // Once the drift throttle expires, NTP replaces GPS-quality time on purpose (drift + // correction), while FromNet/Device stay rejected: the throttle escape is NTP-only. + advanceFakeClock(31 * 60 * 1000); + struct timeval stillPoison = makeTv(gpsEpoch + 555); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &stillPoison)); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityDevice, &stillPoison)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + struct timeval drift = makeTv(gpsEpoch + 999); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &drift)); + TEST_ASSERT_EQUAL_INT(RTCQualityNTP, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 999, getTime()); +} + +// Equal-quality FromNet has no reapply branch: the second set is ignored. +static void test_equal_quality_fromnet_is_not_reapplied(void) +{ + beginFakeClock(60 * 1000); + const time_t firstEpoch = makeValidEpoch(); + struct timeval tv = makeTv(firstEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + + struct timeval second = makeTv(firstEpoch + 500); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &second)); + TEST_ASSERT_EQUAL_INT(RTCQualityFromNet, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch, getTime()); +} + +// Our own GPS is authoritative: a GPS-quality set is always applied, with no throttle. +static void test_gps_reapply_always_accepted(void) +{ + beginFakeClock(60 * 1000); + const time_t firstEpoch = makeValidEpoch(); + struct timeval tv = makeTv(firstEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct timeval second = makeTv(firstEpoch + 123); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &second)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 123, getTime()); +} + +// Equal-quality NTP reapplies only after the 30-minute drift-correction throttle. +static void test_ntp_drift_throttle(void) +{ + beginFakeClock(120 * 1000); + const time_t firstEpoch = makeValidEpoch(); + struct timeval tv = makeTv(firstEpoch); + // The upgrade from None stamps the (function-static, not reset by resetRTCStateForTests) + // throttle timestamp at a known fake instant, keeping this test order-independent. + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &tv)); + + advanceFakeClock(10 * 60 * 1000); // +10 min: still inside the throttle window + struct timeval second = makeTv(firstEpoch + 900); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityNTP, &second)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 600, getTime()); + + advanceFakeClock(21 * 60 * 1000); // total +31 min: past the window + struct timeval third = makeTv(firstEpoch + 2000); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &third)); + TEST_ASSERT_EQUAL_INT(RTCQualityNTP, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)firstEpoch + 2000, getTime()); +} + +// forceUpdate applies the incoming time even when it is a quality downgrade - the T-Watch +// RTC-pause workaround depends on this override. +static void test_force_update_overrides_downgrade(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct timeval forced = makeTv(gpsEpoch + 42); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityDevice, &forced, true)); + TEST_ASSERT_EQUAL_INT(RTCQualityDevice, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch + 42, getTime()); +} + +// The BUILD_EPOCH validity window rejects implausible epochs before quality arbitration - even +// with forceUpdate - and leaves state untouched. BUILD_EPOCH is not visible to this TU (see +// kFortyYears above), so probe at runtime whether RTC.cpp was built with the window enabled. +static void test_build_epoch_bounds_rejected(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct timeval ancient = makeTv(1000000); // Jan 1970: below any plausible build epoch + RTCSetResult probe = perhapsSetRTC(RTCQualityGPS, &ancient); + if (probe == RTCSetResultSuccess) { + TEST_IGNORE_MESSAGE("BUILD_EPOCH not defined in the RTC.cpp build; validity window inactive"); + } + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, probe); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); + + // BUILD_EPOCH <= time(NULL) at run time, so this is strictly beyond BUILD_EPOCH + FORTY_YEARS. + struct timeval far = makeTv((time_t)((uint64_t)time(NULL) + kFortyYears + 2 * SEC_PER_DAY)); + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, &far)); + + // The window is checked before the forceUpdate override: force cannot smuggle in garbage. + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, &ancient, true)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); +} + +// --- perhapsSetRTC(tm) overload --- + +// The tm overload converts via gm_mktime and lands on the timeval path: a valid broken-down UTC +// time round-trips to the exact epoch (host gmtime() is the independent inverse). +static void test_tm_overload_roundtrip(void) +{ + beginFakeClock(60 * 1000); + const time_t epoch = makeValidEpoch(); + struct tm t = *gmtime(&epoch); + + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, t)); + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)epoch, getTime()); +} + +// Implausible years are rejected with state untouched. On BUILD_EPOCH builds the validity window +// fires first, on windowless builds the tm_year guard (<0 or >=300) does; either way the caller +// must see RTCSetResultInvalidTime. +static void test_tm_overload_year_guard(void) +{ + beginFakeClock(60 * 1000); + const time_t gpsEpoch = makeValidEpoch(); + struct timeval tv = makeTv(gpsEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &tv)); + + struct tm farFuture = {}; + farFuture.tm_year = 300; // year 2200 + farFuture.tm_mon = 5; + farFuture.tm_mday = 15; + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, farFuture)); + + struct tm preEpoch = {}; + preEpoch.tm_year = -5; // year 1895 + preEpoch.tm_mon = 0; + preEpoch.tm_mday = 1; + TEST_ASSERT_EQUAL_INT(RTCSetResultInvalidTime, perhapsSetRTC(RTCQualityGPS, preEpoch)); + + TEST_ASSERT_EQUAL_INT(RTCQualityGPS, getRTCQuality()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)gpsEpoch, getTime()); +} + +// --- getValidTime() threshold gating --- + +static void test_getvalidtime_threshold_gating(void) +{ + beginFakeClock(60 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityDevice)); + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityFromNet)); + + const time_t netEpoch = makeValidEpoch(); + struct timeval tv = makeTv(netEpoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch, getValidTime(RTCQualityFromNet)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch, getValidTime(RTCQualityDevice)); // at-or-below passes + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityNTP)); + TEST_ASSERT_EQUAL_UINT32(0, getValidTime(RTCQualityGPS)); + + struct timeval gps = makeTv(netEpoch + 60); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &gps)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch + 60, getValidTime(RTCQualityGPS)); + TEST_ASSERT_EQUAL_UINT32((uint32_t)netEpoch + 60, getValidTime(RTCQualityNTP)); +} + +// --- lastSetFromPhoneNtpOrGps stamp --- + +// Stamped only for quality >= NTP: this is the input PositionModule::hasQualityTimesource() uses +// to gate mesh-time acceptance, so a FromNet or Device set must never refresh it. +static void test_lastSetFromPhoneNtpOrGps_stamp(void) +{ + beginFakeClock(200 * 1000); + TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); + + const time_t epoch = makeValidEpoch(); + struct timeval tv = makeTv(epoch); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &tv)); + TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); // FromNet does not stamp + + advanceFakeClock(1000); + struct timeval ntp = makeTv(epoch + 1); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityNTP, &ntp)); + TEST_ASSERT_EQUAL_UINT32(201 * 1000, lastSetFromPhoneNtpOrGps); + + advanceFakeClock(2000); + struct timeval net = makeTv(epoch + 3); + TEST_ASSERT_EQUAL_INT(RTCSetResultNotSet, perhapsSetRTC(RTCQualityFromNet, &net)); + TEST_ASSERT_EQUAL_UINT32(201 * 1000, lastSetFromPhoneNtpOrGps); // rejection leaves the stamp + + advanceFakeClock(3000); + struct timeval gps = makeTv(epoch + 6); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityGPS, &gps)); + TEST_ASSERT_EQUAL_UINT32(206 * 1000, lastSetFromPhoneNtpOrGps); + + // Device-quality set from a clean slate: applied, but still no stamp. + resetRTCStateForTests(); + struct timeval dev = makeTv(epoch + 9); + TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityDevice, &dev)); + TEST_ASSERT_EQUAL_UINT32(0, lastSetFromPhoneNtpOrGps); +} + +// --- gm_mktime known answers --- + +// Hardcoded expected epochs (no host timegm dependence). The native build compiles the hand-rolled +// UTC path (!MESHTASTIC_EXCLUDE_TZ), so these pin its leap-day and century rules directly. +static void test_gm_mktime_known_epochs(void) +{ + struct KnownAnswer { + int year, mon1, mday, hour, min, sec; // human calendar: year AD, month 1-12 + int64_t expected; + }; + static const KnownAnswer cases[] = { + {1970, 1, 1, 0, 0, 0, 0LL}, + {1970, 3, 1, 0, 0, 0, 5097600LL}, // non-leap February + {1972, 2, 29, 0, 0, 0, 68169600LL}, // first leap day after the epoch + {1999, 12, 31, 23, 59, 59, 946684799LL}, // second before Y2K + {2000, 1, 1, 0, 0, 0, 946684800LL}, + {2000, 2, 29, 12, 0, 0, 951825600LL}, // 400-year-rule leap day + {2000, 3, 1, 0, 0, 0, 951868800LL}, + {2023, 2, 28, 23, 59, 59, 1677628799LL}, // last second of a non-leap February + {2024, 2, 29, 0, 0, 0, 1709164800LL}, + {2024, 3, 1, 0, 0, 0, 1709251200LL}, + {2038, 1, 19, 3, 14, 7, 2147483647LL}, // INT32_MAX second + {2038, 1, 19, 3, 14, 8, 2147483648LL}, // one past it: 64-bit time_t on native + {2100, 2, 28, 0, 0, 0, 4107456000LL}, // 2100 is NOT leap (100-year rule) + {2100, 3, 1, 0, 0, 0, 4107542400LL}, + {2400, 2, 29, 0, 0, 0, 13574563200LL}, // 2400 IS leap (400-year rule) + }; + + for (const KnownAnswer &c : cases) { + struct tm t = {}; + t.tm_year = c.year - 1900; + t.tm_mon = c.mon1 - 1; + t.tm_mday = c.mday; + t.tm_hour = c.hour; + t.tm_min = c.min; + t.tm_sec = c.sec; + const int64_t got = (int64_t)gm_mktime(&t); + if (got != c.expected) { + TEST_MSG_FMT("gm_mktime(%04d-%02d-%02d %02d:%02d:%02d) = %lld, expected %lld", c.year, c.mon1, c.mday, c.hour, c.min, + c.sec, (long long)got, (long long)c.expected); + } + TEST_ASSERT_EQUAL_INT64(c.expected, got); + } +} + +// February length as seen by gm_mktime for the years around each leap rule: Mar 1 minus Feb 28 +// is two days in a leap year and one day otherwise. Self-consistent, anchored by the known +// answers above. +static void test_gm_mktime_leap_rule_sweep(void) +{ + static const int leapYears[] = {1972, 2000, 2024, 2096, 2104, 2400}; // by-4 and by-400 + static const int nonLeapYears[] = {1970, 2023, 2100, 2200, 2300}; // odd years and by-100 + + for (int year : leapYears) { + struct tm feb28 = {}, mar1 = {}; + feb28.tm_year = year - 1900; + feb28.tm_mon = 1; + feb28.tm_mday = 28; + mar1.tm_year = year - 1900; + mar1.tm_mon = 2; + mar1.tm_mday = 1; + TEST_MSG_FMT("leap year %d", year); + TEST_ASSERT_EQUAL_INT64(2 * SEC_PER_DAY, (int64_t)gm_mktime(&mar1) - (int64_t)gm_mktime(&feb28)); + } + for (int year : nonLeapYears) { + struct tm feb28 = {}, mar1 = {}; + feb28.tm_year = year - 1900; + feb28.tm_mon = 1; + feb28.tm_mday = 28; + mar1.tm_year = year - 1900; + mar1.tm_mon = 2; + mar1.tm_mday = 1; + TEST_MSG_FMT("non-leap year %d", year); + TEST_ASSERT_EQUAL_INT64(SEC_PER_DAY, (int64_t)gm_mktime(&mar1) - (int64_t)gm_mktime(&feb28)); + } +} + void setup() { delay(10); @@ -76,6 +429,27 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_readFromRTC_preserves_better_network_time); RUN_TEST(test_readFromRTC_initializes_time_when_no_better_source); + + printf("\n=== perhapsSetRTC(timeval) quality arbitration ===\n"); + RUN_TEST(test_downgrade_rejected_state_untouched); + RUN_TEST(test_equal_quality_fromnet_is_not_reapplied); + RUN_TEST(test_gps_reapply_always_accepted); + RUN_TEST(test_ntp_drift_throttle); + RUN_TEST(test_force_update_overrides_downgrade); + RUN_TEST(test_build_epoch_bounds_rejected); + + printf("\n=== perhapsSetRTC(tm) overload ===\n"); + RUN_TEST(test_tm_overload_roundtrip); + RUN_TEST(test_tm_overload_year_guard); + + printf("\n=== getValidTime / quality-source stamp ===\n"); + RUN_TEST(test_getvalidtime_threshold_gating); + RUN_TEST(test_lastSetFromPhoneNtpOrGps_stamp); + + printf("\n=== gm_mktime known answers ===\n"); + RUN_TEST(test_gm_mktime_known_epochs); + RUN_TEST(test_gm_mktime_leap_rule_sweep); + exit(UNITY_END()); } diff --git a/test/test_stream_framing/test_main.cpp b/test/test_stream_framing/test_main.cpp new file mode 100644 index 000000000..99d5823f5 --- /dev/null +++ b/test/test_stream_framing/test_main.cpp @@ -0,0 +1,397 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include "configuration.h" +#include "mesh/MeshService.h" +#include "mesh/StreamAPI.h" +#include +#include +#include +#include +#include +#include +#include + +// Framing constants mirrored from StreamAPI.cpp (defined only in that translation unit). +static constexpr uint8_t kStart1 = 0x94; +static constexpr uint8_t kStart2 = 0xc3; +static constexpr size_t kHeaderLen = 4; + +/// Input-scripted stream feeding queued bytes through the readStream() polling path. +class InputScriptedStream : public Stream +{ + public: + /// Report how many queued input bytes remain. + int available() override { return (int)input.size(); } + + /// Return the next queued byte as an unsigned value, or -1 when drained. + int read() override + { + if (input.empty()) + return -1; + int value = input.front(); + input.pop_front(); + return value; + } + + /// Return the next queued byte without consuming it. + int peek() override { return input.empty() ? -1 : input.front(); } + + /// Accept unlimited output; this suite only exercises the receive side. + int availableForWrite() override { return std::numeric_limits::max(); } + size_t write(uint8_t) override { return 1; } + size_t write(const uint8_t *, size_t size) override { return size; } + void flush() override {} + + /// Queue bytes for the next readStream() poll. + void feed(const std::vector &bytes) { input.insert(input.end(), bytes.begin(), bytes.end()); } + + std::deque input; +}; + +// The global `service` is installed in setUp() and restored in tearDown() rather than by RAII +// because a failed TEST_ASSERT longjmps out of the test without running destructors, which would +// leave `service` dangling for the rest of the suite. testService is intentionally never freed: +// it stays reachable through the static, so LeakSanitizer does not flag it. +static MeshService *testService = nullptr; +static MeshService *previousService = nullptr; + +/// Records every framed ToRadio payload the receive state machine delivers. +class FramingStreamAPIShim : public StreamAPI +{ + public: + /// Construct the shim over a scripted input stream. + explicit FramingStreamAPIShim(Stream *stream) : StreamAPI(stream) {} + + /// Keep connection-timeout handling inactive during tests. + bool checkIsConnected() override { return true; } + + /// Capture one delivered payload instead of running the real PhoneAPI decode. + bool handleToRadio(const uint8_t *buf, size_t len) override + { + deliveries.emplace_back(buf, buf + len); + return true; + } + + std::vector> deliveries; +}; + +/// Wrap a payload in the 0x94C3 big-endian-length stream framing. +static std::vector makeFrame(const std::vector &payload) +{ + std::vector frame = {kStart1, kStart2, (uint8_t)(payload.size() >> 8), (uint8_t)(payload.size() & 0xff)}; + frame.insert(frame.end(), payload.begin(), payload.end()); + return frame; +} + +/// Drive the buffer-fed receive path (SerialModule/native callers) with one burst. +static void feedBufferPath(FramingStreamAPIShim &api, const std::vector &bytes) +{ + std::vector copy = bytes; // runOncePart takes a mutable char* + api.runOncePart(reinterpret_cast(copy.data()), (uint16_t)copy.size()); +} + +/// Drive the stream-polling receive path with one burst. +static void feedStreamPath(FramingStreamAPIShim &api, InputScriptedStream &stream, const std::vector &bytes) +{ + stream.feed(bytes); + api.runOncePart(); +} + +/// Assert delivery `index` matches the expected payload, size first so a short delivery is a +/// clean assertion failure rather than an out-of-bounds read. +static void assertDeliveryAt(const FramingStreamAPIShim &api, size_t index, const std::vector &expected) +{ + TEST_ASSERT_TRUE_MESSAGE(index < api.deliveries.size(), "delivery index out of range"); + TEST_ASSERT_EQUAL_UINT(expected.size(), api.deliveries[index].size()); + if (!expected.empty()) // Unity rejects zero-length array asserts as pointless + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected.data(), api.deliveries[index].data(), expected.size()); +} + +/// Assert the shim recorded exactly one delivery matching the expected payload. +static void assertSingleDelivery(const FramingStreamAPIShim &api, const std::vector &expected) +{ + TEST_ASSERT_EQUAL_UINT_MESSAGE(1, api.deliveries.size(), "expected exactly one handleToRadio delivery"); + assertDeliveryAt(api, 0, expected); +} + +/// Verify one well-formed frame off the scripted stream delivers its exact payload once. +void test_stream_single_frame_delivers_exact_payload() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x08, 0x01, 0x2a, 0x00, 0x7f}; + + feedStreamPath(api, stream, makeFrame(payload)); + + assertSingleDelivery(api, payload); + TEST_ASSERT_TRUE_MESSAGE(stream.input.empty(), "readStream must drain everything available"); +} + +/// Verify parser state persists across stream polls split mid-header and mid-payload. +void test_stream_partial_reads_persist_state() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0xaa, 0xbb, 0xcc}; + std::vector frame = makeFrame(payload); + + // First poll sees only 3 of the 4 header bytes. + feedStreamPath(api, stream, std::vector(frame.begin(), frame.begin() + 3)); + TEST_ASSERT_EQUAL_UINT(0, api.deliveries.size()); + + // Second poll supplies the length byte and part of the payload. + feedStreamPath(api, stream, std::vector(frame.begin() + 3, frame.begin() + 5)); + TEST_ASSERT_EQUAL_UINT(0, api.deliveries.size()); + + // Final poll completes the payload: exactly one delivery. + feedStreamPath(api, stream, std::vector(frame.begin() + 5, frame.end())); + assertSingleDelivery(api, payload); +} + +/// Verify rxPtr persists across buffer-path invocations fed one byte at a time. +void test_buffer_path_one_byte_per_call_persists_state() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x12, 0x34}; + std::vector frame = makeFrame(payload); + + for (size_t i = 0; i + 1 < frame.size(); i++) { + feedBufferPath(api, {frame[i]}); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api.deliveries.size(), "no delivery before the final byte"); + } + feedBufferPath(api, {frame.back()}); + + assertSingleDelivery(api, payload); +} + +/// Verify the parser hunts past leading ASCII boot-log garbage to the frame marker. +void test_leading_garbage_resyncs_to_frame() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x55, 0x66}; + + const char *bootLog = "INFO | ??:??:?? 1 Booting\r\n"; + std::vector burst(bootLog, bootLog + strlen(bootLog)); + std::vector frame = makeFrame(payload); + burst.insert(burst.end(), frame.begin(), frame.end()); + + feedStreamPath(api, stream, burst); + + assertSingleDelivery(api, payload); +} + +/// Verify a header advertising len 513 is rejected and a later frame in the burst still delivers. +void test_bogus_length_rejected_then_next_frame_recovered() +{ + InputScriptedStream stream; + FramingStreamAPIShim api(&stream); + std::vector payload = {0x77}; + + // MAX_TO_FROM_RADIO_SIZE is 512, so a big-endian length of 513 must fail header validation. + std::vector burst = {kStart1, kStart2, 0x02, 0x01}; + const char *junk = "junk"; + burst.insert(burst.end(), junk, junk + strlen(junk)); + std::vector frame = makeFrame(payload); + burst.insert(burst.end(), frame.begin(), frame.end()); + + feedBufferPath(api, burst); + + assertSingleDelivery(api, payload); +} + +/// Verify a len==512 frame (the exact cap, filling rxBuf to its last byte) is delivered intact +/// on both receive paths. +void test_max_length_frame_accepted_exactly() +{ + std::vector payload(MAX_TO_FROM_RADIO_SIZE); + for (size_t i = 0; i < payload.size(); i++) + payload[i] = (uint8_t)(i & 0xff); + std::vector frame = makeFrame(payload); + + // Total frame is 516 bytes == sizeof(rxBuf); ASan in the coverage env guards the bound. + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, frame); + assertSingleDelivery(streamApi, payload); + + // Buffer path, split so the cap is reached with rxPtr state persisted across calls. + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + const size_t split = frame.size() / 2; + feedBufferPath(bufferApi, std::vector(frame.begin(), frame.begin() + split)); + TEST_ASSERT_EQUAL_UINT(0, bufferApi.deliveries.size()); + feedBufferPath(bufferApi, std::vector(frame.begin() + split, frame.end())); + assertSingleDelivery(bufferApi, payload); +} + +/// Verify a zero-length payload is a valid frame delivering len 0 on both receive paths. +void test_zero_length_payload_delivers_empty() +{ + std::vector frame = makeFrame({}); + TEST_ASSERT_EQUAL_UINT(kHeaderLen, frame.size()); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, frame); + assertSingleDelivery(streamApi, {}); + + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + feedBufferPath(bufferApi, frame); + assertSingleDelivery(bufferApi, {}); +} + +/// Verify two back-to-back frames in one burst deliver twice, in order, on both paths. +void test_back_to_back_frames_deliver_in_order() +{ + std::vector first = {0x01, 0x02, 0x03}; + std::vector second = {0xf0, 0x0d}; + std::vector burst = makeFrame(first); + std::vector secondFrame = makeFrame(second); + burst.insert(burst.end(), secondFrame.begin(), secondFrame.end()); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + TEST_ASSERT_EQUAL_UINT(2, streamApi.deliveries.size()); + assertDeliveryAt(streamApi, 0, first); + assertDeliveryAt(streamApi, 1, second); + + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + feedBufferPath(bufferApi, burst); + TEST_ASSERT_EQUAL_UINT(2, bufferApi.deliveries.size()); + assertDeliveryAt(bufferApi, 0, first); + assertDeliveryAt(bufferApi, 1, second); +} + +/// Verify payload bytes >= 0x80 survive the buffer path identically to the stream path. +/// Pins the unsigned read in StreamAPI::handleRecStream(const char *, uint16_t): a plain +/// (signed) char compare treated any high byte - START1 itself is 0x94 - as EOF and +/// silently dropped frames mid-buffer. +void test_high_bytes_in_payload_delivered_on_both_paths() +{ + // Includes the framing bytes themselves mid-payload: length counts them as data. + std::vector payload = {0x80, kStart1, kStart2, 0xff, 0x00, 0xfe, 0x7f, 0x81}; + std::vector frame = makeFrame(payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, frame); + assertSingleDelivery(streamApi, payload); + + InputScriptedStream unusedStream; + FramingStreamAPIShim bufferApi(&unusedStream); + feedBufferPath(bufferApi, frame); + assertSingleDelivery(bufferApi, payload); + + TEST_ASSERT_EQUAL_UINT8_ARRAY(streamApi.deliveries[0].data(), bufferApi.deliveries[0].data(), payload.size()); +} + +/// A byte that fails START2 is re-tested as START1, so 0x94 0x94 0xc3 ... keeps the frame behind +/// the stray marker instead of consuming its real marker in the reset. +void test_stray_start1_before_frame_still_delivers() +{ + std::vector payload = {0x42}; + std::vector frame = makeFrame(payload); + std::vector burst = {kStart1}; // stray marker, then the real frame + burst.insert(burst.end(), frame.begin(), frame.end()); + + // The byte that fails START2 is itself START1 here, so the frame behind it must survive. + InputScriptedStream bufStream; + FramingStreamAPIShim bufferApi(&bufStream); + feedBufferPath(bufferApi, burst); + assertSingleDelivery(bufferApi, payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + assertSingleDelivery(streamApi, payload); +} + +/// A run of stray markers before a frame must not consume it either. +void test_repeated_stray_start1_before_frame_still_delivers() +{ + std::vector payload = {0x43, 0x44}; + std::vector frame = makeFrame(payload); + std::vector burst = {kStart1, kStart1, kStart1}; + burst.insert(burst.end(), frame.begin(), frame.end()); + + InputScriptedStream bufStream; + FramingStreamAPIShim bufferApi(&bufStream); + feedBufferPath(bufferApi, burst); + assertSingleDelivery(bufferApi, payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + assertSingleDelivery(streamApi, payload); +} + +/// START1 followed by a non-START1, non-START2 byte still resyncs on the next real frame. +void test_start1_then_unrelated_byte_resyncs() +{ + std::vector payload = {0x45}; + std::vector frame = makeFrame(payload); + std::vector burst = {kStart1, 0x00}; + burst.insert(burst.end(), frame.begin(), frame.end()); + + InputScriptedStream bufStream; + FramingStreamAPIShim bufferApi(&bufStream); + feedBufferPath(bufferApi, burst); + assertSingleDelivery(bufferApi, payload); + + InputScriptedStream stream; + FramingStreamAPIShim streamApi(&stream); + feedStreamPath(streamApi, stream, burst); + assertSingleDelivery(streamApi, payload); +} + +/// Unity per-test setup: install the test MeshService the StreamAPI fixtures expect. +void setUp(void) +{ + previousService = service; + if (!testService) + testService = new MeshService(); + service = testService; +} + +/// Unity per-test teardown: runs even after an aborted test, so the restore is failure-safe. +void tearDown(void) +{ + service = previousService; +} + +/// Initialize the native environment and run the receive-framing suite. +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Frame delivery ===\n"); + RUN_TEST(test_stream_single_frame_delivers_exact_payload); + RUN_TEST(test_zero_length_payload_delivers_empty); + RUN_TEST(test_back_to_back_frames_deliver_in_order); + RUN_TEST(test_high_bytes_in_payload_delivered_on_both_paths); + + printf("\n=== Partial reads / state persistence ===\n"); + RUN_TEST(test_stream_partial_reads_persist_state); + RUN_TEST(test_buffer_path_one_byte_per_call_persists_state); + RUN_TEST(test_max_length_frame_accepted_exactly); + + printf("\n=== Resync and rejection ===\n"); + RUN_TEST(test_leading_garbage_resyncs_to_frame); + RUN_TEST(test_bogus_length_rejected_then_next_frame_recovered); + + printf("\n=== Stray framing markers ===\n"); + RUN_TEST(test_stray_start1_before_frame_still_delivers); + RUN_TEST(test_repeated_stray_start1_before_frame_still_delivers); + RUN_TEST(test_start1_then_unrelated_byte_resyncs); + + exit(UNITY_END()); +} + +/// Unused Arduino loop required by the native Unity runner. +void loop() {} diff --git a/test/test_xmodem/test_main.cpp b/test/test_xmodem/test_main.cpp index c6a20fdf0..7dbb9e04f 100644 --- a/test/test_xmodem/test_main.cpp +++ b/test/test_xmodem/test_main.cpp @@ -1,16 +1,27 @@ -// Tests for XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer -// handler (src/xmodem.cpp). The filename in a SOH/STX control frame is attacker-controlled and -// drives FSCom open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".." -// component could escape the mountpoint. Absolute/subdirectory paths must still be accepted. +// Tests for the XModem file-transfer adapter (src/xmodem.cpp). +// +// Group 1: XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer +// handler. The filename in a SOH/STX control frame is attacker-controlled and drives FSCom +// open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".." component could +// escape the mountpoint. Absolute/subdirectory paths must still be accepted. +// +// Group 2 onward: the handlePacket() state machine itself - session start, per-packet seq + CRC +// validation, NAK/retransmit, CAN cleanup, EOT close, and the getForPhone()/resetForPhone() +// contract PhoneAPI uses to drain replies. PhoneAPI feeds handlePacket attacker-controllable +// ToRadio protobufs, and none of this had pinning coverage. These tests assert what the code does +// today; the two tests marked "documents current behaviour" pin known state-confusion edges so a +// deliberate fix has to update them consciously. #include "TestUtil.h" #include "xmodem.h" #include -void setUp(void) {} -void tearDown(void) {} - #ifdef FSCom +#include "SPILock.h" +#include +#include +#include + void test_xmodem_rejects_dotdot_traversal(void) { TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("..")); @@ -57,18 +68,469 @@ void test_xmodem_allows_legit_paths(void) TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("dir/1:30pm.txt")); } +// --- handlePacket state-machine fixture --- + +// Exposes the protected CRC helpers so crafted packets carry the exact checksum the adapter +// computes, and so the transmit-side crc16 field can be cross-checked. +class XModemTestShim : public XModemAdapter +{ + public: + using XModemAdapter::check; + using XModemAdapter::crc16_ccitt; +}; + +static XModemTestShim *xm = nullptr; + +static constexpr size_t kChunk = sizeof(meshtastic_XModem_buffer_t::bytes); // 128 +static const char *kRxPath = "/xmodem_test_rx.bin"; +static const char *kTxPath = "/xmodem_test_tx.bin"; + +// Control-only frame (EOT/ACK/NAK/CAN). +static meshtastic_XModem makeControl(meshtastic_XModem_Control control) +{ + meshtastic_XModem p = meshtastic_XModem_init_zero; + p.control = control; + return p; +} + +// Session-start frame: seq 0, filename in the buffer (NUL included, as the phone sends it). +static meshtastic_XModem makeStart(meshtastic_XModem_Control control, const char *path) +{ + meshtastic_XModem p = meshtastic_XModem_init_zero; + p.control = control; + p.seq = 0; + p.buffer.size = strlen(path) + 1; + memcpy(p.buffer.bytes, path, p.buffer.size); + return p; +} + +// Data frame with a correct (or deliberately corrupted) CRC. +static meshtastic_XModem makeData(uint16_t seq, const uint8_t *data, size_t len, bool goodCrc = true) +{ + meshtastic_XModem p = meshtastic_XModem_init_zero; + p.control = meshtastic_XModem_Control_SOH; + p.seq = seq; + p.buffer.size = len; + memcpy(p.buffer.bytes, data, len); + p.crc16 = xm->crc16_ccitt(p.buffer.bytes, (int)len); + if (!goodCrc) + p.crc16 ^= 0x1; + return p; +} + +static void fillPattern(uint8_t *buf, size_t len, uint8_t seed) +{ + for (size_t i = 0; i < len; i++) + buf[i] = (uint8_t)(seed + i * 7); +} + +static void writeAll(const char *path, const uint8_t *data, size_t len) +{ + File f = FSCom.open(path, FILE_O_WRITE); + TEST_ASSERT_TRUE_MESSAGE(f, path); + TEST_ASSERT_EQUAL_size_t(len, f.write(data, len)); + f.close(); +} + +static size_t readAll(const char *path, uint8_t *buf, size_t maxLen) +{ + File f = FSCom.open(path, FILE_O_READ); + TEST_ASSERT_TRUE_MESSAGE(f, path); + size_t n = f.read(buf, maxLen); + f.close(); + return n; +} + +// Starts a receive session into kRxPath and asserts the adapter accepted it. +static void startReceive(void) +{ + xm->handlePacket(makeStart(meshtastic_XModem_Control_SOH, kRxPath)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_TRUE(xm->isBusy()); +} + +// Writes a patterned file at kTxPath and starts a transmit session; returns the first outbound +// packet after asserting its shape. +static meshtastic_XModem startTransmit(const uint8_t *payload, size_t len) +{ + writeAll(kTxPath, payload, len); + xm->handlePacket(makeStart(meshtastic_XModem_Control_STX, kTxPath)); + meshtastic_XModem out = xm->getForPhone(); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, out.control); + TEST_ASSERT_EQUAL_UINT16(1, out.seq); + TEST_ASSERT_TRUE(xm->isBusy()); + return out; +} + +// --- CRC --- + +void test_xmodem_crc16_known_answer(void) +{ + // CRC-16/XMODEM check value: crc("123456789") == 0x31C3, and the zero-length CRC is 0. + const uint8_t check[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'}; + TEST_ASSERT_EQUAL_HEX16(0x31C3, xm->crc16_ccitt(check, sizeof(check))); + TEST_ASSERT_EQUAL_HEX16(0x0000, xm->crc16_ccitt(check, 0)); + TEST_ASSERT_TRUE(xm->check(check, sizeof(check), 0x31C3)); + TEST_ASSERT_FALSE(xm->check(check, sizeof(check), 0x31C2)); +} + +// --- Receive path --- + +void test_xmodem_receive_happy_path(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 31); + + startReceive(); + + size_t off = 0; + uint16_t seq = 1; + while (off < sizeof(payload)) { + const size_t chunk = std::min(kChunk, sizeof(payload) - off); + xm->handlePacket(makeData(seq, payload + off, chunk)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + off += chunk; + seq++; + } + + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); + + uint8_t readBack[400]; + TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload)); +} + +void test_xmodem_receive_truncates_a_stale_file(void) +{ + // FILE_O_WRITE on Adafruit_LittleFS is append, not truncate; xmodem.cpp removes the target + // before opening. A shorter transfer over a longer stale file must leave no tail bytes. + uint8_t stale[400]; + memset(stale, 'Z', sizeof(stale)); + writeAll(kRxPath, stale, sizeof(stale)); + + uint8_t payload[10]; + fillPattern(payload, sizeof(payload), 3); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + + uint8_t readBack[400]; + TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload)); +} + +void test_xmodem_receive_rejects_wrong_seq(void) +{ + uint8_t p1[kChunk], p2[kChunk]; + fillPattern(p1, sizeof(p1), 11); + fillPattern(p2, sizeof(p2), 97); + + startReceive(); + xm->handlePacket(makeData(1, p1, sizeof(p1))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + // Duplicate of an already-accepted packet: rejected (NAK), not rewritten. + xm->handlePacket(makeData(1, p1, sizeof(p1))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + + // Skip ahead: also rejected, and packetno must not have advanced past 2. + xm->handlePacket(makeData(3, p2, sizeof(p2))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + + // The expected seq still works after both rejections. + xm->handlePacket(makeData(2, p2, sizeof(p2))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + + uint8_t readBack[3 * kChunk]; + TEST_ASSERT_EQUAL_size_t(2 * kChunk, readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(p1, readBack, kChunk); + TEST_ASSERT_EQUAL_HEX8_ARRAY(p2, readBack + kChunk, kChunk); +} + +void test_xmodem_receive_rejects_bad_crc(void) +{ + uint8_t payload[64]; + fillPattern(payload, sizeof(payload), 55); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload), /*goodCrc=*/false)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + + // The sender retries the same seq with a good CRC; only that copy lands in the file. + xm->handlePacket(makeData(1, payload, sizeof(payload))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + + uint8_t readBack[2 * kChunk]; + TEST_ASSERT_EQUAL_size_t(sizeof(payload), readAll(kRxPath, readBack, sizeof(readBack))); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, readBack, sizeof(payload)); +} + +void test_xmodem_receive_naks_traversal_filename(void) +{ + xm->handlePacket(makeStart(meshtastic_XModem_Control_SOH, "../evil")); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); + + // isReceiving stayed false, so a follow-up data packet falls through with no reply at all. + xm->resetForPhone(); + uint8_t junk[16]; + fillPattern(junk, sizeof(junk), 1); + xm->handlePacket(makeData(1, junk, sizeof(junk))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +// NOTE: the receive-side open-failure NAK branch (xmodem.cpp "open(%s, WRITE) failed") is not +// testable on native: Portduino's VFSImpl::open() returns a truthy File whenever the mode permits +// creation, even when the underlying fopen fails, so the branch is unreachable here. + +void test_xmodem_can_mid_receive_removes_the_file(void) +{ + uint8_t payload[kChunk]; + fillPattern(payload, sizeof(payload), 42); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload))); + xm->handlePacket(makeData(2, payload, sizeof(payload))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_CAN)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); + TEST_ASSERT_FALSE(FSCom.exists(kRxPath)); +} + +void test_xmodem_can_after_eot_removes_completed_file(void) +{ + // Documents current behaviour: the CAN handler acts on the stale filename from the previous + // session even when no transfer is in flight, deleting a file that completed successfully. + // A deliberate fix (ignoring CAN while idle) should update this test. + uint8_t payload[8]; + fillPattern(payload, sizeof(payload), 5); + + startReceive(); + xm->handlePacket(makeData(1, payload, sizeof(payload))); + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + TEST_ASSERT_FALSE(xm->isBusy()); + TEST_ASSERT_TRUE(FSCom.exists(kRxPath)); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_CAN)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_FALSE(FSCom.exists(kRxPath)); +} + +// --- Transmit path --- + +void test_xmodem_transmit_happy_path(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 7); + + meshtastic_XModem out = startTransmit(payload, sizeof(payload)); + TEST_ASSERT_EQUAL_UINT16(kChunk, out.buffer.size); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, out.buffer.bytes, kChunk); + TEST_ASSERT_EQUAL_HEX16(xm->crc16_ccitt(out.buffer.bytes, out.buffer.size), out.crc16); + + // ACK-drive the whole stream and reassemble it; the last (short) packet latches EOT, which + // arrives on the following ACK. + uint8_t reassembled[sizeof(payload) + kChunk]; + size_t got = 0; + uint16_t expectSeq = 1; + for (int guard = 0; guard < 10; guard++) { + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, out.control); + TEST_ASSERT_EQUAL_UINT16(expectSeq, out.seq); + TEST_ASSERT_EQUAL_HEX16(xm->crc16_ccitt(out.buffer.bytes, out.buffer.size), out.crc16); + memcpy(reassembled + got, out.buffer.bytes, out.buffer.size); + got += out.buffer.size; + expectSeq++; + + xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK)); + out = xm->getForPhone(); + if (out.control == meshtastic_XModem_Control_EOT) + break; + } + + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_EOT, out.control); + TEST_ASSERT_FALSE(xm->isBusy()); + TEST_ASSERT_EQUAL_size_t(sizeof(payload), got); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload, reassembled, sizeof(payload)); +} + +void test_xmodem_transmit_nak_resends_same_packet(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 61); + + meshtastic_XModem first = startTransmit(payload, sizeof(payload)); + + // NAK seeks back and re-reads the same block: identical seq, bytes and CRC. + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + meshtastic_XModem resent = xm->getForPhone(); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, resent.control); + TEST_ASSERT_EQUAL_UINT16(first.seq, resent.seq); + TEST_ASSERT_EQUAL_UINT16(first.buffer.size, resent.buffer.size); + TEST_ASSERT_EQUAL_HEX8_ARRAY(first.buffer.bytes, resent.buffer.bytes, first.buffer.size); + TEST_ASSERT_EQUAL_HEX16(first.crc16, resent.crc16); + + // A subsequent ACK still advances to the next block. + xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK)); + meshtastic_XModem next = xm->getForPhone(); + TEST_ASSERT_EQUAL_UINT16(2, next.seq); + TEST_ASSERT_EQUAL_HEX8_ARRAY(payload + kChunk, next.buffer.bytes, kChunk); +} + +void test_xmodem_transmit_retry_cap_cancels(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 23); + + startTransmit(payload, sizeof(payload)); + + // retrans starts at MAXRETRANS on a fresh adapter; NAKs 1..MAXRETRANS-1 resend, the + // MAXRETRANS'th decrements it to zero and aborts with CAN. + for (int i = 1; i < MAXRETRANS; i++) { + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_SOH, xm->getForPhone().control); + TEST_ASSERT_EQUAL_UINT16(1, xm->getForPhone().seq); + } + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_transmit_naks_missing_file(void) +{ + xm->handlePacket(makeStart(meshtastic_XModem_Control_STX, "/xmodem_test_missing.bin")); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NAK, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_soh_mid_transmit_cancels(void) +{ + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 89); + + startTransmit(payload, sizeof(payload)); + + // A data frame arriving while we are the sender is protocol confusion: cancel the transfer. + uint8_t junk[16]; + fillPattern(junk, sizeof(junk), 2); + xm->handlePacket(makeData(5, junk, sizeof(junk))); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_eot_mid_transmit_leaves_state_busy(void) +{ + // Documents current behaviour: the EOT handler only clears isReceiving, so an EOT received + // while transmitting ACKs, closes the file, and leaves the adapter wedged busy. A deliberate + // fix should update this test. + uint8_t payload[300]; + fillPattern(payload, sizeof(payload), 13); + + startTransmit(payload, sizeof(payload)); + xm->handlePacket(makeControl(meshtastic_XModem_Control_EOT)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_ACK, xm->getForPhone().control); + TEST_ASSERT_TRUE(xm->isBusy()); +} + +// --- Idle replies and the getForPhone/resetForPhone contract --- + +void test_xmodem_ack_nak_while_idle_provoke_can(void) +{ + xm->handlePacket(makeControl(meshtastic_XModem_Control_ACK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + + // getForPhone() is a read, not a drain: the reply stays until resetForPhone() clears it. + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + xm->resetForPhone(); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control); + + xm->handlePacket(makeControl(meshtastic_XModem_Control_NAK)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_CAN, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +void test_xmodem_unknown_control_ignored(void) +{ + xm->handlePacket(makeControl(meshtastic_XModem_Control_CTRLZ)); + TEST_ASSERT_EQUAL(meshtastic_XModem_Control_NUL, xm->getForPhone().control); + TEST_ASSERT_FALSE(xm->isBusy()); +} + +// --- Unity lifecycle --- + +void setUp(void) +{ + FSCom.remove(kRxPath); + FSCom.remove(kTxPath); + xm = new XModemTestShim(); +} + +void tearDown(void) +{ + delete xm; // File member closes any handle still held + xm = nullptr; + FSCom.remove(kRxPath); + FSCom.remove(kTxPath); +} + +#else // !FSCom + +void setUp(void) {} +void tearDown(void) {} + #endif // FSCom void setup() { initializeTestEnvironment(); +#ifdef FSCom + // handlePacket brackets every FSCom touch with spiLock; nothing in the test environment + // creates it, so do it here (initSPI asserts it only runs once). + if (!spiLock) + initSPI(); +#endif UNITY_BEGIN(); #ifdef FSCom + printf("\n=== isValidFilename ===\n"); RUN_TEST(test_xmodem_rejects_dotdot_traversal); RUN_TEST(test_xmodem_rejects_backslash_traversal); RUN_TEST(test_xmodem_rejects_drive_qualified); RUN_TEST(test_xmodem_rejects_empty); RUN_TEST(test_xmodem_allows_legit_paths); + + printf("\n=== CRC ===\n"); + RUN_TEST(test_xmodem_crc16_known_answer); + + printf("\n=== Receive path ===\n"); + RUN_TEST(test_xmodem_receive_happy_path); + RUN_TEST(test_xmodem_receive_truncates_a_stale_file); + RUN_TEST(test_xmodem_receive_rejects_wrong_seq); + RUN_TEST(test_xmodem_receive_rejects_bad_crc); + RUN_TEST(test_xmodem_receive_naks_traversal_filename); + RUN_TEST(test_xmodem_can_mid_receive_removes_the_file); + RUN_TEST(test_xmodem_can_after_eot_removes_completed_file); + + printf("\n=== Transmit path ===\n"); + RUN_TEST(test_xmodem_transmit_happy_path); + RUN_TEST(test_xmodem_transmit_nak_resends_same_packet); + RUN_TEST(test_xmodem_transmit_retry_cap_cancels); + RUN_TEST(test_xmodem_transmit_naks_missing_file); + RUN_TEST(test_xmodem_soh_mid_transmit_cancels); + RUN_TEST(test_xmodem_eot_mid_transmit_leaves_state_busy); + + printf("\n=== Idle replies / phone contract ===\n"); + RUN_TEST(test_xmodem_ack_nak_while_idle_provoke_can); + RUN_TEST(test_xmodem_unknown_control_ignored); #endif exit(UNITY_END()); } From ee401242aa333888edb9fc67cc6797716b032e4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:32:39 -0500 Subject: [PATCH 081/109] Update protobufs (#11536) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/protobufs b/protobufs index c9cb9ef6e..5b3ed3911 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit c9cb9ef6ee0dd579fbe9424e232484392637e11e +Subproject commit 5b3ed3911b5125351581a08c14110931df1aa735 diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 733014359..1e4235dd1 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -414,6 +414,10 @@ typedef enum _meshtastic_FirmwareEdition { meshtastic_FirmwareEdition_HAMVENTION = 19, /* FAB, the international Fab Lab digital fabrication conference */ meshtastic_FirmwareEdition_FAB = 20, + /* Dragon Con, the yearly pop culture convention in Atlanta, GA */ + meshtastic_FirmwareEdition_DRAGON_CON = 21, + /* Chaos Communication Congress, the hacker conference held yearly in Germany */ + meshtastic_FirmwareEdition_CCC = 22, /* Placeholder for DIY and unofficial events */ meshtastic_FirmwareEdition_DIY_EDITION = 127 } meshtastic_FirmwareEdition; From fe15786dc10c02bacb87546e4fd21a4532f5084a Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 18 Aug 2026 18:15:02 +0000 Subject: [PATCH 082/109] fix(api): stop rebooting ESP32 nodes when a client connects to a fragmented heap (#11537) * fix(api): stop rebooting ESP32 nodes when a client connects to a fragmented heap Connecting a client to an ESP32 node over WiFi/TCP rebooted the node. Two allocations on the accept + config path use operator new, and on ESP32 that is fatal when it fails: the framework builds with CONFIG_COMPILER_CXX_EXCEPTIONS=n (esp32-common.ini), and ESP-IDF's cxx component then --wraps __cxa_throw and every unwinder entry point straight to abort(). libstdc++'s operator new throws std::bad_alloc on a NULL from malloc, so any new that cannot get its block is a reboot with no chance to recover. Both hit on a Meshnology W12 running develop c308d0a (no PSRAM detected, WiFi + HTTPS + TLS up, ~83 KB free heap, fragmented): 1. PhoneAPI::handleStartConfig -> getFiles() -> filenames.reserve(64) 64 * sizeof(meshtastic_FileInfo) = 14,848 B contiguous, requested with the SPI lock held, on the very first client handshake. The try/catch around it (from #10778) is dead code on this platform for the reason above. abort() was called at PC 0x4216f733 on core 1 __cxa_throw / operator new std::vector<_meshtastic_FileInfo>::reserve (getFiles, FSCommon.cpp:275) PhoneAPI::handleStartConfig (PhoneAPI.cpp:325) StreamAPI::readStream / ServerAPI::runOnce 2. APIServerPort::runOnce -> openAPI.reset(new T(client)) sizeof(WiFiServerAPI) is 4,512 B (stream rx/tx buffers + FromRadio/ToRadio scratch). Under a little more pressure - a few TCP sockets held open on 80/4403 plus pending TLS handshakes - the accept itself aborts, before the manifest is ever reached: abort() was called at PC 0x4216f66b on core 1 __cxa_throw / operator new APIServerPort::runOnce (ServerAPI.cpp:120) new (std::nothrow) is not the answer on this platform. libstdc++ implements it as `try { return operator new(sz); } catch (...) { return nullptr; }` (new_opnt.cc:39; objdump shows call8 to the throwing form then __cxa_begin_catch), so with the unwinder wrapped to abort() it aborts one frame deeper - verified by decoding exactly that. malloc() does return NULL here (HEAP_ABORT_WHEN_ALLOCATION_FAILS is off), so both fixes go through it: - getFiles(): size the reservation to what the allocator can actually give, and never let reserve() be the thing that finds out there is no room. On ESP32 ask heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT) - the capability heap_caps_malloc_default() (what new resolves to) falls back to across every region - less a 1 KB margin, divided by sizeof(FileInfo). Nothing is freed before the reserve, so no hole to lose to another task. Elsewhere, probe with malloc() and halve until it fits. The walk is capped at the reserved count so push_back() never grows the vector, and wasLimited reports the truncation exactly as it did for the 64-entry cap. The manifest degrades to fewer entries; the handshake completes. - APIServerPort::runOnce(): take the ServerAPI's block from malloc(), construct it in place, and hold it in a unique_ptr whose deleter runs ~T() and free()s. If there is no room, log and drop that client instead of the node; it retries and the next accept gets a fresh look at the heap. The ServerAPI/PhoneAPI/OSThread constructors do not allocate (default-constructed containers, fixed-size thread table), so nothing inside the placement new can throw either. malloc()'s alignment is the one operator new gives (it calls malloc), so the object is well-formed. Also: the two manifest LOG lines used %zu, which newlib-nano's vsnprintf on ESP32 does not know - they printed "Got zu files in manifest". Cast to unsigned like the rest of the file. Not in this PR, flagged for discussion: every other operator new / container growth in the image has the same failure mode on ESP32, and so does every try/catch in firmware source. A project-wide nothrow global operator new (returning nullptr per the platform's own -fno-exceptions contract) would close the class, but it changes semantics for every library in the image and moves the failure from a clean abort-with-backtrace at the alloc site to whatever the caller does with a nullptr. That is a policy call, not a bug fix. Verified on the W12 (Endor AP): before, the first TCP-API connect aborts; after, 6/6 connects complete full config sends, 3/3 under held-socket + TLS pressure, node never reboots. Both degraded branches driven deliberately with a verify-only heap starvation build: largest block pinned at 7.4 KB gives "reserved=27 of 64 ... (limited to 64 entries/depth 3)" and the handshake runs; pinned at 2.8 KB gives "No heap for API connection (4512 bytes), dropping client" three times with no reboot, where the std::nothrow version aborted three times. test_fscommon_getfiles 8/8 on native-macos; full native suite green in Docker. * fix(api): cap the manifest probe count; suppress cppcheck's placement-new memleak - getFiles(): cap reservedCount at filenames.max_size() before the byte-count multiply in the portable probe. A huge maxCount could wrap reservedCount * sizeof(FileInfo), let malloc() succeed on the wrapped size, and then hand reserve() the original count - a length_error, which on ESP32 is the abort this change exists to remove. max_size() is also exactly the bound reserve() would reject, so one comparison covers both. (CodeRabbit) - APIServerPort::runOnce(): cppcheck 2.20 reports "Memory leak: block" at the end of the accept scope because it does not model ownership passing through placement new into openAPI (MallocDeleter frees it). Inline-suppress with the reason, per the tree's convention. pio check -e rak3172 goes FAILED -> PASSED; every check job in CI was red on only this finding while all builds passed. --- src/FSCommon.cpp | 54 ++++++++++++++++++++++++++++++-------- src/mesh/PhoneAPI.cpp | 6 ++--- src/mesh/api/ServerAPI.cpp | 19 +++++++++++++- src/mesh/api/ServerAPI.h | 18 ++++++++++++- 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index c00b07684..ef0d5841a 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -129,10 +129,13 @@ bool renameFile(const char *pathFrom, const char *pathTo) #endif } +#include +#include #include -#include -#include #include +#ifdef ARCH_ESP32 +#include +#endif /** * @brief Platform-agnostic filesystem format / wipe. @@ -250,6 +253,12 @@ void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vec } // namespace #endif +#ifdef ARCH_ESP32 +// Headroom kept below the allocator's largest free block when sizing the manifest: the block reported +// includes the allocator's own bookkeeping, and other tasks keep allocating while the SPI lock is held. +static constexpr size_t FILES_MANIFEST_HEAP_MARGIN = 1024; +#endif + /** * @brief Get the list of files in a directory. * @@ -268,18 +277,41 @@ std::vector getFiles(const char *dirname, uint8_t levels, s if (wasLimited) *wasLimited = false; #ifdef FSCom -#if defined(__cpp_exceptions) || defined(__EXCEPTIONS) - size_t reservedCount = maxCount; + // Size the vector once, up front, to what the heap can actually hand out, and cap the walk at that + // count so push_back() never has to grow it. Any allocation that fails here goes through operator + // new and raises std::bad_alloc; the ESP32 framework is built with CONFIG_COMPILER_CXX_EXCEPTIONS=n, + // so there is no unwinder and a throw is std::terminate() -> abort() -> reboot. That fires on the + // very first client handshake whenever the heap is fragmented (WiFi + TLS up, no PSRAM), which is + // exactly when this runs. So: never let reserve() be the thing that discovers there is no room. + // Cap at what a vector of FileInfo can hold at all: it keeps the probe's byte count from wrapping + // for a huge maxCount, and it is also the bound reserve() would otherwise reject with a throw. + size_t reservedCount = std::min(maxCount, filenames.max_size()); +#ifdef ARCH_ESP32 + // Ask the allocator for the largest contiguous block malloc() could hand out. MALLOC_CAP_DEFAULT + // is the capability heap_caps_malloc_default() (what operator new resolves to) falls back to + // across every region, internal and PSRAM alike, so this is the "will new succeed" question + // asked directly. Nothing is freed before the reserve, so there is no hole for another task to + // take between the probe and the allocation. + const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); + // Leave a margin below the largest block: the allocator's own overhead sits inside it, and other + // threads keep allocating while we hold the SPI lock. + const size_t usable = largest > FILES_MANIFEST_HEAP_MARGIN ? largest - FILES_MANIFEST_HEAP_MARGIN : 0; + reservedCount = std::min(reservedCount, usable / sizeof(meshtastic_FileInfo)); +#else + // Other targets have no largest-block query. Probe with malloc() - the allocation that returns + // nullptr on failure under every build (new(std::nothrow) is not that: libstdc++ implements it as + // a try/catch around the throwing form) - free the probe, and reserve the size that fit. Not + // airtight against a concurrent allocator, but the SPI lock the caller holds serialises the usual + // competitors and it is strictly better than letting reserve() be the first to find out. while (reservedCount > 0) { - try { - filenames.reserve(reservedCount); + void *probe = malloc(reservedCount * sizeof(meshtastic_FileInfo)); + if (probe) { + free(probe); break; - } catch (const std::bad_alloc &) { - reservedCount /= 2; - } catch (const std::length_error &) { - reservedCount /= 2; } + reservedCount /= 2; } +#endif if (reservedCount == 0) { if (wasLimited) *wasLimited = true; @@ -290,7 +322,7 @@ std::vector getFiles(const char *dirname, uint8_t levels, s *wasLimited = true; maxCount = reservedCount; } -#endif + filenames.reserve(reservedCount); collectFiles(dirname, levels, maxCount, filenames, wasLimited); #endif return filenames; diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 813d413de..b45783677 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -325,10 +325,10 @@ void PhoneAPI::handleStartConfig() filesManifest = getFiles("/", FILES_MANIFEST_LEVELS, FILES_MANIFEST_MAX_COUNT, &filesManifestLimited); } if (filesManifestLimited) { - LOG_WARN("Got %zu files in manifest (limited to %zu entries/depth %u)", filesManifest.size(), - FILES_MANIFEST_MAX_COUNT, static_cast(FILES_MANIFEST_LEVELS)); + LOG_WARN("Got %u files in manifest (limited to %u entries/depth %u)", (unsigned)filesManifest.size(), + (unsigned)FILES_MANIFEST_MAX_COUNT, static_cast(FILES_MANIFEST_LEVELS)); } else { - LOG_DEBUG("Got %zu files in manifest", filesManifest.size()); + LOG_DEBUG("Got %u files in manifest", (unsigned)filesManifest.size()); } } else { releaseFilesManifest(filesManifest); diff --git a/src/mesh/api/ServerAPI.cpp b/src/mesh/api/ServerAPI.cpp index 20ff8af99..7303ae304 100644 --- a/src/mesh/api/ServerAPI.cpp +++ b/src/mesh/api/ServerAPI.cpp @@ -5,6 +5,8 @@ #include "ServerAPI.h" #include "Throttle.h" #include +#include +#include static constexpr uint32_t TCP_IDLE_TIMEOUT_MS = 15 * 60 * 1000UL; @@ -117,7 +119,22 @@ template int32_t APIServerPort::runOnce() openAPI.reset(); } - openAPI.reset(new T(client)); + // A ServerAPI carries the stream rx/tx buffers plus the FromRadio/ToRadio scratch, several + // KB in one block. On ESP32 a new that cannot get that block is a reboot (see the note on + // openAPI in the header), and std::nothrow does not help there because libstdc++ builds it + // on the throwing form. malloc() does return nullptr, so take the block from malloc() and + // construct in place; if there is no room drop this connection instead of the node - the + // client retries and the next accept gets a fresh look at the heap. The T constructors do + // not allocate (default-constructed containers, fixed-size thread table), so nothing inside + // the placement new can throw either. + void *block = malloc(sizeof(T)); + if (!block) { + LOG_ERROR("No heap for API connection (%u bytes), dropping client", (unsigned)sizeof(T)); + client.stop(); + } else { + openAPI.reset(new (block) T(client)); + } + // cppcheck-suppress memleak ; block is owned by openAPI via placement new, freed by MallocDeleter } #if RAK_4631 diff --git a/src/mesh/api/ServerAPI.h b/src/mesh/api/ServerAPI.h index ece8e0ba2..05c3bb56f 100644 --- a/src/mesh/api/ServerAPI.h +++ b/src/mesh/api/ServerAPI.h @@ -1,6 +1,7 @@ #pragma once #include "StreamAPI.h" +#include #include #define SERVER_API_DEFAULT_PORT 4403 @@ -44,8 +45,23 @@ template class APIServerPort : public U, private concurrency: * * FIXME: We currently only allow one open TCP connection at a time, because we depend on the loop() call in this class to * delegate to the worker. Once coroutines are implemented we can relax this restriction. + * + * The ServerAPI is built in a malloc()'d block with placement new rather than operator new: on ESP32 the framework + * is compiled with CONFIG_COMPILER_CXX_EXCEPTIONS=n and every throw is wrapped to abort(), which makes a failed + * operator new - the plain form and, because libstdc++ implements it as a try/catch around the plain form, the + * std::nothrow form too - a reboot. malloc() is the one allocation on that platform that hands back nullptr, so + * a fragmented heap drops the incoming client instead of the node. The deleter runs the destructor and free()s. */ - std::unique_ptr openAPI; + struct MallocDeleter { + void operator()(T *p) const + { + if (p) { + p->~T(); + free(p); + } + } + }; + std::unique_ptr openAPI; #if defined(RAK_4631) || defined(RAK11310) // Track wait time for RAK13800 Ethernet requests int32_t waitTime = 100; From 692adc8131921854753ccc11f7bed7373dbe403b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:02:32 +0200 Subject: [PATCH 083/109] Update protobufs (#11543) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/protobufs b/protobufs index 5b3ed3911..aca181b97 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 5b3ed3911b5125351581a08c14110931df1aa735 +Subproject commit aca181b97b7db047d76e9f000220a11a234cd389 diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 1e4235dd1..c59001f10 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -339,6 +339,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_HELTEC_RC52 = 142, /* Heltec ESP32C6 + SX1262 */ meshtastic_HardwareModel_HELTEC_RCC6 = 143, + /* Seeed Wio Tracker L1 Pro 1W, nRF52840 + SX1262 with 1 W external PA */ + meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1_PRO_1W = 144, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ From 48699a7a484993f6fdee3a72fb0b47e0e2dd78b5 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 18 Aug 2026 20:38:20 +0000 Subject: [PATCH 084/109] fix(http): keep reaping open TLS connections under low heap so the heap can recover (#11539) * fix(http): keep reaping open TLS connections under low heap so the heap can recover Once free heap dropped below MIN_HEAP_FOR_SSL (40 KB) with HTTPS connections open, the node's heap never came back and every later HTTPS or TCP-API connection failed until a reset - node alive, on WiFi, unusable. handleWebResponse() skipped secureServer->loop() entirely under low heap so no new TLS handshake would be attempted on a heap that can't hold its context. But HTTPServer::loop() is the only place already-accepted connections are serviced and reaped: its first pass calls ->loop() on each open one (where the 20 s idle timeout and the SSL close-notify state machine run) and deletes the closed ones. Skipping the whole loop froze the up-to-MAX_HTTPS_CONNECTIONS TLS sessions already open. Never looped, they never timed out, their mbedTLS contexts and pbufs were never freed, so free heap never climbed back over 40 KB, so the loop was skipped forever. The guard's own precondition was what kept it from clearing. Split the two halves. Under low heap keep driving and reaping the connections we already hold, and only skip the accept. HTTPServer keeps its connection table protected, so a thin MeshHTTPSServer subclass exposes serviceExistingConnections(), the first half of HTTPServer::loop() verbatim. Log line reworded to say what now happens: not accepting, not skipping. Verified on a Heltec V3 (Endor AP) against a control build with #11537 (so the node survives the squeeze instead of aborting first): - Recipe: held sockets on 80/4403 + pending TLS, 100 s of HTTPS pokes, repeat. Control: Low heap pins at 6-17 KB, HTTPS dead, and 3 min after all pressure is released heap is still ~12 KB with Low heap firing every 30 s - permanent until reset. Fix: never dips under 40 KB, both pressure rounds 3/3, 65 KB after. - Branch driven deliberately (verify-only heap hog pinning free heap at ~28 KB with a real idle TLS session held open): under the guard the fix logs open=1 -> reaped=1 at the 20 s idle timeout, and heap goes 26 -> 65 KB before the hog is even released. On the control logic that session stays frozen for the whole window. Fixes #11538. * fix(http): trim the low-heap comments to the two-line guideline The mechanism is in the commit message and PR; the source keeps the one-line why. No code change. (CodeRabbit) --- src/mesh/http/WebServer.cpp | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/mesh/http/WebServer.cpp b/src/mesh/http/WebServer.cpp index fd1be5378..8a4489524 100644 --- a/src/mesh/http/WebServer.cpp +++ b/src/mesh/http/WebServer.cpp @@ -62,8 +62,33 @@ static const uint8_t MAX_HTTPS_CONNECTIONS = 2; // Minimum free heap required for SSL handshake (~40KB for mbedTLS contexts) static const uint32_t MIN_HEAP_FOR_SSL = 40000; +// HTTPSServer that can service and reap the connections it already holds without accepting new ones, +// so a low-heap pause doesn't freeze open TLS sessions (and their heap) in place. Needs the protected table. +class MeshHTTPSServer : public HTTPSServer +{ + public: + using HTTPSServer::HTTPSServer; + + /// The first half of HTTPServer::loop(): drive and reap existing connections, accept nothing. + void serviceExistingConnections() + { + if (!_running) + return; + for (uint8_t i = 0; i < _maxConnections; i++) { + if (!_connections[i]) + continue; + if (_connections[i]->isClosed()) { + delete _connections[i]; + _connections[i] = nullptr; + } else { + _connections[i]->loop(); + } + } + } +}; + static SSLCert *cert; -static HTTPSServer *secureServer; +static MeshHTTPSServer *secureServer; static HTTPServer *insecureServer; volatile bool isWebServerReady; @@ -80,10 +105,12 @@ static void handleWebResponse() if (freeHeap >= MIN_HEAP_FOR_SSL) { secureServer->loop(); } else { - // Skip HTTPS when memory is low to prevent SSL setup failures + // Low heap: accept nothing new, but keep servicing open connections so they can time out + // and free their contexts - skipping them pins the heap below the threshold for good. + secureServer->serviceExistingConnections(); static uint32_t lastHeapWarning = 0; if (lastHeapWarning == 0 || !Throttle::isWithinTimespanMs(lastHeapWarning, 30000)) { - LOG_WARN("Low heap (%u bytes), skipping HTTPS processing", freeHeap); + LOG_WARN("Low heap (%u bytes), not accepting HTTPS connections", freeHeap); lastHeapWarning = millis(); } } @@ -231,7 +258,7 @@ void initWebServer() LOG_DEBUG("Init Web Server"); // We can now use the new certificate to setup our server as usual. - secureServer = new HTTPSServer(cert, 443, MAX_HTTPS_CONNECTIONS); + secureServer = new MeshHTTPSServer(cert, 443, MAX_HTTPS_CONNECTIONS); insecureServer = new HTTPServer(); registerHandlers(insecureServer, secureServer); From 8fe246e250f1fa310d6a1d16babdce02a04950e8 Mon Sep 17 00:00:00 2001 From: Ixitxachitl Date: Tue, 18 Aug 2026 21:12:44 +0000 Subject: [PATCH 085/109] fix(mesh): relay foreign packets whose channel hash collides with a local channel (#11544) * fix(mesh): relay foreign packets whose channel hash collides with a local channel The channel hash is one byte, so a foreign channel's name/PSK can fold to the same hash as a local channel (~1/256 per local channel held). Since d6b12ea3f, perhapsDecode returns DECODE_FAILURE whenever any local channel matched the hash, and passesRoutingAuthGate turned that into REJECT - silently blackholing legitimate foreign traffic that master and 2.7.x relay. A node with a wrong PSK for a channel name stopped relaying the real channel entirely. Channel crypto (AES-CTR) has no authentication tag, so "wrong key, foreign channel" and "our channel, tampered payload" are indistinguishable at this decision point. The strict drop bought nothing: an attacker picks a hash matching no local channel and gets DECODE_OPAQUE relay anyway (test_C6), so the rule only suppressed honest colliding traffic. Return OPAQUE_RELAY_ONLY on DECODE_FAILURE unless the packet is addressed to us or claims to be from us. isFromUs stays REJECT because OPAQUE_RELAY_ONLY reaches perhapsGenerateImplicitAckForOwnOverheard, which matches pending sends on header bytes alone - a forged sender with a colliding hash and matching id could otherwise fake-ACK a DM and cancel its retransmissions. Other DECODE_FAILURE sources are unaffected: legacy-DM rejection, pending-key refusal, and failed PKI candidates are all isToUs, and the KNOWN_ONLY early return is re-gated by relayOpaquePacket's own mode check. Opaque frames still never touch PacketHistory, NodeDB, modules, MQTT, ACKs, or the phone. test_C12's collision leg now expects OPAQUE_RELAY_ONLY (its tampered packet is a broadcast - byte-identical to the foreign case); it still pins per-exact-byte cache reevaluation. test_C9 renamed to match what it now verifies. New test_C17 covers the colliding-hash foreign broadcast and the spoofed-sender REJECT. * style(mesh): trim collision-relay comment to two lines --- src/mesh/Router.cpp | 6 ++++ test/test_packet_signing/test_main.cpp | 43 ++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 99d448ac6..750bba7a7 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -789,6 +789,12 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) return RoutingAuthVerdict::REJECT; } if (state == DecodeState::DECODE_FAILURE) { + // One-byte hash collisions are indistinguishable from tampering, so relay opaquely + // instead of blackholing; isFromUs stays REJECT to keep forged senders off the ACK path. + if (!isToUs(p) && !isFromUs(p)) { + LOG_WARN("Decryptable packet failed decoding, relay opaquely"); + return RoutingAuthVerdict::OPAQUE_RELAY_ONLY; + } LOG_WARN("Decryptable packet failed decoding, drop"); return RoutingAuthVerdict::REJECT; } diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index d7453e29a..2bfd2b6e6 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -1391,7 +1391,7 @@ void test_C8_trusted_local_decoded_delivery_is_not_filtered(void) packetPool.release(local); } -void test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque(void) +void test_C9_known_channel_malformed_plaintext_has_no_pipeline_effects(void) { meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero; malformed.from = REMOTE_NODE; @@ -1404,6 +1404,12 @@ void test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque(void) malformed.encrypted.bytes[2] = 0xFF; malformed.channel = channels.setActiveByIndex(0); crypto->encryptPacket(malformed.from, malformed.id, malformed.encrypted.size, malformed.encrypted.bytes); + + // Verdict is opaque-relay-eligible now (see test_C17); hop_limit 0 is what keeps this a no-op. + meshtastic_MeshPacket verdictCopy = malformed; + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), + static_cast(passesRoutingAuthGate(&verdictCopy))); + mockNodeDB->addNode(REMOTE_NODE); const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; runPipelineIngress(malformed); @@ -1468,9 +1474,12 @@ void test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass runPipelineIngress(valid); TEST_ASSERT_EQUAL_MESSAGE(2, routingAuthEvaluationCount(), "consumed verdict must not authenticate a later replay"); + // Broadcast, so isToUs() is false like any colliding-hash foreign broadcast (see test_C17); + // this still guards that the cache is reevaluated per exact bytes, not reused for a same-ID replay. meshtastic_MeshPacket collision = valid; collision.encrypted.bytes[0] ^= 0x80; - TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::REJECT), static_cast(passesRoutingAuthGate(&collision))); + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), + static_cast(passesRoutingAuthGate(&collision))); TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated"); } @@ -1573,6 +1582,33 @@ void test_C16_reliable_broadcast_keeps_three_total_attempts(void) TEST_ASSERT_EQUAL_UINT8(3, pipelineRouter->pendingTotalAttempts(LOCAL_NODE, p.id)); } +void test_C17_colliding_channel_hash_foreign_broadcast_is_relay_only(void) +{ + // Foreign channel whose PSK collides with our channel 0's one-byte hash (see test_C9/test_C12 + // for the paired tradeoff): indistinguishable from tampering, so it must relay opaquely. + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket foreign = meshtastic_MeshPacket_init_zero; + foreign.from = REMOTE_NODE; + foreign.to = NODENUM_BROADCAST; + foreign.id = 0xC1700017; + foreign.hop_limit = 1; + foreign.hop_start = 2; + foreign.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + const int16_t hash = channels.setActiveByIndex(0); + TEST_ASSERT_GREATER_OR_EQUAL_MESSAGE(0, hash, "no usable primary channel"); + foreign.channel = (uint8_t)hash; // collides with our channel 0, but the ciphertext below is not ours + foreign.encrypted.size = 16; + memset(foreign.encrypted.bytes, 0xA5, foreign.encrypted.size); + + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast(passesRoutingAuthGate(&foreign))); + + // Same undecodable frame claiming to be from us must still be dropped: OPAQUE_RELAY_ONLY would + // reach perhapsGenerateImplicitAckForOwnOverheard, which acts on header bytes alone. + meshtastic_MeshPacket spoofed = foreign; + spoofed.from = LOCAL_NODE; + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::REJECT), static_cast(passesRoutingAuthGate(&spoofed))); +} + // 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) @@ -2142,7 +2178,7 @@ void setup() RUN_TEST(test_C6_opaque_unknown_channel_is_relay_only); RUN_TEST(test_C7_strict_rejects_unsigned_decoded_simradio_ingress); RUN_TEST(test_C8_trusted_local_decoded_delivery_is_not_filtered); - RUN_TEST(test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque); + RUN_TEST(test_C9_known_channel_malformed_plaintext_has_no_pipeline_effects); 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); @@ -2150,6 +2186,7 @@ void setup() RUN_TEST(test_C14_duty_cycle_limited_reliable_send_remains_pending); RUN_TEST(test_C15_reliable_unicast_tracks_five_total_attempts); RUN_TEST(test_C16_reliable_broadcast_keeps_three_total_attempts); + RUN_TEST(test_C17_colliding_channel_hash_foreign_broadcast_is_relay_only); 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 a5fc95f774d95be41190cae11e8122fd0290d32b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 18 Aug 2026 21:58:51 +0000 Subject: [PATCH 086/109] fix(mesh): coerce coordinate traffic to the position channel on event builds (#11545) Under USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL every coordinate packet a client aimed at the event channel was rejected with the "Location sharing is disabled on this channel" notification - including the phone's own location feed. Both apps hand a GPS-less node its fix as a POSITION_APP packet addressed to the node itself on channel 0; that packet never leaves the device (Router::sendLocal delivers it locally) but resolved to the event channel and was dropped before PositionModule saw it. Result: the toast on every location tick, and nodes without a GPS never learned a position to share on their private channel. Position traffic now converges on the position channel - findPositionChannel(), the first channel with non-zero on-wire precision, which is never the event channel: - From-us-to-us coordinate packets are exempt from the event block. - Local coordinate sends aimed at the event channel (phone share-location, request-position, waypoints, any module/UI originator) are moved onto the position channel in Router::sendLocal and PhoneAPI instead of rejected. The client notification is only sent when no channel carries positions at all. - A position request DM'd to us on the event channel is answered on the position channel at that channel's precision (request_id preserved, same reply throttle); the requester's coordinates are still not stored, forwarded, relayed or published. want_response from the bitfield is merged before the event-channel decode short-circuit so such requests are seen. - PositionModule::sendOurPosition, positionUnchangedSinceLastSend and MeshService::trySendPosition use the shared helper instead of three copies of the same walk. Non-event builds are unaffected: the coercion compiles out and the helper matches the previous walk. Tests: coverage-event-policy (test_event_channel_phone_api, test_event_channel_router, test_position_precision, test_mqtt, test_nexthop_routing) and the same suites with the policy off. Co-authored-by: Claude Fable 5 --- src/mesh/MeshService.cpp | 28 +--- src/mesh/PhoneAPI.cpp | 7 +- src/mesh/PositionPrecision.cpp | 11 ++ src/mesh/PositionPrecision.h | 4 + src/mesh/Router.cpp | 54 +++++- src/mesh/Router.h | 4 + src/modules/PositionModule.cpp | 38 +++-- src/modules/PositionModule.h | 7 + .../test_main.cpp | 101 +++++++++++- test/test_event_channel_router/test_main.cpp | 156 ++++++++++++++++++ test/test_position_precision/test_main.cpp | 41 +++++ 11 files changed, 411 insertions(+), 40 deletions(-) diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 0667e0b13..cfe213ff9 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -419,27 +419,17 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) LOG_DEBUG("Skip position ping; no fresh position since boot"); return false; } - // Prefer the node's current channel, but fall back to the first channel with - // position enabled (matching PositionModule::sendOurPosition() behavior). + // Prefer the node's current channel, but fall back to the position channel + // (matching PositionModule::sendOurPosition() behavior). uint8_t sendChan = node->channel; - if (getPositionPrecisionForChannel(sendChan) == 0) { - bool found = false; - for (uint8_t ch = 0; ch < 8; ++ch) { - if (getPositionPrecisionForChannel(ch) != 0) { - sendChan = ch; - found = true; - break; - } - } - if (!found) { - // No channel with position enabled: fall back to sending nodeinfo, as before. - if (nodeInfoModule) { - LOG_INFO("No position-enabled channel; send nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", dest, - wantReplies, node->channel); - nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); - } - return false; + if (getPositionPrecisionForChannel(sendChan) == 0 && !findPositionChannel(sendChan)) { + // No channel with position enabled: fall back to sending nodeinfo, as before. + if (nodeInfoModule) { + LOG_INFO("No position-enabled channel; send nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", dest, + wantReplies, node->channel); + nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); } + return false; } LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, sendChan); positionModule->sendOurPosition(dest, wantReplies, sendChan); diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index b45783677..fdffd0c26 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -1826,8 +1826,11 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p) } #endif - // Reject before recording duplicate or per-port cooldown state, so a blocked - // attempt cannot throttle a valid private-channel position retry. + // Coordinates aimed at the event channel go out on the position channel instead (the phone picks the + // channel it last heard the node on, which is the event channel for everyone). Only when there is no + // channel to move them to is the send rejected. Reject before recording duplicate or per-port cooldown + // state, so a blocked attempt cannot throttle a valid private-channel position retry. + coerceCoordinatePacketToPositionChannel(&p); if (isBlockedEventCoordinatePacket(&p)) { LOG_DEBUG("Suppress phone coordinate send on event (everyone) channel"); meshtastic_QueueStatus qs = router->getQueueStatus(); diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp index d34c66086..df846c01c 100644 --- a/src/mesh/PositionPrecision.cpp +++ b/src/mesh/PositionPrecision.cpp @@ -32,6 +32,17 @@ uint32_t getPositionPrecisionForChannel(uint8_t channelIndex) return precision; } +bool findPositionChannel(uint8_t &channelIndex) +{ + for (uint8_t i = 0; i < channels.getNumChannels(); i++) { + if (getPositionPrecisionForChannel(i) != 0) { + channelIndex = i; + return true; + } + } + return false; +} + int32_t truncateCoordinate(int32_t coordinate, uint32_t precision) { if (precision == 0 || precision >= 32) diff --git a/src/mesh/PositionPrecision.h b/src/mesh/PositionPrecision.h index 0a2dc8ef0..a3565aa56 100644 --- a/src/mesh/PositionPrecision.h +++ b/src/mesh/PositionPrecision.h @@ -16,6 +16,10 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel); // Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable. uint32_t getPositionPrecisionForChannel(uint8_t channelIndex); +// The channel our position goes out on: the lowest index with a non-zero on-wire precision (disabled and event +// channels never qualify). Returns false when position sharing is off on every channel. +bool findPositionChannel(uint8_t &channelIndex); + // Truncate a single latitude_i/longitude_i to `precision` significant bits, centered in the // resulting grid cell (stable under GPS jitter). precision 0 or >=32 returns the value unchanged. // The return is the coordinate (int32_t); the uint8_t overload only narrows the precision arg. diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 750bba7a7..02e85b62d 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -16,6 +16,9 @@ #include #include #include +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +#include "modules/PositionModule.h" +#endif #if HAS_TRAFFIC_MANAGEMENT #endif #if HAS_VARIABLE_HOPS @@ -86,6 +89,11 @@ bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p) if (p->pki_encrypted || willUsePki(p)) { return false; } + // From us, to us: never leaves the device (sendLocal delivers it locally). This is how the phone + // hands a GPS-less node its fix and time, so it shares nothing and must not be blocked. + if (isFromUs(p) && isToUs(p)) { + return false; + } if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { return isCoordinatePortnum(p->decoded.portnum) && channels.isEventChannel(getEffectiveChannelIndex(p)); } @@ -96,6 +104,33 @@ bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p) #endif } +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +// A remote node's unicast position request to us. Only the reply is generated for these; the packet +// itself is still dropped by the caller. +static bool isEventChannelPositionRequestForUs(const meshtastic_MeshPacket *p) +{ + return p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && + p->decoded.portnum == meshtastic_PortNum_POSITION_APP && p->decoded.want_response && isToUs(p) && !isFromUs(p); +} +#endif + +bool coerceCoordinatePacketToPositionChannel(meshtastic_MeshPacket *p) +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (!isBlockedEventCoordinatePacket(p)) + return false; + uint8_t positionChannel; + if (!findPositionChannel(positionChannel)) + return false; + LOG_DEBUG("Coerce coordinate packet 0x%08x from event channel to position channel %u", p->id, positionChannel); + p->channel = positionChannel; + return true; +#else + (void)p; + return false; +#endif +} + bool willUsePki(const meshtastic_MeshPacket *p) { #if !(MESHTASTIC_EXCLUDE_PKI) @@ -396,6 +431,11 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) return ERRNO_NO_INTERFACES; } else { + // Coordinates never go out on the event channel: any local originator (phone, module, UI) that aimed + // one there is moved onto the position channel instead. Before the loopback below so the local copy + // carries the channel it will actually be sent on. + coerceCoordinatePacketToPositionChannel(p); + // If we are sending a broadcast, we also treat it as if we just received it ourself // this allows local apps (and PCs) to see broadcasts sourced locally. Only the loopback // handleReceived is deferred when nested; send(p) below still transmits immediately. @@ -1029,14 +1069,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) return DecodeState::DECODE_POLICY_REJECT; #endif + if (p->decoded.has_bitfield) + p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; + if (isBlockedEventCoordinatePacket(p)) { + // want_response is already merged above: a position request on the event channel is still + // answered (on the position channel) even though its coordinates are dropped. LOG_DEBUG("Decoded coordinate packet on event channel; suppress payload logging"); return DecodeState::DECODE_SUCCESS; } - if (p->decoded.has_bitfield) - p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; - /* Not actually ever used. // Decompress if needed. jm if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) { @@ -1515,6 +1557,12 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) // Discard coordinate-bearing packets that arrive on the event ("everyone") // channel: don't process, store in NodeDB, or rebroadcast them. if (!skipHandle && isBlockedEventCoordinatePacket(p)) { + // A position request addressed to us is still answered, on our position channel at that + // channel's precision, so "request position" from a node that only shares the event channel + // with us resolves where positions actually live. The requester's own coordinates are + // still dropped: not stored, not forwarded to the phone, not relayed, not published. + if (isEventChannelPositionRequestForUs(p) && positionModule) + positionModule->replyOnPositionChannel(*p); LOG_DEBUG("Drop coordinate packet on event (everyone) channel"); cancelSending(p->from, p->id); skipHandle = true; diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 9882a4b9c..069b4ede0 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -18,6 +18,10 @@ inline bool isCoordinatePortnum(meshtastic_PortNum portnum) } bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p); +/// Retarget a locally-originated coordinate packet that would be blocked on the event channel onto the +/// position channel (see findPositionChannel). Returns true if p->channel was changed; false when the +/// packet is not a blocked event coordinate packet or no channel carries positions. +bool coerceCoordinatePacketToPositionChannel(meshtastic_MeshPacket *p); bool willUsePki(const meshtastic_MeshPacket *p); /// rx_time/has_rx_time for "now": a real epoch when the clock is trustworthy, else a diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 9ee985b15..40ad53d3a 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -289,6 +289,27 @@ meshtastic_MeshPacket *PositionModule::allocReply() return reply; } +void PositionModule::replyOnPositionChannel(const meshtastic_MeshPacket &req) +{ + uint8_t positionChannel; + if (!findPositionChannel(positionChannel)) { + LOG_DEBUG("Skip position reply to 0x%08x: position sharing disabled on all channels", getFrom(&req)); + return; + } + if (!service) + return; + + precision = getPositionPrecisionForChannel(positionChannel); + meshtastic_MeshPacket *reply = allocReply(); // reply throttle + precision-0/no-fix guards live here + if (!reply) + return; + + setReplyTo(reply, req); + reply->channel = positionChannel; // not the channel the request came in on + LOG_INFO("Reply to position request from 0x%08x on position channel %u", getFrom(&req), positionChannel); + service->sendToMesh(reply); +} + meshtastic_MeshPacket *PositionModule::allocAtakPli() { LOG_INFO("Send TAK V2 PLI packet"); @@ -374,12 +395,11 @@ void PositionModule::sendOurPosition() currentGeneration = radioGeneration; // If we changed channels, ask everyone else for their latest info - 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; - } + uint8_t positionChannel; + if (findPositionChannel(positionChannel)) { + LOG_INFO("Send pos@%x:6 to mesh (wantReplies=%d)", localPosition.timestamp, requestReplies); + sendOurPosition(NODENUM_BROADCAST, requestReplies, positionChannel); + return; } LOG_INFO("Skip pos@%x:6 broadcast; position sharing disabled on all channels", localPosition.timestamp); } @@ -467,12 +487,10 @@ bool PositionModule::positionUnchangedSinceLastSend(const meshtastic_PositionLit // precision). Default nodes gauge movement at that on-wire (public-clamped) resolution; // trackers use their own configured (unclamped) precision so finer moves still count. uint32_t precisionBits = 0; - for (uint8_t ch = 0; ch < 8; ch++) { - if (getPositionPrecisionForChannel(ch) == 0) - continue; + uint8_t ch; + if (findPositionChannel(ch)) { precisionBits = useConfiguredPrecision ? getPositionPrecisionForChannel(channels.getByIndex(ch)) : getPositionPrecisionForChannel(ch); - break; } return positionWithinPrecisionCell(selfPos.latitude_i, selfPos.longitude_i, lastGpsLatitude, lastGpsLongitude, precisionBits); diff --git a/src/modules/PositionModule.h b/src/modules/PositionModule.h index 03754c22b..c5a3d47ad 100644 --- a/src/modules/PositionModule.h +++ b/src/modules/PositionModule.h @@ -36,6 +36,13 @@ class PositionModule : public ProtobufModule, private concu void sendOurPosition(NodeNum dest, bool wantReplies = false, uint8_t channel = 0); void sendOurPosition(); + /** + * Answer a position request that arrived on a channel we never share position on (the event channel): + * the reply goes out on the position channel at that channel's precision, tagged as a reply to req. + * Subject to the same reply throttle as allocReply(). No-op when no channel carries positions. + */ + void replyOnPositionChannel(const meshtastic_MeshPacket &req); + void handleNewPosition(); // Pure broadcast-policy helpers, split out so they're unit-testable without the module. diff --git a/test/test_event_channel_phone_api/test_main.cpp b/test/test_event_channel_phone_api/test_main.cpp index f7932b9c3..57a65915a 100644 --- a/test/test_event_channel_phone_api/test_main.cpp +++ b/test/test_event_channel_phone_api/test_main.cpp @@ -1,4 +1,5 @@ #include "Channels.h" +#include "MeshModule.h" #include "MeshService.h" #include "NodeDB.h" #include "RadioInterface.h" @@ -15,10 +16,28 @@ namespace { constexpr PacketId BLOCKED_PACKET_ID = 0x10203040; constexpr PacketId FOLLOWUP_PACKET_ID = 0x50607080; +constexpr PacketId WAYPOINT_PACKET_ID = 0x0a0b0c0d; constexpr ChannelIndex EVENT_CHANNEL = 0; constexpr ChannelIndex PRIVATE_CHANNEL = 1; +constexpr NodeNum LOCAL_NODE = 0x87654321; constexpr NodeNum REMOTE_NODE = 0x12345678; +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) +// Where a coordinate packet the phone aimed at the event channel actually goes once a channel carries positions. +constexpr ChannelIndex COERCED_CHANNEL = PRIVATE_CHANNEL; +#else +constexpr ChannelIndex COERCED_CHANNEL = EVENT_CHANNEL; +#endif + +// Router::sendLocal() loops a to-self packet through MeshModule::callModules(), which walks the module +// list; construct one so the list exists in this otherwise module-free binary. +class NoopModule : public MeshModule +{ + public: + NoopModule() : MeshModule("event-phone-api-noop") {} + bool wantPacket(const meshtastic_MeshPacket *) override { return false; } +}; + class MockRadioInterface : public RadioInterface { public: @@ -106,6 +125,7 @@ MockMeshService *mockService; MockRouter *mockRouter; NodeDB *mockNodeDB; TestStreamAPI *streamAPI; +NoopModule *noopModule; void configureChannels() { @@ -135,20 +155,35 @@ void configureChannels() channels.onConfigChanged(); } -meshtastic_ToRadio makePositionToRadio(PacketId id, ChannelIndex channel) +// configureChannels() leaves both channels without module_settings, i.e. position sharing off everywhere +// (getPositionPrecisionForChannel fails closed). Opt the private channel in so it becomes the position channel. +void enablePositionOnPrivateChannel() +{ + auto &privateChannel = channelFile.channels[PRIVATE_CHANNEL]; + privateChannel.settings.has_module_settings = true; + privateChannel.settings.module_settings.position_precision = 32; + channels.onConfigChanged(); +} + +meshtastic_ToRadio makeCoordinateToRadio(PacketId id, ChannelIndex channel, meshtastic_PortNum portnum, NodeNum to) { meshtastic_ToRadio message = meshtastic_ToRadio_init_default; const meshtastic_MeshPacket defaultPacket = meshtastic_MeshPacket_init_default; message.which_payload_variant = meshtastic_ToRadio_packet_tag; message.packet = defaultPacket; - message.packet.to = REMOTE_NODE; + message.packet.to = to; message.packet.id = id; message.packet.channel = channel; message.packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; - message.packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + message.packet.decoded.portnum = portnum; return message; } +meshtastic_ToRadio makePositionToRadio(PacketId id, ChannelIndex channel) +{ + return makeCoordinateToRadio(id, channel, meshtastic_PortNum_POSITION_APP, REMOTE_NODE); +} + bool sendToRadio(const meshtastic_ToRadio &message) { uint8_t encoded[meshtastic_ToRadio_size] = {}; @@ -160,13 +195,14 @@ bool sendToRadio(const meshtastic_ToRadio &message) return streamAPI->handleToRadio(encoded, encodedSize); } -void assertSentPacket(size_t index, PacketId id, ChannelIndex channel) +void assertSentPacket(size_t index, PacketId id, ChannelIndex channel, + meshtastic_PortNum portnum = meshtastic_PortNum_POSITION_APP) { TEST_ASSERT_GREATER_THAN(index, mockRouter->sentPackets.size()); const auto &packet = mockRouter->sentPackets[index]; TEST_ASSERT_EQUAL_UINT32(id, packet.id); TEST_ASSERT_EQUAL_UINT8(channel, packet.channel); - TEST_ASSERT_EQUAL(meshtastic_PortNum_POSITION_APP, packet.decoded.portnum); + TEST_ASSERT_EQUAL(portnum, packet.decoded.portnum); } } // namespace @@ -177,16 +213,19 @@ void setUp(void) service = mockService = new MockMeshService(); nodeDB = mockNodeDB = new NodeDB(); - myNodeInfo.my_node_num = 0x87654321; + myNodeInfo.my_node_num = LOCAL_NODE; configureChannels(); cryptLock = nullptr; // Router's ctor asserts this is unset before allocating its own. router = mockRouter = new MockRouter(); streamAPI = new TestStreamAPI(); + noopModule = new NoopModule(); testDelay(1); } void tearDown(void) { + delete noopModule; + noopModule = nullptr; delete streamAPI; streamAPI = nullptr; delete mockRouter; @@ -250,12 +289,62 @@ static void test_event_position_ingress_does_not_poison_retry_state() #endif } +// The apps feed the node its phone GPS fix as a POSITION packet addressed to the node itself on channel 0. +// That packet never leaves the device, so it must pass regardless of the event policy and without a +// notification, on any channel configuration (here: no channel carries positions at all). +static void test_phone_position_to_self_is_never_blocked() +{ + const auto toSelf = makeCoordinateToRadio(BLOCKED_PACKET_ID, EVENT_CHANNEL, meshtastic_PortNum_POSITION_APP, LOCAL_NODE); + + TEST_ASSERT_TRUE(sendToRadio(toSelf)); + TEST_ASSERT_EQUAL(0, mockRouter->sentPackets.size()); // delivered locally, never on the air + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); +} + +// A coordinate the phone aims at the event channel is moved onto the position channel (the first channel +// with position sharing enabled) instead of being rejected, and the phone is not told anything went wrong. +// Without the event policy the packet stays on the channel the phone chose. +static void test_phone_coordinates_on_event_channel_move_to_position_channel() +{ + enablePositionOnPrivateChannel(); + const auto positionRequest = makePositionToRadio(BLOCKED_PACKET_ID, EVENT_CHANNEL); // DM (e.g. "request position") + const auto waypointBroadcast = + makeCoordinateToRadio(WAYPOINT_PACKET_ID, EVENT_CHANNEL, meshtastic_PortNum_WAYPOINT_APP, NODENUM_BROADCAST); + + TEST_ASSERT_TRUE(sendToRadio(positionRequest)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, BLOCKED_PACKET_ID, COERCED_CHANNEL); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); + + TEST_ASSERT_TRUE(sendToRadio(waypointBroadcast)); + TEST_ASSERT_EQUAL(2, mockRouter->sentPackets.size()); + assertSentPacket(1, WAYPOINT_PACKET_ID, COERCED_CHANNEL, meshtastic_PortNum_WAYPOINT_APP); + mockService->assertQueueStatus(WAYPOINT_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); +} + +// A coordinate already on the position channel is left alone. +static void test_phone_coordinates_on_position_channel_are_untouched() +{ + enablePositionOnPrivateChannel(); + + TEST_ASSERT_TRUE(sendToRadio(makePositionToRadio(FOLLOWUP_PACKET_ID, PRIVATE_CHANNEL))); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, FOLLOWUP_PACKET_ID, PRIVATE_CHANNEL); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); +} + extern "C" { void setup() { initializeTestEnvironment(); UNITY_BEGIN(); RUN_TEST(test_event_position_ingress_does_not_poison_retry_state); + RUN_TEST(test_phone_position_to_self_is_never_blocked); + RUN_TEST(test_phone_coordinates_on_event_channel_move_to_position_channel); + RUN_TEST(test_phone_coordinates_on_position_channel_are_untouched); exit(UNITY_END()); } diff --git a/test/test_event_channel_router/test_main.cpp b/test/test_event_channel_router/test_main.cpp index 82700a795..479a0275c 100644 --- a/test/test_event_channel_router/test_main.cpp +++ b/test/test_event_channel_router/test_main.cpp @@ -9,7 +9,11 @@ #include "mesh/MeshRadio.h" #include "mesh/MeshService.h" #include "mesh/NodeDB.h" +#include "mesh/PositionPrecision.h" #include "mesh/Router.h" +#include "modules/PositionModule.h" +#include "modules/RoutingModule.h" +#include "support/MockMeshService.h" #include #include #include @@ -277,6 +281,151 @@ static void test_opaque_tx_is_not_misclassified_as_coordinates() TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); } +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +static void enablePositionOnPrivateChannel() +{ + meshtastic_Channel &privateChannel = channelFile.channels[kPrivateChannel]; + privateChannel.settings.has_module_settings = true; + privateChannel.settings.module_settings.position_precision = 32; + channels.onConfigChanged(); + uint8_t positionChannel = 0xff; + TEST_ASSERT_TRUE(findPositionChannel(positionChannel)); + TEST_ASSERT_EQUAL_UINT8(kPrivateChannel, positionChannel); +} + +// The reply path needs the module, a service to send through, a routing module for the response hop +// limit, and a fix of our own. Scoped to the one test so the rest of the suite stays module-free. +struct ReplyHarness { + MeshService *savedService = service; + RoutingModule *savedRouting = routingModule; + PositionModule *savedPosition = positionModule; + MockMeshService localService; + RoutingModule localRouting; + PositionModule localPosition; + + ReplyHarness() + { + service = &localService; + routingModule = &localRouting; + positionModule = &localPosition; + testNodeDB->addNode(kLocalNode, kEventChannel); // refreshLocalMeshNode() asserts our own entry exists + meshtastic_Position fix = meshtastic_Position_init_zero; + fix.has_latitude_i = true; + fix.latitude_i = 407825770; + fix.has_longitude_i = true; + fix.longitude_i = -1192084390; + testNodeDB->setLocalPosition(fix); + } + + ~ReplyHarness() + { + // Drain what sendToMesh() queued for the (absent) phone so the pools are clean at exit. + while (auto *status = localService.getQueueStatusForPhone()) + localService.releaseQueueStatusToPool(status); + while (auto *packet = localService.getForPhone()) + localService.releaseToPool(packet); + positionModule = savedPosition; + routingModule = savedRouting; + service = savedService; + } +}; + +// A position request DM'd to us on the event channel is not processed (no module sees it, so nothing is +// stored or forwarded), but it is answered: our position goes out as a reply, on the position channel. +static void test_rx_event_channel_position_request_to_us_is_answered_on_position_channel() +{ + enablePositionOnPrivateChannel(); + ReplyHarness harness; + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kRemoteNode, kLocalNode, kEventChannel); + request.decoded.want_response = true; + receivePacket(request); + + TEST_ASSERT_EQUAL_UINT32(0, captureModule->packets.size()); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); + + meshtastic_MeshPacket reply = captureRadio->packets.front(); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT32(kLocalNode, reply.from); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, reply.which_payload_variant); // went out under a channel key + TEST_ASSERT_EQUAL(DecodeState::DECODE_SUCCESS, perhapsDecode(&reply)); + TEST_ASSERT_EQUAL_UINT8(kPrivateChannel, reply.channel); // ...the position channel's, not the event channel's + TEST_ASSERT_EQUAL(meshtastic_PortNum_POSITION_APP, reply.decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); +} + +// Without a position channel there is nothing to answer on: the request is simply dropped. +static void test_rx_event_channel_position_request_without_position_channel_is_dropped() +{ + ReplyHarness harness; + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kRemoteNode, kLocalNode, kEventChannel); + request.decoded.want_response = true; + receivePacket(request); + + TEST_ASSERT_EQUAL_UINT32(0, captureModule->packets.size()); + TEST_ASSERT_EQUAL_UINT32(0, captureRadio->packets.size()); +} + +// A broadcast position on the event channel is dropped outright, want_response or not: only unicast +// requests to us are answered. +static void test_rx_event_channel_position_broadcast_with_want_response_is_not_answered() +{ + enablePositionOnPrivateChannel(); + ReplyHarness harness; + + meshtastic_MeshPacket broadcast = + makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kRemoteNode, NODENUM_BROADCAST, kEventChannel); + broadcast.decoded.want_response = true; + receivePacket(broadcast); + + TEST_ASSERT_EQUAL_UINT32(0, captureModule->packets.size()); + TEST_ASSERT_EQUAL_UINT32(0, captureRadio->packets.size()); +} + +// The phone hands a GPS-less node its fix as a POSITION packet from us to us on channel 0. It never goes on +// the air, so the event policy must let it through to the modules (where PositionModule records it). +static void test_loopback_position_from_us_to_us_on_event_channel_is_not_blocked() +{ + meshtastic_MeshPacket loopback = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kLocalNode, kLocalNode, kEventChannel); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&loopback)); + + meshtastic_MeshPacket *packet = packetPool.allocCopy(loopback); + TEST_ASSERT_NOT_NULL(packet); + TEST_ASSERT_EQUAL_INT(ERRNO_SHOULD_RELEASE, testRouter->sendLocal(packet, RX_SRC_USER)); + packetPool.release(packet); + + TEST_ASSERT_EQUAL_UINT32(1, captureModule->packets.size()); + TEST_ASSERT_EQUAL_UINT32(0, captureRadio->packets.size()); +} + +// A local originator (module, UI) that aims a coordinate at the event channel is moved onto the position +// channel by sendLocal(); with no position channel the send is still refused. +static void test_tx_local_coordinate_on_event_channel_is_moved_to_position_channel() +{ + meshtastic_MeshPacket *packet = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(packet); + packet->to = NODENUM_BROADCAST; + packet->channel = kEventChannel; + packet->decoded = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kLocalNode, NODENUM_BROADCAST, kEventChannel).decoded; + TEST_ASSERT_EQUAL_INT(meshtastic_Routing_Error_NOT_AUTHORIZED, testRouter->sendLocal(packet, RX_SRC_LOCAL)); + TEST_ASSERT_EQUAL_UINT32(0, captureRadio->packets.size()); + + enablePositionOnPrivateChannel(); + packet = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(packet); + packet->to = NODENUM_BROADCAST; + packet->channel = kEventChannel; + packet->decoded = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, kLocalNode, NODENUM_BROADCAST, kEventChannel).decoded; + TEST_ASSERT_EQUAL_INT(ERRNO_OK, testRouter->sendLocal(packet, RX_SRC_LOCAL)); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); + + meshtastic_MeshPacket sent = captureRadio->packets.front(); + TEST_ASSERT_EQUAL(DecodeState::DECODE_SUCCESS, perhapsDecode(&sent)); + TEST_ASSERT_EQUAL_UINT8(kPrivateChannel, sent.channel); +} +#endif + static void test_capture_endpoints_release_packet_pool_ownership() { constexpr size_t iterations = 64; @@ -382,6 +531,13 @@ EVENT_ROUTER_TEST_ENTRY void setup() RUN_TEST(test_tx_event_coordinate_that_uses_pki_reaches_radio); #endif RUN_TEST(test_opaque_tx_is_not_misclassified_as_coordinates); +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + RUN_TEST(test_rx_event_channel_position_request_to_us_is_answered_on_position_channel); + RUN_TEST(test_rx_event_channel_position_request_without_position_channel_is_dropped); + RUN_TEST(test_rx_event_channel_position_broadcast_with_want_response_is_not_answered); + RUN_TEST(test_loopback_position_from_us_to_us_on_event_channel_is_not_blocked); + RUN_TEST(test_tx_local_coordinate_on_event_channel_is_moved_to_position_channel); +#endif RUN_TEST(test_capture_endpoints_release_packet_pool_ownership); exit(UNITY_END()); diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index 7497b42f6..034314b1e 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -304,6 +304,9 @@ static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, uint8 packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; packet.decoded.portnum = portnum; packet.channel = channelIndex; + // A real destination: this suite never sets a node number, so a default to=0 would read as + // "to us" (getNodeNum()==0) and take the from-us-to-us loopback exemption. + packet.to = NODENUM_BROADCAST; return packet; } @@ -373,7 +376,12 @@ static void test_eventCoordinatePolicy_usesResolvedUnicastChannel() configureEventChannels(false, false); meshtastic_NodeInfoLite *node = nodeDB->getNumMeshNodes() > 1 ? nodeDB->getMeshNodeByIndex(1) : nodeDB->getOrCreateMeshNode(0x12345678); + // A persisted DB (unsandboxed host run) can hand back our own entry here; a from-us-to-us packet is + // loopback-exempt, which is not the policy under test. Insist on a remote destination. + if (node && node->num == nodeDB->getNodeNum()) + node = nodeDB->getOrCreateMeshNode(0x12345678); TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_NOT_EQUAL(nodeDB->getNodeNum(), node->num); const NodeNum destination = node->num; const uint8_t savedChannel = node->channel; @@ -396,6 +404,38 @@ static void test_eventCoordinatePolicy_usesResolvedUnicastChannel() #endif } +static void test_findPositionChannel_skipsEventAndDisabledChannels() +{ + // Both channels store precision 16. Under the block gate the event channel never carries + // positions, so the private one (index 1) is the position channel; otherwise index 0 wins. + configureEventChannels(false, false); + uint8_t positionChannel = 0xff; + TEST_ASSERT_TRUE(findPositionChannel(positionChannel)); +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_EQUAL_UINT8(1, positionChannel); +#else + TEST_ASSERT_EQUAL_UINT8(0, positionChannel); +#endif + + // Reordering follows the effective key, not the index. + configureEventChannels(true, false); + TEST_ASSERT_TRUE(findPositionChannel(positionChannel)); + TEST_ASSERT_EQUAL_UINT8(0, positionChannel); + + // Precision 0 everywhere: nothing to pick. + configureEventChannels(false, false); + channelFile.channels[0].settings.module_settings.position_precision = 0; + channelFile.channels[1].settings.module_settings.position_precision = 0; + channels.onConfigChanged(); + TEST_ASSERT_FALSE(findPositionChannel(positionChannel)); + + // A disabled channel does not count even with a stored precision. + channelFile.channels[1].settings.module_settings.position_precision = 32; + channelFile.channels[1].role = meshtastic_Channel_Role_DISABLED; + channels.onConfigChanged(); + TEST_ASSERT_FALSE(findPositionChannel(positionChannel)); +} + static void test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored() { // A private channel with a full 32-byte key that is not the configured @@ -439,6 +479,7 @@ void setup() RUN_TEST(test_eventCoordinatePolicy_coversPortsAndExcludesPki); RUN_TEST(test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash); RUN_TEST(test_eventCoordinatePolicy_usesResolvedUnicastChannel); + RUN_TEST(test_findPositionChannel_skipsEventAndDisabledChannels); RUN_TEST(test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored); exit(UNITY_END()); } From 9fcb289643ed87f062569fb7fd2005edb5fdc11e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 18 Aug 2026 20:28:01 -0500 Subject: [PATCH 087/109] fix(thinknode_m9): define SPI_FREQUENCY for the non-MUI build (#11546) The M9's variant.h defines ST7789_CS, so TFTDisplay.cpp compiles its ST7789 LGFX branch, which reads SPI_FREQUENCY for the panel write clock (SPI_READ_FREQUENCY, its pair, is already in variant.h). The flag was only set in the -tft env, so `build (thinknode_m9, esp32s3)` has failed on develop since the board landed in #10908: src/graphics/TFTDisplay.cpp:504:30: error: 'SPI_FREQUENCY' was not declared in this scope; did you mean 'SD_SPI_FREQUENCY'? Move the flag up into thinknode_m9_base, keeping the 75 MHz the -tft env already used for the same panel and matching the SD card's 75 MHz on the bus they share. The -tft env inherits the base flags, so device-ui's LGFX_GENERIC.h - which falls back to 20 MHz when the macro is absent - still sees the identical value. --- variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini index fbd595dee..7aea2c5fb 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini @@ -31,6 +31,7 @@ build_flags = -D SDCARD_INIT_SPI -D SD_SPI_FREQUENCY=75000000U -D HAS_SCREEN=1 + -D SPI_FREQUENCY=75000000 ; -D COMPASS_SENSOR_DEBUG=1 lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom depName=LovyanGFX packageName=lovyan03/library/LovyanGFX @@ -82,7 +83,6 @@ build_flags = -D LGFX_SCREEN_WIDTH=240 -D LGFX_SCREEN_HEIGHT=320 -D LGFX_INVERT_LIGHT=true - -D SPI_FREQUENCY=75000000 ; -D MAP_FULL_REDRAW -D MUI_WIFI_PS_MIN_MODEM -D DEFAULT_FTP_SERVER_NETWORK_TYPE_ESP32=NETWORK_ESP32 From 74119c088b6ca1e7c8febfec843363c949cf8f01 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 18 Aug 2026 20:44:48 -0500 Subject: [PATCH 088/109] fix(mesh): don't reference the position module on MESHTASTIC_EXCLUDE_GPS builds The event-channel position-request reply added in #11545 calls positionModule-> replyOnPositionChannel() guarded only by USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL. Targets that set MESHTASTIC_EXCLUDE_GPS (repeaters such as rak_wismesh_repeater_mini_hp) never construct PositionModule in Modules.cpp, so an event build for one of those fails to link: undefined reference to `PositionModule::replyOnPositionChannel(...)' undefined reference to `positionModule' Guard the call, the include and the isEventChannelPositionRequestForUs() helper with !MESHTASTIC_EXCLUDE_GPS, matching how AdminModule guards its positionModule use. A node with no position module has nothing to answer a position request with, so skipping the reply is the correct behavior there. Not reachable on develop, where the userpref defaults off and the whole block compiles out - it only breaks builds that enable it, which is why #11545 was green. Verified by building rak_wismesh_repeater_mini_hp with the pref enabled. Co-Authored-By: Claude Fable 5 --- src/mesh/Router.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 02e85b62d..34f477438 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -16,7 +16,7 @@ #include #include #include -#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && !MESHTASTIC_EXCLUDE_GPS #include "modules/PositionModule.h" #endif #if HAS_TRAFFIC_MANAGEMENT @@ -104,7 +104,7 @@ bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p) #endif } -#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && !MESHTASTIC_EXCLUDE_GPS // A remote node's unicast position request to us. Only the reply is generated for these; the packet // itself is still dropped by the caller. static bool isEventChannelPositionRequestForUs(const meshtastic_MeshPacket *p) @@ -1561,8 +1561,12 @@ void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) // channel's precision, so "request position" from a node that only shares the event channel // with us resolves where positions actually live. The requester's own coordinates are // still dropped: not stored, not forwarded to the phone, not relayed, not published. + // Builds without the position module (MESHTASTIC_EXCLUDE_GPS, e.g. repeaters) have nothing + // to answer with, and neither the symbol nor the global exists to link against. +#if !MESHTASTIC_EXCLUDE_GPS if (isEventChannelPositionRequestForUs(p) && positionModule) positionModule->replyOnPositionChannel(*p); +#endif LOG_DEBUG("Drop coordinate packet on event (everyone) channel"); cancelSending(p->from, p->id); skipHandle = true; From 93d15a536887bc519636a11aaeddc8cdc4035485 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Wed, 19 Aug 2026 10:17:02 +0000 Subject: [PATCH 089/109] Add AS3935 lightning sensor support (#10931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add AS3935 lightning sensor support Implements meshtastic/firmware#10774: an AS3935Sensor (TelemetrySensor subclass) that reports lightning_strike_count_1h and lightning_distance_km on the normal environment telemetry interval, like a rain gauge - strikes are counted over a fixed rolling ~1h window and read non-destructively, so replying to a peer's telemetry request in between broadcasts can't silently drop counted strikes. The AS3935's IRQ pin (opt-in per board via AS3935_IRQ) is polled with a plain digitalRead() in runOnce(), deliberately not attachInterrupt(): the IRQ line is a level that stays asserted until its interrupt register is read, so polling can't miss an event regardless of timing, matching the SparkFun library's own reference examples. An interrupt would also buy nothing here even setting that aside - classification requires an I2C read (readInterruptReg(), which itself calls delay(2) per the datasheet's settle-time requirement), and blocking I2C/delay() calls aren't safe from ISR context on any of this codebase's target platforms, so the ISR could only ever set a flag for later draining - no less work than just polling the pin directly on the next tick. A genuine lightning classification also requests an immediate out-of-cycle send via a new EnvironmentTelemetryModule:: requestImmediateSend() hook. There's no fixed debounce on the request itself - EnvironmentTelemetryModule's existing airtime/duty-cycle gate already paces every send, so it sends as often as airtime allows rather than an arbitrary fixed rate. The request does expire after 5 minutes unfulfilled, so it can't fire an arbitrarily stale broadcast if airtime was blocked for a long stretch. The AS3935's I2C addresses (0x01-0x03) fall inside the range this codebase's I2C scanner otherwise skips as reserved, so detection is a small dedicated probe gated behind AS3935_IRQ and respecting the caller's address filter, rather than a change to the general scan loop. Presence is confirmed via a register write/readback round-trip rather than a fixed expected value, since the AS3935 has no WHOAMI register and a power-on-reset-only check can't survive a warm reboot that doesn't power-cycle the sensor (initDevice() permanently rewrites that register on first configuration). Generated files under src/mesh/generated/ are intentionally excluded from this commit - they're regenerated from the protobufs submodule by update_protobufs.yml, and hand edits get overwritten and conflict once the companion protobufs PR merges and the submodule pointer updates. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong * fix(as3935): calibration and telemetry logging initDevice() never called the library's calibrateOsc(). The AS3935's internal oscillators are calibrated against the antenna's resonance, which the AFE/watchdog/spike-rejection thresholds depend on; without it, only a directly-driven IRQ pin (bypassing detection entirely) reacted during testing. The sensor could already have a historical detection event latching the IRQ pin high before our initialization. Added an explicit drain read after the IRQ pin is configured, so the sensor doesn't start out stuck asserting IRQ. EnvironmentTelemetryModule::sendTelemetry() logs every other environment metric category on send but was missing lightning; added a matching log line. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong * Support AS3935 without an IRQ line, make the antenna trim configurable Detection no longer requires AS3935_IRQ. The probe is gated like the other environmental sensors, so an I2C-only breakout is found on any board. Where AS3935_IRQ is defined the pin still gates the I2C read, otherwise runOnce() polls the interrupt register, which latches until read. Antenna tuning capacitance moves to AdminMessage.sensor_config.as3935_config, persisted to /prefs/as3935.dat and defaulting to 96pF. The chip does not retain it across power loss. Disturbers are masked in the chip, since runOnce() now polls every second. The lightning telemetry log is guarded so nodes without the sensor no longer log it on every send. Requires meshtastic/protobufs#981. * Revert protobufs pointer to the develop baseline The submodule bump conflicts on merge and the generated headers come from an out of band CI job, so the pointer moves with that job rather than in this branch. * Report lightning strikes over a true rolling hour strikeCountWindow was zeroed on a fixed interval, so lightning_strike_count_1h reported strikes since the last reset rather than over the preceding hour. RollingCounter is a fixed memory sliding window: one counter per bucket, nothing stored per event, so a storm cannot grow it. The ring holds one bucket more than the window needs so none is recycled while part of it is still inside, and the oldest bucket contributes only the fraction still in range. Both are needed to hold the span at exactly the window length rather than letting it drift by a bucket either way. Expiry is exact to one bucket rather than to the event, which is below the 5 minute floor on mesh telemetry sends. The distance expires with the last strike in the window instead of on the interval reset. Covered by test/test_rolling_counter. * Widen the RollingCounter edge weighting to 64 bit counts * inWindow is a 32 bit product, so a bucket holding more than 2^32 / BucketMs events wraps. At a 5 minute width that is about 14k: a bucket of 50000 reported 11367 instead of 40000 once it reached the window edge. Below the threshold nothing changes, so lightning was unaffected, but the helper is meant to be reused by counters with far higher rates. test_large_burst_at_window_edge covers it. The existing burst test sampled only inside the window, where the bucket is whole and never weighted. * Trim RollingCounter comments to the house limit --------- Signed-off-by: Andrew Yong Co-authored-by: Thomas Göttgens --- platformio.ini | 2 + src/configuration.h | 3 + src/detect/ScanI2C.h | 3 +- src/detect/ScanI2CTwoWire.cpp | 34 +++ src/modules/Modules.cpp | 2 +- .../Telemetry/EnvironmentTelemetry.cpp | 21 +- src/modules/Telemetry/EnvironmentTelemetry.h | 10 + src/modules/Telemetry/Sensor/AS3935Sensor.cpp | 225 ++++++++++++++++++ src/modules/Telemetry/Sensor/AS3935Sensor.h | 44 ++++ src/modules/Telemetry/Sensor/RollingCounter.h | 85 +++++++ test/test_rolling_counter/test_main.cpp | 142 +++++++++++ 11 files changed, 568 insertions(+), 3 deletions(-) create mode 100644 src/modules/Telemetry/Sensor/AS3935Sensor.cpp create mode 100644 src/modules/Telemetry/Sensor/AS3935Sensor.h create mode 100644 src/modules/Telemetry/Sensor/RollingCounter.h create mode 100644 test/test_rolling_counter/test_main.cpp diff --git a/platformio.ini b/platformio.ini index a1dd0d310..74916381f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -193,6 +193,8 @@ lib_deps = https://github.com/DFRobot/DFRobot_RTU/archive/refs/tags/V1.0.6.zip # renovate: datasource=git-refs depName=DFRobot_RainfallSensor packageName=https://github.com/DFRobot/DFRobot_RainfallSensor gitBranch=master https://github.com/DFRobot/DFRobot_RainfallSensor/archive/38fea5e02b40a5430be6dab39a99a6f6347d667e.zip + # renovate: datasource=github-tags depName=SparkFun AS3935 packageName=sparkfun/SparkFun_AS3935_Lightning_Detector_Arduino_Library + https://github.com/sparkfun/SparkFun_AS3935_Lightning_Detector_Arduino_Library/archive/refs/tags/v1.4.9.zip # renovate: datasource=github-tags depName=INA226 packageName=robtillaart/INA226 https://github.com/RobTillaart/INA226/archive/refs/tags/0.6.6.zip # renovate: datasource=github-tags depName=SparkFun MAX3010x packageName=sparkfun/SparkFun_MAX3010x_Sensor_Library diff --git a/src/configuration.h b/src/configuration.h index 03cb12bf3..5c9cdf956 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -317,6 +317,9 @@ along with this program. If not, see . #define DS248X_ADDR_ALT6 0x1E // same as HMC5883L_ADDR #define DS248X_ADDR_ALT7 0x1F // same as BBQ10_KB_ADDR #define HM330X_ADDR 0x40 +#define AS3935_ADDR 0x03 // both address pins tied high, the common breakout-board default +#define AS3935_ADDR_ALT 0x01 +#define AS3935_ADDR_ALT2 0x02 // ----------------------------------------------------------------------------- // ACCELEROMETER diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index c36434ae7..4bb141722 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -108,7 +108,8 @@ class ScanI2C SPA06, STC8HKB, // STC8H companion-MCU keypad (ThinkNode-M9) DS248X, - HM330X + HM330X, + AS3935 } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 9085763d5..d9c9d7717 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -1095,6 +1095,40 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) } } +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR + // AS3935 addresses (0x01-0x03) fall in the reserved range the loop above skips; probe + // them separately rather than widening that loop for every board. + static const uint8_t as3935Candidates[] = {AS3935_ADDR_ALT, AS3935_ADDR_ALT2, AS3935_ADDR}; + for (uint8_t i = 0; i < sizeof(as3935Candidates); i++) { + // Respect the caller's address filter, same as the main loop above (line ~269). + if (asize != 0 && !in_array(address, asize, as3935Candidates[i])) + continue; + + DeviceAddress as3935Addr(port, as3935Candidates[i]); + i2cBus->beginTransmission(as3935Candidates[i]); + uint8_t as3935Err = i2cBus->endTransmission(); + if (as3935Err == 0) { + // No WHOAMI, and a POR-only check can't survive a warm reboot (initDevice rewrites + // REG0x00). Write a test pattern to bits[5:1] instead and confirm it reads back. + constexpr uint8_t AS3935_PROBE_PATTERN = 0b01010; // arbitrary, bits[5:1] + i2cBus->beginTransmission(as3935Candidates[i]); + i2cBus->write((uint8_t)0x00); // REG0x00 (AFE_GAIN) + i2cBus->write((uint8_t)(AS3935_PROBE_PATTERN << 1)); // PWD=0, gain bits = pattern + if (i2cBus->endTransmission() == 0) { + uint16_t reg0 = getRegisterValue(ScanI2CTwoWire::RegisterLocation(as3935Addr, 0x00), 1); + if (((reg0 >> 1) & 0x1F) == AS3935_PROBE_PATTERN) { + logFoundDevice("AS3935", as3935Candidates[i]); + deviceAddresses[AS3935] = as3935Addr; + foundDevices[as3935Addr] = AS3935; + break; // only one AS3935 expected per bus + } else { + LOG_DEBUG("Unexpected REG0x00 readback for AS3935: addr=0x%x val=0x%x", as3935Candidates[i], reg0); + } + } + } + } +#endif + // The QMC6309 magnetometer sits at 0x7C, above the general scan ceiling (the loop above stops at 0x77 to // avoid the reserved 0x78-0x7F block). Probe it explicitly. Gated on the SensorLib driver being present so // only boards that can actually drive the chip poke this reserved address. diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 546651c5b..a2de555e9 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -223,7 +223,7 @@ void setupModules() #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR if (moduleConfig.has_telemetry && (moduleConfig.telemetry.environment_measurement_enabled || moduleConfig.telemetry.environment_screen_enabled)) { - new EnvironmentTelemetryModule(); + environmentTelemetryModule = new EnvironmentTelemetryModule(); } #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR if (moduleConfig.has_telemetry && diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index aae103e24..a4143de29 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -102,6 +102,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/DFRobotGravitySensor.h" #endif +#if __has_include() +#include "Sensor/AS3935Sensor.h" +#endif + #if __has_include() #include "Sensor/NAU7802Sensor.h" #endif @@ -154,6 +158,7 @@ EnvironmentTelemetryModule::DisplaySource gDisplaySource = EnvironmentTelemetryM } // namespace static constexpr uint16_t TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY = 0x8002; +static constexpr uint32_t IMMEDIATE_SEND_MAX_STALENESS_MS = 5UL * 60UL * 1000; // 5 minutes static constexpr uint32_t LOCAL_DISPLAY_REFRESH_INTERVAL_MS = 1000; EnvironmentTelemetryModule::DisplaySource EnvironmentTelemetryModule::getDisplaySource() @@ -294,6 +299,9 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::DFROBOT_RAIN); #endif +#if __has_include() + addSensor(i2cScanner, ScanI2C::DeviceType::AS3935); +#endif #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::AHT10); #endif @@ -434,9 +442,15 @@ int32_t EnvironmentTelemetryModule::runOnce() } refreshDisplayedMeasurement(); + // Give up on a stale immediate-send request rather than fire an arbitrarily late broadcast. + if (immediateSendRequested && + !Throttle::isWithinTimespanMs(immediateSendRequestedAtMs, IMMEDIATE_SEND_MAX_STALENESS_MS)) { + immediateSendRequested = false; + } + uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY) : 0; - if (((lastTelemetry == 0) || + if (((lastTelemetry == 0) || immediateSendRequested || !Throttle::isWithinTimespanMs( lastTelemetry, Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.environment_update_interval, default_telemetry_broadcast_interval_secs, numOnlineNodes, @@ -444,6 +458,7 @@ int32_t EnvironmentTelemetryModule::runOnce() airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { sendTelemetry(); + immediateSendRequested = false; if (transmitHistory) transmitHistory->setLastSentToMesh(TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY); } else if (((lastSentToPhone == 0) || !Throttle::isWithinTimespanMs(lastSentToPhone, sendToPhoneIntervalMs)) && @@ -811,6 +826,10 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) m.variant.environment_metrics.adc_voltage_ch5, m.variant.environment_metrics.adc_voltage_ch6, m.variant.environment_metrics.adc_voltage_ch7); + if (m.variant.environment_metrics.has_lightning_strike_count_1h) + LOG_INFO("Send: lightning=%u, distance=%fkm", m.variant.environment_metrics.lightning_strike_count_1h, + m.variant.environment_metrics.lightning_distance_km); + meshtastic_MeshPacket *p = allocDataProtobuf(m); if (!p) { validTelemetry = false; diff --git a/src/modules/Telemetry/EnvironmentTelemetry.h b/src/modules/Telemetry/EnvironmentTelemetry.h index 6d5678b35..0aabe8647 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.h +++ b/src/modules/Telemetry/EnvironmentTelemetry.h @@ -56,6 +56,14 @@ class EnvironmentTelemetryModule : private concurrency::OSThread, virtual void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) override; #endif + /** Bypass the normal broadcast throttle once, for a sensor with a noteworthy event to + * report sooner than the next scheduled send (airtime limits still apply). */ + void requestImmediateSend() + { + immediateSendRequested = true; + immediateSendRequestedAtMs = millis(); + } + protected: /** Called to handle a particular incoming message @return true if you've guaranteed you've handled this message and no other handlers should be considered for it @@ -87,6 +95,8 @@ class EnvironmentTelemetryModule : private concurrency::OSThread, bool shouldDisplayRemoteNode(NodeNum nodeNum) const; bool firstTime = 1; + bool immediateSendRequested = false; + uint32_t immediateSendRequestedAtMs = 0; meshtastic_MeshPacket *lastMeasurementPacket; uint32_t lastLocalDisplayRefreshMs = 0; uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute diff --git a/src/modules/Telemetry/Sensor/AS3935Sensor.cpp b/src/modules/Telemetry/Sensor/AS3935Sensor.cpp new file mode 100644 index 000000000..e8790fd92 --- /dev/null +++ b/src/modules/Telemetry/Sensor/AS3935Sensor.cpp @@ -0,0 +1,225 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "AS3935Sensor.h" +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "TelemetrySensor.h" +#include "modules/Telemetry/EnvironmentTelemetry.h" +#include +#include +#include + +namespace +{ +// No attachInterrupt(): the interrupt latches until read, so polling can't miss it, and the +// I2C read itself isn't ISR-safe anyway. AS3935_IRQ is optional - see runOnce(). +constexpr int32_t AS3935_CHECK_INTERVAL_MS = DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; +constexpr uint8_t AS3935_DISTANCE_OUT_OF_RANGE = 0x3F; +} // namespace + +// Fallback until an admin message sets one; 96pF is DFRobot's value for the SEN0290. +#ifndef AS3935_TUNING_CAP_PF +#define AS3935_TUNING_CAP_PF 96 +#endif +static_assert(AS3935_TUNING_CAP_PF % 8 == 0 && AS3935_TUNING_CAP_PF <= 120, + "AS3935_TUNING_CAP_PF must be a multiple of 8, at most 120 - tuneCap() silently ignores other values"); + +AS3935Sensor::AS3935Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_AS3935, "AS3935") {} + +AS3935Sensor::~AS3935Sensor() +{ + if (lightning) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdelete-non-virtual-dtor" + delete lightning; +#pragma GCC diagnostic pop + lightning = nullptr; + } +} + +bool AS3935Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s", sensorName); + + lightning = new SparkFun_AS3935(dev->address.address); + status = lightning->begin(*bus); + if (!status) { + initI2CSensor(); + return status; + } + + // Oscillators are tuned to the antenna resonance; calibration affects strike detection thresholds. + if (!lightning->calibrateOsc()) { + LOG_WARN("%s: oscillator calibration failed", sensorName); + } + + // Defaults match the library's own example, except outdoor mode. Disturbers are masked in + // the chip - runOnce() polls every second, so an unmasked noisy site never goes quiet. + lightning->setIndoorOutdoor(OUTDOOR); + lightning->setNoiseLevel(2); + lightning->watchdogThreshold(2); + lightning->spikeRejection(2); + lightning->maskDisturber(true); + lightning->lightningThreshold(1); + + // Applied last: the RCO calibration above uses the antenna oscillator as its reference. + if (!loadCalibrationData()) + as3935config.tuning_cap_pf = AS3935_TUNING_CAP_PF; + if (!setTuningCap(as3935config.tuning_cap_pf)) { + LOG_WARN("%s: bad stored cap %upF", sensorName, as3935config.tuning_cap_pf); + setTuningCap(AS3935_TUNING_CAP_PF); + } + +#ifdef AS3935_IRQ + pinMode(AS3935_IRQ, INPUT); +#endif + // Drain anything already latched, so we don't report a strike that predates us. + lightning->readInterruptReg(); + + initI2CSensor(); + return status; +} + +int32_t AS3935Sensor::runOnce() +{ +#ifdef AS3935_IRQ + // IRQ wired: only spend an I2C transaction once the pin says something is latched. + if (digitalRead(AS3935_IRQ) == HIGH) { + classifyPendingIrq(); + } +#else + // I2C-only breakout: poll the register instead, it reads back 0 when nothing is pending. + classifyPendingIrq(); +#endif + return AS3935_CHECK_INTERVAL_MS; +} + +void AS3935Sensor::classifyPendingIrq() +{ + uint8_t interruptReason = lightning->readInterruptReg(); + switch (interruptReason) { + case LIGHTNING: { + strikes.add(); + uint8_t distance = lightning->distanceToStorm(); + if (distance != AS3935_DISTANCE_OUT_OF_RANGE) { + lastDistanceKm = distance; + LOG_INFO("%s: strike %dkm", sensorName, distance); + } else { + LOG_INFO("%s: strike, distance unknown", sensorName); + } + // No debounce here - EnvironmentTelemetryModule's airtime gate already paces every send. + if (environmentTelemetryModule) { + environmentTelemetryModule->requestImmediateSend(); + } + break; + } + case NOISE_TO_HIGH: + LOG_DEBUG("%s: noise floor high", sensorName); + break; + default: + break; + } +} + +bool AS3935Sensor::setTuningCap(uint32_t pf) +{ + if (pf > 120 || pf % 8 != 0) + return false; + + lightning->tuneCap(pf); + as3935config.tuning_cap_pf = pf; + // Readback, not pf: the only evidence the register write actually landed. + LOG_INFO("%s: tuning cap %upF", sensorName, lightning->readTuneCap()); + return true; +} + +AdminMessageHandleResult AS3935Sensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + result = AdminMessageHandleResult::NOT_HANDLED; + + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: + if (!request->sensor_config.has_as3935_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + + if (request->sensor_config.as3935_config.has_set_tuning_cap_pf) { + uint32_t pf = request->sensor_config.as3935_config.set_tuning_cap_pf; + if (!setTuningCap(pf)) { + LOG_ERROR("%s: bad cap %upF", sensorName, pf); + } else if (!saveCalibrationData()) { + LOG_WARN("%s: save failed", sensorName); + } + } + + result = AdminMessageHandleResult::HANDLED; + break; + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} + +bool AS3935Sensor::saveCalibrationData() +{ + auto file = SafeFile(as3935ConfigFileName); + bool okay = false; + + LOG_INFO("%s state write to %s", sensorName, as3935ConfigFileName); + pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_AS3935Config_size}; + + if (!pb_encode(&stream, &meshtastic_AS3935Config_msg, &as3935config)) { + LOG_ERROR("Can't encode protobuf %s", PB_GET_ERROR(&stream)); + } else { + okay = true; + } + // Note: SafeFile::close() already acquires the lock and releases it internally + okay &= file.close(); + + return okay; +} + +bool AS3935Sensor::loadCalibrationData() +{ + spiLock->lock(); + auto file = FSCom.open(as3935ConfigFileName, FILE_O_READ); + bool okay = false; + if (file) { + LOG_INFO("%s state read from %s", sensorName, as3935ConfigFileName); + pb_istream_t stream = {&readcb, &file, meshtastic_AS3935Config_size}; + if (!pb_decode(&stream, &meshtastic_AS3935Config_msg, &as3935config)) { + LOG_ERROR("Can't decode protobuf %s", PB_GET_ERROR(&stream)); + } else { + okay = true; + } + file.close(); + } else { + LOG_INFO("No %s state found (File: %s)", sensorName, as3935ConfigFileName); + } + spiLock->unlock(); + return okay; +} + +bool AS3935Sensor::getMetrics(meshtastic_Telemetry *measurement) +{ + uint32_t count = strikes.sum(); + measurement->variant.environment_metrics.has_lightning_strike_count_1h = true; + measurement->variant.environment_metrics.lightning_strike_count_1h = count; + // The distance belongs to the newest strike, so it expires when that strike leaves the window. + if (count && lastDistanceKm >= 0) { + measurement->variant.environment_metrics.has_lightning_distance_km = true; + measurement->variant.environment_metrics.lightning_distance_km = lastDistanceKm; + } + return true; +} + +#endif diff --git a/src/modules/Telemetry/Sensor/AS3935Sensor.h b/src/modules/Telemetry/Sensor/AS3935Sensor.h new file mode 100644 index 000000000..37e25ab40 --- /dev/null +++ b/src/modules/Telemetry/Sensor/AS3935Sensor.h @@ -0,0 +1,44 @@ +#pragma once + +#ifndef _MT_AS3935SENSOR_H +#define _MT_AS3935SENSOR_H +#include "MeshModule.h" +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "RollingCounter.h" +#include "TelemetrySensor.h" +#include + +class AS3935Sensor : public TelemetrySensor +{ + private: + SparkFun_AS3935 *lightning = nullptr; + RollingCounter<60UL * 60 * 1000, 5UL * 60 * 1000> strikes; + float lastDistanceKm = -1; // sentinel: no valid distance captured yet + + void classifyPendingIrq(); + + protected: + const char *as3935ConfigFileName = "/prefs/as3935.dat"; + meshtastic_AS3935Config as3935config = meshtastic_AS3935Config_init_zero; + bool saveCalibrationData(); + bool loadCalibrationData(); + + public: + AS3935Sensor(); + ~AS3935Sensor(); + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual int32_t runOnce() override; + // Antenna trim in pF. Rejects anything but a multiple of 8 up to 120, which + // tuneCap() would silently ignore. + bool setTuningCap(uint32_t pf); + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; +}; + +#endif +#endif diff --git a/src/modules/Telemetry/Sensor/RollingCounter.h b/src/modules/Telemetry/Sensor/RollingCounter.h new file mode 100644 index 000000000..1d2d8bae0 --- /dev/null +++ b/src/modules/Telemetry/Sensor/RollingCounter.h @@ -0,0 +1,85 @@ +#pragma once + +#include "UptimeClock.h" +#include "mesh/Throttle.h" +#include + +/** + * Sliding-window event counter in fixed memory, one counter per bucket. The spare bucket and the + * weighted oldest bucket are what hold sum() at exactly WindowMs rather than a bucket either way. + * RollingCounter<60UL * 60 * 1000, 5UL * 60 * 1000> strikes; // last hour in 5min steps + */ +template class RollingCounter +{ + static_assert(BucketMs > 0, "BucketMs must be non-zero"); + static_assert(WindowMs % BucketMs == 0, "WindowMs must be a whole number of buckets"); + static_assert(WindowMs / BucketMs + 1 <= UINT8_MAX, "too many buckets"); + + // One more than WindowMs needs, so the oldest is never recycled while still in the window. + static constexpr uint8_t BUCKETS = WindowMs / BucketMs + 1; + + public: + /// Record events happening now. + void add(uint32_t events = 1) + { + advance(); + counts[head] += events; + } + + /// Events within the last WindowMs. + uint32_t sum() + { + advance(); + + // The current bucket plus every fully enclosed one: WindowMs - BucketMs, plus however + // far the current bucket has filled. + uint32_t total = 0; + for (uint8_t age = 0; age <= BUCKETS - 2; age++) + total += counts[(head + BUCKETS - age) % BUCKETS]; + + // The oldest bucket covers the remainder. Counting only the part still inside is what + // holds the total at exactly WindowMs as the current bucket fills. + uint32_t elapsed = Time::getMillis() - bucketStartMs; + uint32_t inWindow = elapsed < BucketMs ? BucketMs - elapsed : 0; + // 64-bit: the product overflows 32 bits once a bucket holds more than 2^32 / BucketMs + // events, which is only ~14k at a 5 minute width. + total += (uint32_t)(((uint64_t)counts[(head + 1) % BUCKETS] * inWindow + BucketMs / 2) / BucketMs); + return total; + } + + void reset() + { + memset(counts, 0, sizeof(counts)); + head = 0; + bucketStartMs = Time::getMillis(); + started = true; + } + + private: + void advance() + { + if (!started) { + reset(); + return; + } + if (!Throttle::hasElapsed(bucketStartMs, BucketMs)) + return; + + uint32_t steps = (Time::getMillis() - bucketStartMs) / BucketMs; + if (steps >= BUCKETS) { // idle longer than the ring, nothing survives + reset(); + return; + } + bucketStartMs += steps * BucketMs; + while (steps--) { + head = (head + 1) % BUCKETS; + counts[head] = 0; + } + } + + uint32_t counts[BUCKETS] = {}; + uint32_t bucketStartMs = 0; + uint8_t head = 0; + // Explicit rather than bucketStartMs == 0, which is a real time value after a rollover. + bool started = false; +}; diff --git a/test/test_rolling_counter/test_main.cpp b/test/test_rolling_counter/test_main.cpp new file mode 100644 index 000000000..e8704e0c3 --- /dev/null +++ b/test/test_rolling_counter/test_main.cpp @@ -0,0 +1,142 @@ +// Unit tests for RollingCounter. The case that matters is the span sum() covers: an +// under-sized ring reports WindowMs - BucketMs, and counting the edge bucket whole reports more. +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "modules/Telemetry/Sensor/RollingCounter.h" +#include + +static constexpr uint32_t kMinute = 60UL * 1000; +static constexpr uint32_t kWindow = 60 * kMinute; +static constexpr uint32_t kBucket = 5 * kMinute; + +using Counter = RollingCounter; + +void setUp() +{ + Time::setTestMillis(1000); +} + +void tearDown() +{ + Time::useRealClock(); +} + +// Everything added inside the window is still counted at the far edge. +void test_counts_within_window() +{ + Counter c; + for (int i = 0; i < 10; i++) { + c.add(); + Time::advanceTestMillis(kMinute); + } + TEST_ASSERT_EQUAL_UINT32(10, c.sum()); +} + +// Expiry is exact to one bucket, not to the event: nothing records where inside a bucket an event +// fell, so it is wholly counted to WindowMs, wholly gone by WindowMs + BucketMs, decaying between. +void test_expires_within_one_bucket_of_the_hour() +{ + Counter c; + c.add(100); + + Time::advanceTestMillis(kWindow - kMinute); + TEST_ASSERT_EQUAL_UINT32(100, c.sum()); // 59 minutes old, wholly inside + + uint32_t previous = 100; + for (int i = 0; i < 7; i++) { // walk a full bucket past the hour + Time::advanceTestMillis(kMinute); + uint32_t current = c.sum(); + TEST_ASSERT_LESS_OR_EQUAL_UINT32(previous, current); // decays, never grows back + previous = current; + } + TEST_ASSERT_EQUAL_UINT32(0, previous); +} + +// The span must not shrink to 55 minutes as the current bucket fills. One event per +// minute for well over an hour means a correct 60-minute window always holds 60. +void test_span_stays_sixty_minutes() +{ + Counter c; + for (int i = 0; i < 60; i++) { + c.add(); + Time::advanceTestMillis(kMinute); + } + // Steady state: sample at every minute across two more bucket widths. A ring that + // under-covers dips to 55, one that over-covers climbs to 65. + for (int i = 0; i < 20; i++) { + TEST_ASSERT_EQUAL_UINT32(60, c.sum()); + c.add(); + Time::advanceTestMillis(kMinute); + } +} + +// Buckets must not be recycled while any part of them is still inside the window. +void test_bucket_not_dropped_early() +{ + Counter c; + c.add(7); // lands in the first bucket + + // Step to just under an hour in bucket-sized hops; the batch stays counted throughout. + for (uint32_t elapsed = 0; elapsed + kBucket < kWindow; elapsed += kBucket) { + Time::advanceTestMillis(kBucket); + TEST_ASSERT_EQUAL_UINT32(7, c.sum()); + } +} + +// Going quiet for longer than the ring leaves nothing behind, and the counter still works. +void test_long_idle_gap() +{ + Counter c; + c.add(3); + Time::advanceTestMillis(5 * kWindow); + TEST_ASSERT_EQUAL_UINT32(0, c.sum()); + + c.add(2); + TEST_ASSERT_EQUAL_UINT32(2, c.sum()); +} + +// A burst far larger than the bucket count still costs the same fixed memory, and is carried +// whole while it is inside the window. +void test_burst_survives_whole() +{ + Counter c; + c.add(50000); + Time::advanceTestMillis(kWindow - kMinute); + TEST_ASSERT_EQUAL_UINT32(50000, c.sum()); +} + +// Weighting the edge bucket must not overflow: 50000 * 240000 exceeds 32 bits, and a 32-bit +// product wraps to 11367 instead of 40000. Four of the bucket's five minutes are still inside. +void test_large_burst_at_window_edge() +{ + Counter c; + c.add(50000); + Time::advanceTestMillis(kWindow + kMinute); + TEST_ASSERT_EQUAL_UINT32(40000, c.sum()); +} + +void test_reset_clears() +{ + Counter c; + c.add(5); + c.reset(); + TEST_ASSERT_EQUAL_UINT32(0, c.sum()); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_counts_within_window); + RUN_TEST(test_expires_within_one_bucket_of_the_hour); + RUN_TEST(test_span_stays_sixty_minutes); + RUN_TEST(test_bucket_not_dropped_early); + RUN_TEST(test_long_idle_gap); + RUN_TEST(test_burst_survives_whole); + RUN_TEST(test_large_burst_at_window_edge); + RUN_TEST(test_reset_clears); + exit(UNITY_END()); +} + +void loop() {} From bb6a81f1e9ffb0627373ddacae9024d5d419f8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 19 Aug 2026 15:50:49 +0000 Subject: [PATCH 090/109] Pass framebuffer rotation through DisplayDriverConfig (#11534) * tftSetup: pass framebuffer rotation via DisplayDriverConfig Replaces the MESHTASTIC_FB_ROTATION environment variable with DisplayDriverConfig::rotation(), which device-ui reads in FBDriver::create(const DisplayDriverConfig &). * tftSetup: carry framebuffer rotation in the panel config Use the DisplayDriverConfig builder with panel_config_t::offset_rotation instead of a dedicated rotation setter. Width and height fall back to the device-ui defaults when the yaml does not set them. * tftSetup: pass the framebuffer panel config unfiltered Take Display.Width, Display.Height and Display.OffsetRotate straight from the portduino config, like the CUSTOM_TFT branch does. --- src/graphics/tftSetup.cpp | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp index 460b2f86e..8e971cb60 100644 --- a/src/graphics/tftSetup.cpp +++ b/src/graphics/tftSetup.cpp @@ -15,8 +15,6 @@ #endif #if defined(ARCH_PORTDUINO) || !defined(HAS_FREE_RTOS) -#include -#include #include #endif @@ -351,15 +349,11 @@ void tftSetup(void) #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); - else - displayConfig.device(DisplayDriverConfig::device_t::FB); + displayConfig.device(DisplayDriverConfig::device_t::FB) + .panel(DisplayDriverConfig::panel_config_t{.type = panels[portduino_config.displayPanel], + .panel_width = (uint16_t)portduino_config.displayWidth, + .panel_height = (uint16_t)portduino_config.displayHeight, + .offset_rotation = (uint8_t)portduino_config.displayOffsetRotate}); } else #endif { From 9c027a24ea21f539a23ebb4495288e85a4621f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 19 Aug 2026 15:52:23 +0000 Subject: [PATCH 091/109] Toggle GPS and buzzer together on the ThinkNode M8 function button double click (#11551) * Toggle GPS and buzzer together on the ThinkNode M8 function button double click * Shorten the comments added with the ThinkNode M8 double click toggle * Only sync the buzzer when the GPS mode actually toggles, and unmute before the tone plays --- src/input/ButtonThread.cpp | 13 ++++--------- src/input/InputBroker.cpp | 2 +- src/input/InputBroker.h | 1 + src/modules/SystemCommandsModule.cpp | 22 +++++++++++++++++----- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/input/ButtonThread.cpp b/src/input/ButtonThread.cpp index 29f56dba5..251311df7 100644 --- a/src/input/ButtonThread.cpp +++ b/src/input/ButtonThread.cpp @@ -102,7 +102,9 @@ bool ButtonThread::initButton(const ButtonConfig &config) #endif userButton.setPressMs(_longPressTime); - if (screen) { + // The 20ms window a screen normally gets closes before a second click can land, so boards + // binding double or multi click need the full one. + if (screen && _doublePress == INPUT_BROKER_NONE && _triplePress == INPUT_BROKER_NONE) { userButton.setClickMs(20); } else { userButton.setClickMs(BUTTON_CLICK_MS); @@ -225,15 +227,8 @@ int32_t ButtonThread::runOnce() break; } - case BUTTON_EVENT_DOUBLE_PRESSED: { // not wired in if screen detected + case BUTTON_EVENT_DOUBLE_PRESSED: { // only on boards binding ButtonConfig::doublePress 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 429ecd7ea..7b0f830c5 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -382,7 +382,7 @@ void InputBroker::Init() userConfig.singlePress = INPUT_BROKER_SEND_PING; userConfig.longPress = INPUT_BROKER_SHUTDOWN; userConfig.longPressTime = 5000; - userConfig.doublePress = INPUT_BROKER_GPS_TOGGLE; + userConfig.doublePress = INPUT_BROKER_PRIVACY_TOGGLE; UserButtonThread->initButton(userConfig); } #else diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index 975c9d9f4..e30e84ff7 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -28,6 +28,7 @@ enum input_broker_event { INPUT_BROKER_FACTORY_RST = 0x9a, INPUT_BROKER_SHUTDOWN = 0x9b, INPUT_BROKER_GPS_TOGGLE = 0x9e, + INPUT_BROKER_PRIVACY_TOGGLE = 0x9f, // GPS and buzzer off together, and back on together INPUT_BROKER_SEND_PING = 0xaf, INPUT_BROKER_FN_F1 = 0xf1, INPUT_BROKER_FN_F2 = 0xf2, diff --git a/src/modules/SystemCommandsModule.cpp b/src/modules/SystemCommandsModule.cpp index 5c4babb19..1b3194290 100644 --- a/src/modules/SystemCommandsModule.cpp +++ b/src/modules/SystemCommandsModule.cpp @@ -86,18 +86,30 @@ int SystemCommandsModule::handleInputEvent(const InputEvent *event) } switch (event->inputEvent) { - // GPS + // GPS, on its own or together with the buzzer case INPUT_BROKER_GPS_TOGGLE: + case INPUT_BROKER_PRIVACY_TOGGLE: #if !MESHTASTIC_EXCLUDE_GPS if (gps) { - if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED && - config.position.fixed_position == false) { + const bool wasEnabled = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED; + // toggleGpsMode() only moves between ENABLED and DISABLED, so leave the buzzer alone otherwise. + const bool withBuzzer = event->inputEvent == INPUT_BROKER_PRIVACY_TOGGLE && + (wasEnabled || config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_DISABLED); + if (wasEnabled && config.position.fixed_position == false) { nodeDB->clearLocalPosition(); nodeDB->saveToDisk(); } + if (withBuzzer) // unmute first, so the confirmation beep is audible in both directions + config.device.buzzer_mode = meshtastic_Config_DeviceConfig_BuzzerMode_ALL_ENABLED; gps->toggleGpsMode(); - const char *msg = - (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) ? "GPS Enabled" : "GPS Disabled"; + const bool nowEnabled = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED; + if (withBuzzer) { + config.device.buzzer_mode = nowEnabled ? meshtastic_Config_DeviceConfig_BuzzerMode_ALL_ENABLED + : meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED; + nodeDB->saveToDisk(SEGMENT_CONFIG); + } + const char *msg = withBuzzer ? (nowEnabled ? "GPS + Buzzer\nEnabled" : "GPS + Buzzer\nDisabled") + : (nowEnabled ? "GPS Enabled" : "GPS Disabled"); IF_SCREEN(screen->forceDisplay(); screen->showSimpleBanner(msg, 3000);) } #endif From 80f8611e65637d93ca00fdff6eeed416e3d4dc1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Wed, 19 Aug 2026 17:05:34 +0000 Subject: [PATCH 092/109] feat(variants): add Seeed Wio Tracker L1 Pro 1W (#11542) * fix(sx126x): allow boards to opt out of the PA optimization table Boards driving an external PA can define SX126X_NO_POWER_OPTIMIZATION_TABLE to use the fixed PA config instead of RadioLib's table, which is tuned for a bare SX126x. Default behaviour is unchanged. init() applies the fixed config after begin(), which programs power through the table. * feat(variants): add Seeed Wio Tracker L1 Pro 1W nRF52840 + SX1262 with a 1 W external PA, L76K GNSS, SH1106 OLED. Uses hw_model 144 (meshtastic/protobufs#1038), opts into SX126X_NO_POWER_OPTIMIZATION_TABLE and declares SX126X_MAX_POWER explicitly. The PA gain table is indexed by SX1262 output power in dBm. Requires protobufs#1038 and a protobuf regen before it builds. * chore(deps): bump RadioLib to 510e00cf Carries the current LR11x0 and LR2021 fixes. * fix(variants): correct L1 Pro 1W QSPI pins and clean up comments PIN_QSPI_* are logical pin indices. The QSPI flash sits at D19-D24 in variant.cpp, but the defines carried D21-D26 from seeed_solar_node, where that block does start at D21. D25 and D26 are trackball pins. Also replaces mis-encoded characters in the pin comments and drops the migration note, which referenced a private repo path and a stale PINS_COUNT. * fix(variants): move L1 Pro 1W out of the per-PR build matrix board_level = pr is the high-attention tier that builds on every PR. This board belongs with the mainline set, which uses board_level = release. --- boards/seeed_wio_tracker_L1_Pro_1W.json | 57 +++++ platformio.ini | 2 +- src/configuration.h | 9 +- src/mesh/SX126xInterface.cpp | 12 +- src/platform/nrf52/architecture.h | 2 + .../platformio.ini | 22 ++ .../seeed_wio_tracker_L1_Pro_1W/variant.cpp | 93 ++++++++ .../seeed_wio_tracker_L1_Pro_1W/variant.h | 201 ++++++++++++++++++ 8 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 boards/seeed_wio_tracker_L1_Pro_1W.json create mode 100644 variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/platformio.ini create mode 100644 variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.cpp create mode 100644 variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.h diff --git a/boards/seeed_wio_tracker_L1_Pro_1W.json b/boards/seeed_wio_tracker_L1_Pro_1W.json new file mode 100644 index 000000000..f87074a01 --- /dev/null +++ b/boards/seeed_wio_tracker_L1_Pro_1W.json @@ -0,0 +1,57 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v7.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DARDUINO_MDBT50Q_RX -DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + ["0x2886", "0x1668"], + ["0x2886", "0x1667"] + ], + "usb_product": "TRACKER L1 Pro 1W", + "mcu": "nrf52840", + "variant": "seeed_wio_tracker_L1_Pro_1W", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "7.3.0", + "sd_fwid": "0x0123" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": ["bluetooth"], + "debug": { + "jlink_device": "nRF52840_xxAA", + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52840-mdk-rs" + }, + "frameworks": ["arduino"], + "name": "seeed_wio_tracker_L1_Pro_1W", + "upload": { + "maximum_ram_size": 248832, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": [ + "jlink", + "nrfjprog", + "nrfutil", + "stlink", + "cmsis-dap", + "blackmagic" + ], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://www.seeedstudio.com/Wio-Tracker-L1-Pro-p-6454.html", + "vendor": "Seeed Studio" +} diff --git a/platformio.ini b/platformio.ini index 74916381f..555aa376d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -132,7 +132,7 @@ lib_deps = [radiolib_base] lib_deps = # renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib - https://github.com/jgromes/RadioLib/archive/6d8934836678d8894e3d556550475b37dce3e2b6.zip + https://github.com/jgromes/RadioLib/archive/510e00cfb05bbc3c2b7b524262785454944adb6e.zip [device-ui_base] lib_deps = diff --git a/src/configuration.h b/src/configuration.h index 5c9cdf956..b55ce262f 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -205,6 +205,13 @@ along with this program. If not, see . #define TX_GAIN_LORA 7, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8 #endif +#ifdef SEEED_WIO_TRACKER_L1_PRO_1W +// Indexed by SX1262 output power in dBm, matching RadioInterface::limitPower(). +// TODO: verify against measured output. +#define NUM_PA_POINTS 22 +#define TX_GAIN_LORA 10, 10, 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10 +#endif + // Default system gain to 0 if not defined #ifndef NUM_PA_POINTS #define NUM_PA_POINTS 1 @@ -234,7 +241,7 @@ along with this program. If not, see . #define SSD1306_ADDRESS_L 0x3C // Addr = 0 #define SSD1306_ADDRESS_H 0x3D // Addr = 1 -#if defined(SEEED_WIO_TRACKER_L1) && !defined(SEEED_WIO_TRACKER_L1_EINK) +#if (defined(SEEED_WIO_TRACKER_L1) || defined(SEEED_WIO_TRACKER_L1_PRO_1W)) && !defined(SEEED_WIO_TRACKER_L1_EINK) #define SSD1306_ADDRESS SSD1306_ADDRESS_H #define USE_SH1106 #endif diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 750ebbbef..2400a8e03 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -176,6 +176,12 @@ template bool SX126xInterface::init() if (res == RADIOLIB_ERR_NONE) res = lora.setCRC(RADIOLIB_SX126X_LORA_CRC_ON); +#ifdef SX126X_NO_POWER_OPTIMIZATION_TABLE + // begin() applied the optimization table; re-apply the fixed PA config. + if (res == RADIOLIB_ERR_NONE) + res = lora.setOutputPower(power, false); +#endif + if (res == RADIOLIB_ERR_NONE) startReceive(); // start receiving @@ -226,7 +232,11 @@ template bool SX126xInterface::reconfigure() if (power < -9) power = -9; +#ifdef SX126X_NO_POWER_OPTIMIZATION_TABLE + err = lora.setOutputPower(power, false); // external PA: fixed PA config +#else err = lora.setOutputPower(power); +#endif if (err != RADIOLIB_ERR_NONE) { // Don't abort: this power is operator config (tx_power/SX126X_MAX_POWER); a value above the // driver's max would crash the daemon before reloadConfig() persists. Flag it and keep prior power. @@ -525,4 +535,4 @@ template void SX126xInterface::setTransmitEnable(bool txon) #endif } -#endif \ No newline at end of file +#endif diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index 0193eeb5c..e9abbbc29 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -137,6 +137,8 @@ #define HW_VENDOR meshtastic_HardwareModel_HELTEC_MESH_POCKET #elif defined(SEEED_WIO_TRACKER_L1_EINK) #define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1_EINK +#elif defined(SEEED_WIO_TRACKER_L1_PRO_1W) +#define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1_PRO_1W #elif defined(SEEED_WIO_TRACKER_L1) #define HW_VENDOR meshtastic_HardwareModel_SEEED_WIO_TRACKER_L1 #elif defined(HELTEC_MESH_SOLAR) diff --git a/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/platformio.ini b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/platformio.ini new file mode 100644 index 000000000..772c0152e --- /dev/null +++ b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/platformio.ini @@ -0,0 +1,22 @@ +[env:seeed_wio_tracker_L1_Pro_1W] +custom_meshtastic_hw_model = 144 +custom_meshtastic_hw_model_slug = SEEED_WIO_TRACKER_L1_PRO_1W +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Seeed Wio Tracker L1 Pro 1W +custom_meshtastic_images = wio_tracker_l1_case.svg +custom_meshtastic_tags = Seeed +custom_meshtastic_requires_dfu = true + +board = seeed_wio_tracker_L1_Pro_1W +board_level = release +extends = nrf52840_base +build_flags = ${nrf52840_base.build_flags} + -I variants/nrf52840/seeed_wio_tracker_L1_Pro_1W + -D SEEED_WIO_TRACKER_L1_PRO_1W + -I src/platform/nrf52/softdevice + -I src/platform/nrf52/softdevice/nrf52 +board_build.ldscript = src/platform/nrf52/nrf52840_s140_v7.ld +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/seeed_wio_tracker_L1_Pro_1W> +debug_tool = jlink diff --git a/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.cpp b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.cpp new file mode 100644 index 000000000..b957db314 --- /dev/null +++ b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.cpp @@ -0,0 +1,93 @@ +/* + * Digital pin mapping (logical Dx to nRF Port.Pin) and initVariant() for the + * Seeed Wio Tracker L1 Pro 1W. + */ + +#include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +/** + * @brief Digital pin to GPIO port/pin mapping table + * + * Format: Logical Pin (Dx) -> nRF Port.Pin (Px.xx) + */ +extern "C" { +const uint32_t g_ADigitalPinMap[] = { + // D0 .. D10 - Peripheral control pins + 41, // D0 P1.09 GNSS_WAKEUP + 7, // D1 P0.07 LORA_DIO1 + 39, // D2 P1.07 LORA_RESET + 42, // D3 P1.10 LORA_BUSY + 46, // D4 P1.14 LORA_CS + 29, // D5 P0.29 (AIN5) LORA_VDET, Pro 1W uses P0.29 not P1.08 + 27, // D6 P0.27 GNSS_TX + 26, // D7 P0.26 GNSS_RX + 30, // D8 P0.30 SPI_SCK + 3, // D9 P0.03 SPI_MISO + 28, // D10 P0.28 SPI_MOSI + + // D11-D12 - LED outputs / Buzzer + 33, // D11 P1.01 Mesh_LED (orange), Pro 1W uses P1.01 not P1.15 + 32, // D12 P1.00 Buzzer, shared with the LED_BLUE macro alias + + // D13 - User input + 8, // D13 P0.08 User Button + + // D14-D15 - OLED I2C0 + 6, // D14 P0.06 OLED SDA + 5, // D15 P0.05 OLED SCL + + // D16 - Battery voltage ADC + 31, // D16 P0.31 VBAT_ADC + + // D17-D18 - Grove I2C1 + 43, // D17 P1.11 GROVE SCL + 44, // D18 P1.12 GROVE SDA + + // D19-D24 - QSPI Flash + 21, // D19 P0.21 QSPI_SCK + 25, // D20 P0.25 QSPI_CSN + 20, // D21 P0.20 QSPI_SIO_0 + 24, // D22 P0.24 QSPI_SIO_1 + 22, // D23 P0.22 QSPI_SIO_2 + 23, // D24 P0.23 QSPI_SIO_3 + + // D25-D29 - Trackball + 36, // D25 TB_UP + 12, // D26 TB_DOWN + 11, // D27 TB_LEFT + 35, // D28 TB_RIGHT + 37, // D29 TB_PRESS + + // D30 - Battery divider enable + 4, // D30 P0.04 BAT_CTL + + // D31-D33 - Pro 1W only + 13, // D31 P0.13 BOOST_EN (Grove 5V Boost) + 47, // D32 P1.15 nRF_Sig_Charge_State (BQ25616 STAT) + 14, // D33 P0.14 LORA_PWR_EN (SX1262 + 1 W PA LDO) +}; +} + +void initVariant() +{ + pinMode(PIN_QSPI_CS, OUTPUT); + digitalWrite(PIN_QSPI_CS, HIGH); + + // Enable battery divider for ADC sampling + pinMode(BAT_READ, OUTPUT); + digitalWrite(BAT_READ, HIGH); + + // Grove 5V Boost: default OFF to save power at boot / shipping state. + // Apps that need Grove 5V can re-enable by writing BOOST_EN_ACTIVE to PIN_BOOST_EN. + pinMode(PIN_BOOST_EN, OUTPUT); + digitalWrite(PIN_BOOST_EN, !BOOST_EN_ACTIVE); + + // LED: default off + pinMode(PIN_LED1, OUTPUT); + digitalWrite(PIN_LED1, LOW); + // PIN_LED2 (D12) shares the buzzer pin; ExternalNotification configures it. + // Forcing it LOW here would prevent PWM output. +} diff --git a/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.h b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.h new file mode 100644 index 000000000..75bfe887b --- /dev/null +++ b/variants/nrf52840/seeed_wio_tracker_L1_Pro_1W/variant.h @@ -0,0 +1,201 @@ +#ifndef _SEEED_TRACKER_L1_PRO_1W_H_ +#define _SEEED_TRACKER_L1_PRO_1W_H_ + +#include "WVariant.h" + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Clock Configuration +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define VARIANT_MCK (64000000ul) // Master clock frequency +#define USE_LFXO // 32.768kHz crystal for LFCLK + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Pin Capacity Definitions +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define PINS_COUNT (34u) // Total GPIO pins (D0-D33) +#define NUM_DIGITAL_PINS (34u) // Digital I/O pins +#define NUM_ANALOG_INPUTS (8u) // Analog inputs (A0-A5 + VBAT + AREF) +#define NUM_ANALOG_OUTPUTS (0u) + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// LED Configuration +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Only one real LED (orange, P1.01). PIN_LED2/LED_BLUE/LED_CONN alias D12 (buzzer) for +// ABI compatibility with app code; they drive no hardware LED. +#define PIN_LED1 (11) // Mesh_LED orange P1.01 +#define PIN_LED2 (12) // buzzer pin (no real LED on L1 Pro 1W) + +#define LED_GREEN PIN_LED1 +#define LED_BLUE PIN_LED2 +#define LED_STATE_ON 1 // State when LED is lit + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Button Configuration +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define CANCEL_BUTTON_PIN D13 // Program Button +#define CANCEL_BUTTON_ACTIVE_LOW true +#define CANCEL_BUTTON_ACTIVE_PULLUP false + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Digital Pin Mapping (D0-D32) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// D5 / D11 / D31 / D32 are the Pro 1W V1.0 hardware-revision pins +#define D0 0 // P1.09 GNSS_WAKEUP/IO0 +#define D1 1 // P0.07 LORA_DIO1 +#define D2 2 // P1.07 LORA_RESET +#define D3 3 // P1.10 LORA_BUSY +#define D4 4 // P1.14 LORA_CS +#define D5 5 // P0.29 LORA_VDET (AIN5), replaces the stock L1 LORA_SW on P1.08 +#define D6 6 // P0.27 GNSS_TX +#define D7 7 // P0.26 GNSS_RX +#define D8 8 // P0.30 SPI_SCK +#define D9 9 // P0.03 SPI_MISO +#define D10 10 // P0.28 SPI_MOSI +#define D11 11 // P1.01 Mesh_LED (orange) +#define D12 12 // P1.00 Buzzer +#define D13 13 // P0.08 User Button +#define D14 14 // P0.06 OLED SDA +#define D15 15 // P0.05 OLED SCL +#define D16 16 // P0.31 VBAT_ADC +#define D17 17 // P1.11 Grove I2C1 SCL +#define D18 18 // P1.12 Grove I2C1 SDA +#define D31 31 // P0.13 BOOST_EN (Grove 5V Boost enable), new on Pro 1W +#define D32 32 // P1.15 nRF_Sig_Charge_State (BQ25616 STAT), new on Pro 1W +#define D33 33 // P0.14 LORA_PWR_EN (SX1262 + 1 W PA LDO), new on Pro 1W + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Analog Pin Definitions +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define PIN_A0 0 // P0.02 Analog Input 0 +#define PIN_A1 1 // P0.03 Analog Input 1 +#define PIN_A2 2 // P0.28 Analog Input 2 +#define PIN_A3 3 // P0.29 Analog Input 3 +#define PIN_A4 4 // P0.04 Analog Input 4 +#define PIN_A5 5 // P0.05 Analog Input 5 +#define PIN_VBAT D16 // P0.31 Battery voltage sense + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Communication Interfaces +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// I2C Configuration +#define PIN_WIRE_SDA D14 // P0.06 OLED SDA +#define PIN_WIRE_SCL D15 // P0.05 OLED SCL +#define WIRE_INTERFACES_COUNT 2 +#define PIN_WIRE1_SDA D18 +#define PIN_WIRE1_SCL D17 +#define I2C_NO_RESCAN + +static const uint8_t SDA = PIN_WIRE_SDA; +static const uint8_t SCL = PIN_WIRE_SCL; + +#define HAS_SCREEN 1 +#define USE_SSD1306 1 + +// SPI Configuration (SX1262) +#define SPI_INTERFACES_COUNT 1 +#define PIN_SPI_MISO 9 // P0.03 (D9) +#define PIN_SPI_MOSI 10 // P0.28 (D10) +#define PIN_SPI_SCK 8 // P0.30 (D8) + +// SX1262 LoRa Module Pins +#define USE_SX1262 +#define SX126X_CS D4 // Chip select +#define SX126X_DIO1 D1 // Digital IO 1 (Interrupt) +#define SX126X_BUSY D3 // Busy status +#define SX126X_RESET D2 // Reset control +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 // TCXO supply voltage +#define SX126X_RXEN RADIOLIB_NC +#define SX126X_TXEN RADIOLIB_NC +#define SX126X_DIO2_AS_RF_SWITCH // DIO2 controls antenna switch (no external RXEN/TXEN) + +// SX1262 drives a 1 W external PA; use the fixed PA config, not RadioLib's table. +#define SX126X_NO_POWER_OPTIMIZATION_TABLE + +// Chip-side drive ceiling; limitPower() already subtracted the PA gain. TODO: verify on bench. +#define SX126X_MAX_POWER 22 + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Power Management +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define BAT_READ 30 // D30 = P0.04 Battery divider enable (BAT_CTL) on signal board. +#define ADC_CTRL BAT_READ +#define ADC_CTRL_ENABLED HIGH +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define ADC_MULTIPLIER 2.0 +#define BATTERY_PIN PIN_VBAT +#define AREF_VOLTAGE 3.6 +// We rely on the nrf52840 USB controller to tell us if we are hooked to a power supply +#define NRF_APM + +// BQ25616 single-wire charge status (Pro 1W) +#define PIN_BOOST_EN D31 // D31 / P0.13, Grove 5V Boost enable +#define EXT_CHRG_DETECT D32 // D32 / P1.15, BQ25616 STAT +#define EXT_CHRG_DETECT_VALUE LOW // 0 = charging, 1 = full / charger sleep +#define BOOST_EN_ACTIVE HIGH // HIGH enables Grove 5V Boost + +// External LDO enable for the SX1262 + 1 W PA. D33 rather than raw GPIO 14 because +// g_ADigitalPinMap[14] is D14 (OLED SDA). init() drives it HIGH; deep sleep does not clear it. +#define LORA_PWR_EN D33 +#define SX126X_POWER_EN LORA_PWR_EN + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// GPS L76KB +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define GPS_L76K +#ifdef GPS_L76K +#define GPS_TX_PIN D6 // P0.26 - This is data from the MCU +#define GPS_RX_PIN D7 // P0.27 - This is data from the GNSS +#define HAS_GPS 1 +#define GPS_BAUDRATE 9600 +#define GPS_THREAD_INTERVAL 50 +#define PIN_SERIAL1_RX GPS_RX_PIN +#define PIN_SERIAL1_TX GPS_TX_PIN + +#define PIN_GPS_STANDBY D0 +#endif + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// On-board QSPI Flash +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Logical pin indices; the QSPI block is at D19-D24 in variant.cpp. +#define PIN_QSPI_SCK (19) +#define PIN_QSPI_CS (20) +#define PIN_QSPI_IO0 (21) +#define PIN_QSPI_IO1 (22) +#define PIN_QSPI_IO2 (23) +#define PIN_QSPI_IO3 (24) + +#define EXTERNAL_FLASH_DEVICES P25Q16H +#define EXTERNAL_FLASH_USE_QSPI + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Buzzer +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define PIN_BUZZER D12 // P1.00, pwm output + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Trackball +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#define CANNED_MESSAGE_ADD_CONFIRMATION 1 + +#define HAS_TRACKBALL 1 +#define TB_UP 25 +#define TB_DOWN 26 +#define TB_LEFT 27 +#define TB_RIGHT 28 +#define TB_PRESS 29 +#define TB_DIRECTION FALLING + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Compatibility Definitions +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +#ifdef __cplusplus +extern "C" { +#endif +#define PIN_SERIAL2_RX (-1) +#define PIN_SERIAL2_TX (-1) +#ifdef __cplusplus +} +#endif + +#endif // _SEEED_TRACKER_L1_PRO_1W_H_ From abd3348790219ca3e8690f7ee7991930d08496a6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:42:23 -0500 Subject: [PATCH 093/109] chore(deps): update meshtastic/device-ui digest to 44b86e1 (#11552) 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 555aa376d..5f43d64d6 100644 --- a/platformio.ini +++ b/platformio.ini @@ -137,7 +137,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/adfbd3811a53b6aed0649c8d8f078118c042a407.zip + https://github.com/meshtastic/device-ui/archive/44b86e1b6842e9c67b1ed935753304b0313605da.zip custom_sdkconfig = # CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y From 90a6dec3f3a730c48a61142e416ccb32082ac0f2 Mon Sep 17 00:00:00 2001 From: Clive Blackledge Date: Thu, 20 Aug 2026 12:19:57 +0000 Subject: [PATCH 094/109] fix(NodeDB): require a full 32-byte key when demoting to the warm tier (#11431) * fix(NodeDB): require a full 32-byte key when demoting to the warm tier meshtastic_User.public_key is a wire `bytes` field with max_size 32, so any size in 0..32 decodes off the air, and nothing validates it on ingress: NodeInfoModule hands the decoded User straight to NodeDB::updateUser, whose PKI gates are all `== 32` and so fall through for a partial key, and TypeConversions::CopyUserToNodeInfoLite then stores it with the short size. demoteOldestHotNodesToWarm() admitted that partial key into the warm tier on a `size > 0` gate. WarmNodeEntry has no length field - it distinguishes "has a key" from "no key" purely by all-zero - so N real bytes plus 32-N zeros become indistinguishable from a genuine key. copyPublicKeyAuthoritative() then hands that fabricated key back with size = 32 and reports it AUTHORITATIVE, and re-admission writes size = 32 into the hot store. From then on updateUser's key pin permanently rejects the node's real NodeInfo, and DMs to it are encrypted to a key nobody holds. Require a full 32-byte key, so a partial one is absorbed as "no key" (nullptr) rather than as a truncated one. WarmNodeStore::place() already treats a null key as keyless and clears the slot's stale key when repurposing it. This aligns the site with its two siblings, which both already gate on `size == 32` (the purge path in cleanupMeshDB and the runtime eviction in getOrCreateMeshNode). The ingress gap - updateUser accepting a 1..31-byte key at all - is a separate, larger change and is left for its own review. Co-Authored-By: Claude Opus 5 * docs(NodeDB): shorten warm-demotion comment to two lines Repo guideline (AGENTS.md): keep code comments to one or two lines. Retains the non-obvious invariant - warm entries have no key length field - and drops the restated detail. Co-Authored-By: Claude Opus 5 * test(NodeDB): cover short-key demotion into the warm tier A warm record stores 32 raw key bytes with no length field, so a partial hot-store key is indistinguishable from a real one once demoted. The public_key.size == 32 gate in demoteOldestHotNodesToWarm() is what keeps a truncated key from being laundered into a full-looking warm key, but nothing exercised it. test_migration_dropsShortKeyOnDemotion overflows the hot store with one node carrying a 31-byte key and asserts it lands as a keyless placeholder while a genuine 32-byte key still survives. push() grows a keySize parameter to seed the partial key, and clearWarm() gives the test an empty warm tier, which it needs because the warm store outlives setUp() and a prior run's warm.dat. Verified to discriminate: with the size gate reverted to size > 0 the new test fails on "a 31-byte key must not be demoted as if it were a full key", and passes again once restored. Co-Authored-By: Claude Opus 5 * test(NodeDB): assert the keyless placeholder carries last_heard The test only proved a warm metadata row survived the demotion, not that the placeholder does the job the nullptr is there for, which is preserving last_heard when the key is dropped. Asserting the value needed the seeds fixing first. Warm entries pack role, protected category and the xeddsa flag into the low 7 bits of last_heard (WARM_TIME_MASK is 0xFFFFFF80), so warm time has 128 second granularity and the old seeds of 1, 2, 3 all quantised to 0. They are now multiples of 128, which keeps the demotion ordering identical and makes the values survive the round trip. Real last_heard is epoch seconds, so this is closer to production than the old counter was. Reads the entry through WarmNodeStore::take() rather than getOrCreateMeshNode(), which does not restore last_heard from the warm tier and would have been asserting a path that does not exist. Reported by CodeRabbit on #11431. --------- Co-authored-by: Claude Opus 5 Co-authored-by: Ben Meadors --- src/mesh/NodeDB.cpp | 6 ++-- test/test_nodedb_blocked/test_main.cpp | 45 ++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 41d570f3e..040777857 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2148,9 +2148,9 @@ void NodeDB::demoteOldestHotNodesToWarm() const meshtastic_NodeInfoLite &n = (*meshNodes)[i]; if (n.num == 0) continue; - // Keep the public key if we have one (40 B warm record); keyless nodes - // still get a placeholder so re-admission restores last_heard. - warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role, + // Warm entries carry no key length, so a partial key would be indistinguishable + // from a full one. nullptr keeps the keyless placeholder that restores last_heard. + warmStore.absorb(n.num, n.last_heard, n.public_key.size == 32 ? n.public_key.bytes : nullptr, n.role, warmProtectedCategory(n), nodeInfoLiteHasXeddsaSigned(&n)); // Demotion drops the node from the header table, so drop its satellites // too (the eviction chokepoint) - they'd otherwise orphan until the next diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index 96d392cd8..3825ed5ee 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -29,6 +29,7 @@ class NodeDBTestShim : public NodeDB // Read back the role + protected category the warm tier cached for a node. bool warmMeta(NodeNum n, uint8_t &role, uint8_t &prot) { return warmStore.lookupMeta(n, role, prot); } + bool warmTake(NodeNum n, WarmNodeEntry &out) { return warmStore.take(n, out); } void clearHot() { @@ -36,8 +37,13 @@ class NodeDBTestShim : public NodeDB numMeshNodes = 0; } + // The warm tier outlives setUp() (and a prior run's warm.dat), so a test that + // asserts on a warm row has to start from an empty one. + void clearWarm() { warmStore.clear(); } + + // keySize < 32 seeds a partial key, as a truncated/short NodeInfo would leave behind. void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey, - meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT) + meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT, pb_size_t keySize = 32) { meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; n.num = num; @@ -50,8 +56,8 @@ class NodeDBTestShim : public NodeDB if (withUser) nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_USER_MASK, true); if (withKey) { - n.public_key.size = 32; - memset(n.public_key.bytes, static_cast(num & 0xff), 32); + n.public_key.size = keySize; + memset(n.public_key.bytes, static_cast(num & 0xff), keySize); n.public_key.bytes[0] = 0x01; // ensure non-zero (all-zero == "no key") } meshNodes->push_back(n); @@ -161,6 +167,38 @@ static void test_migration_carriesSignerBitThroughWarm(void) TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasXeddsaSigned(plainBack), "re-admission must not invent the signer bit"); } +// A warm record stores 32 raw key bytes with no length, so a partial hot-store key would be +// indistinguishable from a real one once demoted. It must land as a keyless placeholder instead. +static void test_migration_dropsShortKeyOnDemotion(void) +{ + db->clearWarm(); + db->seedSelf(); + const NodeNum shortKeyNum = 2000 + 3; + const NodeNum fullKeyNum = 2000 + 4; + const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted + // Warm entries steal the low 7 bits of last_heard for role and protected-category metadata + // (WARM_TIME_MASK), so seed multiples of 128 to keep the values representable once demoted. + for (int i = 1; i <= extra; i++) + db->push(2000 + i, /*last_heard=*/(uint32_t)i * 128, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true, + /*withKey=*/true, meshtastic_Config_DeviceConfig_Role_CLIENT, + /*keySize=*/(NodeNum)(2000 + i) == shortKeyNum ? 31 : 32); + + db->runDemote(); + + // Both left the hot store; only the full key is allowed through to the warm tier. + TEST_ASSERT_NULL(db->getMeshNode(shortKeyNum)); + TEST_ASSERT_NULL(db->getMeshNode(fullKeyNum)); + TEST_ASSERT_FALSE_MESSAGE(warmHasKey(shortKeyNum), "a 31-byte key must not be demoted as if it were a full key"); + TEST_ASSERT_TRUE_MESSAGE(warmHasKey(fullKeyNum), "a full 32-byte key still survives demotion"); + + // The short-key node is still held, just keyless, so re-admission restores its last_heard. + uint8_t role = 0xFF, prot = 0xFF; + TEST_ASSERT_TRUE_MESSAGE(db->warmMeta(shortKeyNum, role, prot), "keyless placeholder row must still be present"); + WarmNodeEntry placeholder = {}; + TEST_ASSERT_TRUE_MESSAGE(db->warmTake(shortKeyNum, placeholder), "placeholder must be readable from the warm tier"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(3u * 128, warmTimeOf(placeholder), "the keyless placeholder must carry last_heard"); +} + // Favourite handling: a favourite is never the eviction victim, even when it is // the oldest node in a full hot store. static void test_eviction_preservesFavorite(void) @@ -290,6 +328,7 @@ NDB_TEST_ENTRY void setup() RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf); RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm); RUN_TEST(test_migration_carriesSignerBitThroughWarm); + RUN_TEST(test_migration_dropsShortKeyOnDemotion); RUN_TEST(test_eviction_preservesFavorite); RUN_TEST(test_eviction_prefersCurrentBootStampOverPost2038Epoch); RUN_TEST(test_ignored_survivesEvictionAndCleanup); From 389559bddb104e3c9b8cf337dffb2c9d3ec2f71c Mon Sep 17 00:00:00 2001 From: Clive Blackledge Date: Thu, 20 Aug 2026 12:23:02 +0000 Subject: [PATCH 095/109] fix(NodeDB): re-derive my_node_num when ensurePkiKeys() mints the identity keypair (#11426) * fix(pki): re-derive NodeNum when setting a region mints the identity key A node's mesh address is derived from its identity key: my_node_num == crc32Buffer(config.security.public_key.bytes, 32) NodeDB::createNewIdentity() is what establishes that, and NodeDB:: generateCryptoKeyPair() is the only thing that called it. CryptoEngine::ensurePkiKeys() generates or re-derives the keypair and writes security.public_key, security.private_key and user.public_key - but never re-derives my_node_num. Boot-time keygen is suppressed while the LoRa region is UNSET (generateCryptoKeyPair()'s regionBlocksKeygen guard), so on a fresh device my_node_num is still the MAC-derived value from pickNewNodeNum(). The user then sets the region - the stock onboarding flow - ensurePkiKeys() mints a key, and the invariant is broken. The node then signs its broadcasts (Router.cpp signs when !pki_encrypted && (owner.is_licensed || isBroadcast(p->to))). Every receiver runs verifyFirstContactNodeInfo, fails crc32Buffer(user.public_key) != p->from, and drops the NodeInfo. The node's identity beacons are invisible to the mesh. Nothing reboots to repair it: AdminModule sets requiresReboot = false for LoRa changes ("All LoRa radio changes apply live via configChanged observer") and MenuHandler ends at service->reloadConfig(changes). Four call sites reached ensurePkiKeys(): 1. AdminModule set_config LORA, region first set (phone app - the common path) 2. MenuHandler applyLoraRegion (on-device region picker) 3. InkHUD MenuApplet applyLoRaRegion (schedules a reboot, so it self-healed at next boot) 4. portduino wasm wasm_set_region The reference implementation was already in the tree: the *licensed* branch of call site 1, thirteen lines below the broken unlicensed one, calls nodeDB->generateCryptoKeyPair() (which reaches createNewIdentity()) and widens the persisted mask with SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE. Rather than repeat that at four call sites, the key-mint is routed through one chokepoint that owns both halves of the identity: NodeDB::ensurePkiIdentity() calls crypto->ensurePkiKeys() and then createNewIdentity(). It lives in NodeDB because createNewIdentity() operates on the devicestate/node-DB globals, which CryptoEngine deliberately does not touch - ensurePkiKeys() takes the security config and user by reference precisely so it stays free of that dependency, and it is unit-tested against a standalone CryptoEngine. ensurePkiIdentity() returns true only when my_node_num actually moved (createNewIdentity() early-returns when the key is unchanged, so a repeat region change does not disturb the self entry or force a needless flash write). Callers use that to widen their save mask; my_node_num lives in devicestate and the self row moves in the node DB, so both segments must be persisted or the fix would revert at the next boot. SEGMENT_CONFIG, which carries the key itself, is already unconditional on all four paths. The InkHUD reboot is left as-is. It is now redundant for this invariant, but it covers the rest of that menu's behaviour and a redundant reboot is not a bug. Adds test_handleSetConfig_persistsUnlicensedFirstRegionIdentity, the unlicensed twin of the existing licensed test, asserting both the segment mask and my_node_num == crc32(public_key). Co-Authored-By: Claude Opus 5 * style(NodeDB): trim identity-recovery comments and guard the WASM nodeDB deref Two review asks, no behaviour change on any built target. Copilot flagged the unguarded nodeDB deref in the WASM region setter; it is the only ensurePkiIdentity() call site that did not check the pointer first. The rest is comment length. AGENTS.md:83 caps code comments at two lines, and the identity-recovery comments across the four call sites plus the NodeDB.h doc block ran to four and six lines. The rationale they carried is in the commit messages and the PR body, which is where AGENTS.md says it belongs. The PR's own fix in AdminModule.cpp is deliberately untouched. * fix(NodeDB): keep the identity move authoritative when the self record cannot be created createNewIdentity() removes the old node entry and assigns myNodeInfo.my_node_num before it tries to create the row for the new number. If getOrCreateMeshNode() came back null it returned false, so the first-region callers left SEGMENT_DEVICESTATE and SEGMENT_NODEDATABASE out of the save mask. The number had already moved in RAM at that point, and the freshly minted key goes to flash under SEGMENT_CONFIG regardless. The next boot therefore reloads the old number alongside the new key, which is exactly the crc32(public_key) != my_node_num break this path exists to prevent, reached through the error branch instead of the happy one. Rolling the number back is not an option either, since the key has already been replaced by the time this runs. So the move is now reported as the fact it is and the missing self record is logged separately; getOrCreateMeshNode() will recreate that row on the next contact. Reachable when the self record is absent and the table is full of protected nodes. Reported by CodeRabbit on #11426. --------- Co-authored-by: Claude Opus 5 --- src/graphics/draw/MenuHandler.cpp | 5 ++-- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 5 ++-- src/mesh/NodeDB.cpp | 24 ++++++++++++++-- src/mesh/NodeDB.h | 4 +++ src/modules/AdminModule.cpp | 6 ++-- .../portduino/wasm/portduino_glue_wasm.cpp | 13 +++++---- test/test_admin_radio/test_main.cpp | 28 +++++++++++++++++++ 7 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 6471d70c6..446f29a21 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -245,8 +245,9 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool } auto changes = SEGMENT_CONFIG; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto) { - crypto->ensurePkiKeys(config.security, owner); + // Minting the key moves our node num with it, and nothing reboots on this path to repair it later. + if (nodeDB->ensurePkiIdentity()) { + changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; } #endif initRegion(); diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 5e8a08e75..863c1e85d 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -324,8 +324,9 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) auto changes = SEGMENT_CONFIG; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto) { - crypto->ensurePkiKeys(config.security, owner); + // Minting the key moves our node num with it, and the reboot below only re-derives after the save. + if (nodeDB->ensurePkiIdentity()) { + changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; } #endif diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 040777857..e2557e8d0 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -4446,14 +4446,32 @@ bool NodeDB::createNewIdentity() myNodeInfo.my_node_num = newNodeNum; + // The number has moved, so the caller must persist it whatever happens next. Returning false here + // would leave the new key saved against the old number, which is the break this exists to prevent. meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum()); - if (!info) - return false; - TypeConversions::CopyUserToNodeInfoLite(info, owner); + if (info) + TypeConversions::CopyUserToNodeInfoLite(info, owner); + else + LOG_ERROR("No room for our own node 0x%08x, identity moved without a self record", newNodeNum); return true; } +bool NodeDB::ensurePkiIdentity() +{ +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + // A failed or declined keygen leaves the existing key, and so the existing node num, untouched. + if (!crypto || !crypto->ensurePkiKeys(config.security, owner)) + return false; + + // ensurePkiKeys() writes key material only, so my_node_num is still the stale MAC-derived value. + // createNewIdentity() early-returns when the key, and so the node num, did not actually change. + return createNewIdentity(); +#else + return false; +#endif +} + bool NodeDB::backupPreferences(meshtastic_AdminMessage_BackupLocation location) { bool success = false; diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index ca0acf171..0e669cca5 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -596,6 +596,10 @@ class NodeDB bool createNewIdentity(); + /// Mint the identity keypair outside the boot path and re-seat my_node_num == crc32(public_key). + /// @return true if my_node_num moved; the caller must then also persist SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE. + bool ensurePkiIdentity(); + bool backupPreferences(meshtastic_AdminMessage_BackupLocation location); bool restorePreferences(meshtastic_AdminMessage_BackupLocation location, int restoreWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 80bb79903..55b029f03 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1034,8 +1034,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // If we're setting region for the first time, init the region and regenerate the keys if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto && !owner.is_licensed) { - crypto->ensurePkiKeys(config.security, owner); + // Minting the key moves our node num with it (my_node_num == crc32(public_key)), so + // persist devicestate + the node DB too - exactly as the licensed branch below does. + if (!owner.is_licensed && nodeDB->ensurePkiIdentity()) { + changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; } #endif // new region is valid and we're coming from an unset region, so enable tx diff --git a/src/platform/portduino/wasm/portduino_glue_wasm.cpp b/src/platform/portduino/wasm/portduino_glue_wasm.cpp index a4b4a31e9..65d632084 100644 --- a/src/platform/portduino/wasm/portduino_glue_wasm.cpp +++ b/src/platform/portduino/wasm/portduino_glue_wasm.cpp @@ -12,10 +12,9 @@ // - exec() short-circuits to "" (no popen/shell in the browser). // Downstream is unchanged: Ch341Hal -> libpinedio_webusb.c -> WebUSB. -#include "CryptoEngine.h" // crypto->ensurePkiKeys() #include "MeshRadio.h" // initRegion() #include "MeshService.h" // service->reloadConfig() -#include "NodeDB.h" // config, owner globals + SEGMENT_CONFIG +#include "NodeDB.h" // config globals, SEGMENT_*, nodeDB->ensurePkiIdentity() #include "PhoneAPI.h" // the transport-agnostic client API seam #include "PortduinoFS.h" // portduinoVFS #include "PortduinoGlue.h" // declares `portduino_config` + Ch341Hal @@ -260,11 +259,13 @@ extern "C" EMSCRIPTEN_KEEPALIVE int wasm_set_region(int region) if (!(RadioInterface::validateConfigRegion(validated) && RadioInterface::validateConfigLora(validated))) return -1; + int changes = SEGMENT_CONFIG; bool wasUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET); if (wasUnset && newRegion > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto) - crypto->ensurePkiKeys(config.security, owner); // first real region -> generate keys + // Minting the key moves our node num with it, so persist devicestate + the node DB too. + if (nodeDB && nodeDB->ensurePkiIdentity()) + changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; #endif validated.tx_enabled = true; } @@ -274,8 +275,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE int wasm_set_region(int region) config.lora = validated; initRegion(); // repoint myRegion at the new region table if (service) - service->reloadConfig(SEGMENT_CONFIG); // reconfigure radio (new freq) + persist - wasm_fs_sync(); // browser: flush config.proto to IndexedDB + service->reloadConfig(changes); // reconfigure radio (new freq) + persist + wasm_fs_sync(); // browser: flush config.proto to IndexedDB return 0; } diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index ebdad8827..004037c9a 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -23,6 +23,7 @@ #include "mesh/Channels.h" #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" +#include // crc32Buffer(), for the my_node_num == crc32(public_key) invariant #include #include #include @@ -1153,6 +1154,32 @@ static void test_handleSetConfig_persistsLicensedFirstRegionIdentity() TEST_ASSERT_EQUAL(32, owner.public_key.size); } +// Unlicensed twin of the test above. Without the re-derivation the node signs broadcasts every receiver +// drops (verifyFirstContactNodeInfo: crc32(user.public_key) != from). +static void test_handleSetConfig_persistsUnlicensedFirstRegionIdentity() +{ + owner = meshtastic_User_init_zero; + owner.is_licensed = false; + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + initRegion(); + + testAdmin->deferSaves(); + const meshtastic_Config c = + makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + testAdmin->handleSetConfig(c, false); + + const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL(32, config.security.public_key.size); + TEST_ASSERT_EQUAL(32, owner.public_key.size); + // The invariant: a node's mesh address is derived from its identity key. + TEST_ASSERT_EQUAL_UINT32(crc32Buffer(config.security.public_key.bytes, config.security.public_key.size), + nodeDB->getNodeNum()); +} + static void test_handleSetConfig_fromOthers_invalidPresetRejected() { // Set up a known-good baseline in the global config @@ -1975,6 +2002,7 @@ void setup() // getRegion() RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity); + RUN_TEST(test_handleSetConfig_persistsUnlicensedFirstRegionIdentity); RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn); RUN_TEST(test_getRegion_returnsCorrectRegion_US); From 0b906b4d152e4b00c5e0062edd0ba521166c525a Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:28:57 +0000 Subject: [PATCH 096/109] T-Watch Ultra support (#8171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: T-Watch Ultra support * fix init touch controller * add framebuffer * update to device-ui * trunk fmt * update amoled driver reference * PMU cosmetics * power off lora * fix NodeDB defaults * trySetRTC when fixedPosition * haptic touch (only BaseUI) * init lora RF switch * update LovyanGFX 1.2.19 * earlyInitVariant() adaptations acc. #9438 * update device-ui / touch handling * Set NFC_CS disabled on boot * Get t-watch-ultra working better on BaseUI * Fix compilation * Fix flash reads on t-watch-ultra * Get baseui drawing to the screen correctly again on t-watch and add touch IRQ handling * Add PMU IRQ handling * Add IMU support * Change define to avoid collision * BaseUI changes to support t-watch-s3 rounded screen (#10786) * BaseUI changes to support t-watch-s3 rounded screen * Extend margin work to CannedMessages * Finish merge * Get audio working on watch-ultra * trunk fmt * added custom_meshtastic boilerplate * T-Echo-Plus: disable BHI260AP while assumingly not implemented * Drop the duplicate origBold declaration from the merge * Inset incoming message bubbles on rounded screens * Fix RTTTL tempo, WiFi screen margins, PMU guard and a duplicate define * fix compile errror (the 2nd time) * fix SDcard * fix/workaround CO5300 pixel flush to SPI * trunk fmt --------- Co-authored-by: Jonathan Bennett Co-authored-by: Thomas Göttgens --- boards/t-watch-ultra.json | 40 +++++ src/AudioThread.h | 1 + src/Power.cpp | 48 ++++- src/buzz/buzz.cpp | 41 +++-- src/detect/ScanI2C.cpp | 6 +- src/gps/GPS.cpp | 4 +- src/graphics/Screen.cpp | 9 +- src/graphics/Screen.h | 3 + src/graphics/SharedUIDisplay.cpp | 10 +- src/graphics/SharedUIDisplay.h | 18 +- src/graphics/TFTDisplay.cpp | 167 +++++++++++++++++- src/graphics/draw/DebugRenderer.cpp | 68 +++---- src/graphics/draw/MessageRenderer.cpp | 21 ++- src/graphics/draw/NodeListRenderer.cpp | 33 +++- src/graphics/draw/NodeListRenderer.h | 1 + src/graphics/draw/UIRenderer.cpp | 129 +++++++++----- src/graphics/draw/UIRenderer.h | 4 + src/input/TouchScreenBase.cpp | 2 +- src/main.cpp | 2 + src/mesh/NodeDB.cpp | 4 +- src/modules/CannedMessageModule.cpp | 69 +++++--- src/modules/PositionModule.cpp | 2 +- src/motion/AccelerometerThread.h | 8 + src/motion/BHI260APSensor.cpp | 80 +++++++++ src/motion/BHI260APSensor.h | 31 ++++ src/platform/esp32/architecture.h | 2 + .../esp32/esp_partition_read_mmap_wrap.c | 42 +++++ src/platform/extra_variants/README.md | 2 + .../extra_variants/t-watch-ultra/variant.cpp | 83 +++++++++ src/sleep.cpp | 2 +- variants/esp32s3/t-deck-pro/variant.h | 1 + variants/esp32s3/t-watch-ultra/pins_arduino.h | 94 ++++++++++ variants/esp32s3/t-watch-ultra/platformio.ini | 96 ++++++++++ variants/esp32s3/t-watch-ultra/variant.h | 101 +++++++++++ variants/nrf52840/t-echo-plus/variant.h | 2 +- 35 files changed, 1060 insertions(+), 166 deletions(-) create mode 100644 boards/t-watch-ultra.json create mode 100644 src/motion/BHI260APSensor.cpp create mode 100644 src/motion/BHI260APSensor.h create mode 100644 src/platform/esp32/esp_partition_read_mmap_wrap.c create mode 100644 src/platform/extra_variants/t-watch-ultra/variant.cpp create mode 100644 variants/esp32s3/t-watch-ultra/pins_arduino.h create mode 100644 variants/esp32s3/t-watch-ultra/platformio.ini create mode 100644 variants/esp32s3/t-watch-ultra/variant.h diff --git a/boards/t-watch-ultra.json b/boards/t-watch-ultra.json new file mode 100644 index 000000000..3ac379df5 --- /dev/null +++ b/boards/t-watch-ultra.json @@ -0,0 +1,40 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_qspi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-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": "qio", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "t-watch-ultra" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino"], + "name": "LilyGo T-Watch Ultra", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "speed": 921600 + }, + "url": "https://www.lilygo.cc/en-pl/products/t-watch-ultra", + "vendor": "LilyGo" +} diff --git a/src/AudioThread.h b/src/AudioThread.h index 3a44bf823..f4f5781fc 100644 --- a/src/AudioThread.h +++ b/src/AudioThread.h @@ -79,6 +79,7 @@ class AudioThread : public concurrency::OSThread auto sam = std::unique_ptr(new ESP8266SAM); sam->Say(audioOut.get(), text); setCPUFast(false); + audioOut->stop(); #ifdef AUDIO_AMP_ENABLE AUDIO_AMP_ENABLE(false); #endif diff --git a/src/Power.cpp b/src/Power.cpp index aa752cf63..2cb73296b 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -1142,8 +1142,10 @@ int32_t Power::runOnce() // cancel action also turns the screen on and off. if (PMU->isPekeyShortPressIrq()) { LOG_INFO("Input: Corona Button Click"); - InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0}; - inputBroker->injectInputEvent(&event); + if (inputBroker) { + InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0}; + inputBroker->injectInputEvent(&event); + } } #endif /* @@ -1446,6 +1448,48 @@ 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_T_WATCH_ULTRA) { + PMU->clearIrqStatus(); + + // Turn off the PMU charging indicator light, no physical connection + PMU->setChargingLedMode(XPOWERS_CHG_LED_OFF); // NO LED + + PMU->setPowerChannelVoltage(XPOWERS_ALDO1, 3300); // SD Card + PMU->enablePowerOutput(XPOWERS_ALDO1); + + PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); // Display + PMU->enablePowerOutput(XPOWERS_ALDO2); + + PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); // LoRa + PMU->enablePowerOutput(XPOWERS_ALDO3); + + PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 1800); // Sensor + PMU->enablePowerOutput(XPOWERS_ALDO4); + + PMU->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); // GPS + PMU->enablePowerOutput(XPOWERS_BLDO1); + + PMU->setPowerChannelVoltage(XPOWERS_BLDO2, 3300); // Speaker + PMU->enablePowerOutput(XPOWERS_BLDO2); + + PMU->setPowerChannelVoltage(XPOWERS_VBACKUP, 3300); // RTC Button battery + PMU->enablePowerOutput(XPOWERS_VBACKUP); + + // PMU->enablePowerOutput(XPOWERS_DLDO1); // NFC + + // UNUSED POWER CHANNEL + PMU->disablePowerOutput(XPOWERS_DCDC2); + PMU->disablePowerOutput(XPOWERS_DCDC3); + PMU->disablePowerOutput(XPOWERS_DCDC4); + PMU->disablePowerOutput(XPOWERS_DCDC5); + PMU->disablePowerOutput(XPOWERS_CPULDO); + + // Enable Measure + PMU->enableBattDetection(); + PMU->enableVbusVoltageMeasure(); + PMU->enableBattVoltageMeasure(); + PMU->enableSystemVoltageMeasure(); + PMU->enableTemperatureMeasure(); } 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) diff --git a/src/buzz/buzz.cpp b/src/buzz/buzz.cpp index 897f74d69..63f4e9b13 100644 --- a/src/buzz/buzz.cpp +++ b/src/buzz/buzz.cpp @@ -62,20 +62,17 @@ const int DURATION_1_1 = 1000; // 1/1 note #ifdef HAS_I2S void playTonesRTTTL(const ToneDuration *tone_durations, int size) { - // translate ToneDuration[] to RTTTL string and play using audioThread - static std::unordered_map freqToNote = { - {NOTE_C3, "c4"}, {NOTE_CS3, "c#4"}, {NOTE_D3, "d4"}, {NOTE_DS3, "d#4"}, {NOTE_E3, "e4"}, {NOTE_F3, "f4"}, - {NOTE_FS3, "f#4"}, {NOTE_G3, "g4"}, {NOTE_GS3, "g#4"}, {NOTE_A3, "a4"}, {NOTE_AS3, "a#4"}, {NOTE_B3, "b4"}, - {NOTE_C4, "c5"}, {NOTE_E4, "e5"}, {NOTE_G4, "g5"}, {NOTE_A4, "a5"}, {NOTE_C5, "c6"}, {NOTE_E5, "e6"}, - {NOTE_G5, "g6"}, {NOTE_F5, "f6"}, {NOTE_G6, "g7"}, {NOTE_E7, "e8"}}; + // translate ToneDuration[] to a single RTTTL string and play it via audioThread + static std::unordered_map freqToNote = { + {NOTE_SILENT, "p"}, // rest + {NOTE_C3, "c4"}, {NOTE_CS3, "c#4"}, {NOTE_D3, "d4"}, {NOTE_DS3, "d#4"}, {NOTE_E3, "e4"}, {NOTE_F3, "f4"}, + {NOTE_FS3, "f#4"}, {NOTE_G3, "g4"}, {NOTE_GS3, "g#4"}, {NOTE_A3, "a4"}, {NOTE_AS3, "a#4"}, {NOTE_B3, "b4"}, + {NOTE_C4, "c5"}, {NOTE_CS4, "c#5"}, {NOTE_E4, "e5"}, {NOTE_G4, "g5"}, {NOTE_A4, "a5"}, {NOTE_B4, "b5"}, + {NOTE_C5, "c6"}, {NOTE_E5, "e6"}, {NOTE_G5, "g6"}, {NOTE_F5, "f6"}, {NOTE_G6, "g7"}, {NOTE_E7, "e8"}}; - char rtttl[128] = "tone:d=32,o=4,b=200:"; // default duration and octave + char rtttl[128] = "tone:d=32,o=4,b=240:"; // b=240 makes 240000/(bpm*d) match the ms durations above for (int i = 0; i < size; i++) { const auto &td = tone_durations[i]; - std::string note = "b4"; - if (freqToNote.find(td.frequency_khz) != freqToNote.end()) { - note = freqToNote[td.frequency_khz]; - } int dur = 32; // default duration if (td.duration_ms >= 1000) dur = 1; @@ -90,16 +87,22 @@ void playTonesRTTTL(const ToneDuration *tone_durations, int size) else dur = 32; - char noteStr[64]; - snprintf(noteStr, sizeof(noteStr), "%s,%d", note.c_str(), dur); - strncat(rtttl, noteStr, sizeof(rtttl) - strlen(rtttl) - 1); + auto it = freqToNote.find(td.frequency_khz); + const char *note = (it != freqToNote.end()) ? it->second : "p"; // unknown freq -> rest - audioThread->beginRttl(rtttl, strlen(rtttl)); - while (audioThread->isPlaying()) { - delay(10); - } - return; + // RTTTL grammar puts duration before the note; notes are comma-separated + char noteStr[64]; + snprintf(noteStr, sizeof(noteStr), "%s%d%s", i ? "," : "", dur, note); + strncat(rtttl, noteStr, sizeof(rtttl) - strlen(rtttl) - 1); } + // trailing rest flushes the last note out of the I2S DMA buffer before teardown + strncat(rtttl, ",32p", sizeof(rtttl) - strlen(rtttl) - 1); + + audioThread->beginRttl(rtttl, strlen(rtttl)); + while (audioThread->isPlaying()) { + delay(10); + } + audioThread->stop(); // release I2S so the amp goes silent instead of looping the last buffer } #endif diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp index eff44c114..580cba7fd 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, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX, - ICM20948, BMM150, BMI270, ICM42607P, ISM330DHCX, QMA6100P, QMI8658}; - return firstOfOrNONE(14, types); + ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, SC7A20, BMA423, LSM6DS3, BMX160, STK8BAXX, ICM20948, + BMM150, BMI270, BHI260AP, ICM42607P, ISM330DHCX, QMA6100P, QMI8658}; + return firstOfOrNONE(15, types); } ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 73d1d0355..0bd0f3212 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -1281,8 +1281,8 @@ void GPS::setPowerPMU(bool on) } else if (HW_VENDOR == meshtastic_HardwareModel_LILYGO_TBEAM_S3_CORE) { // t-beam-s3-core GNSS power channel on ? PMU->enablePowerOutput(XPOWERS_ALDO4) : PMU->disablePowerOutput(XPOWERS_ALDO4); - } else if (HW_VENDOR == meshtastic_HardwareModel_T_WATCH_S3) { - // t-watch-s3-plus GNSS power channel + } else if (HW_VENDOR == meshtastic_HardwareModel_T_WATCH_ULTRA || HW_VENDOR == meshtastic_HardwareModel_T_WATCH_S3) { + // t-watch-ultra / t-watch-s3-plus GNSS power channel on ? PMU->enablePowerOutput(XPOWERS_BLDO1) : PMU->disablePowerOutput(XPOWERS_BLDO1); } } else if (model == XPOWERS_AXP192) { diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 5e8423886..f36154e8c 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -681,12 +681,13 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) if (on) { LOG_INFO("Turn on screen"); powerMon->setState(meshtastic_PowerMon_State_Screen_On); -#ifdef T_WATCH_S3 - PMU->enablePowerOutput(XPOWERS_ALDO2); +#if defined(T_WATCH_S3) || defined(T_WATCH_ULTRA) + if (PMU) // cleared when both AXP init attempts failed + PMU->enablePowerOutput(XPOWERS_ALDO2); #endif // some screens seem to need a kick in the pants to turn back on -#if defined(MUZI_BASE) || defined(M5STACK_CARDPUTER_ADV) +#if defined(MUZI_BASE) || defined(M5STACK_CARDPUTER_ADV) || defined(TFT_RESET_AFTER_SLEEP) dispdev->init(); dispdev->setBrightness(brightness); dispdev->flipScreenVertically(); @@ -819,7 +820,7 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) #endif #endif -#ifdef T_WATCH_S3 +#if defined(T_WATCH_S3) // on T_WATCH_ULTRA, powering down this pin seems to goober the i2c bus. PMU->disablePowerOutput(XPOWERS_ALDO2); #endif enabled = false; diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index cf694f51a..e7a77942f 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -287,6 +287,9 @@ class Screen : public concurrency::OSThread // FIXME: Needs refactoring and getMacAddr needs to be moved to a utility class char ourId[5]; + // if we have a step counter, this stores the number of steps. + uint32_t steps = 0; + /// Initializes the UI, turns on the display, starts showing boot screen. // // Not thread safe - must be called before any other methods are called. diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index b66a2a02a..5e6567206 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -104,14 +104,14 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date, bool transparent_background, bool use_title_color_override, uint16_t title_color_override) { - constexpr int HEADER_OFFSET_Y = 1; + constexpr int HEADER_OFFSET_Y = 1 + BASEUI_HEADER_MARGIN; y += HEADER_OFFSET_Y; display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); - const int xOffset = 4; - const int highlightHeight = FONT_HEIGHT_SMALL - 1; + const int xOffset = 4 + BASEUI_HEADER_LR_MARGIN; + const int highlightHeight = FONT_HEIGHT_SMALL - 1 + BASEUI_HEADER_MARGIN; const bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED); const bool isBold = config.display.heading_bold; @@ -250,8 +250,8 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } #endif - int batteryX = 1; - int batteryY = HEADER_OFFSET_Y + 1; + int batteryX = x + 1 + BASEUI_HEADER_LR_MARGIN; + int batteryY = HEADER_OFFSET_Y + 1 + BASEUI_HEADER_MARGIN / 2; #if !defined(OLED_TINY) // === Battery Icons === if (usbPowered && !isCharging) { // This is a basic check to determine USB Powered is flagged but not charging diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h index 3ed91d86b..3b99fb77f 100644 --- a/src/graphics/SharedUIDisplay.h +++ b/src/graphics/SharedUIDisplay.h @@ -21,7 +21,7 @@ namespace graphics #define textSixthLine (textFifthLine + (FONT_HEIGHT_SMALL - 5)) // Consistent Line Spacing for devices like T114 and TEcho/ThinkNode M1 of devices -#define textFirstLine_medium (FONT_HEIGHT_SMALL + 1) +#define textFirstLine_medium (FONT_HEIGHT_SMALL + 1 + BASEUI_HEADER_MARGIN) #define textSecondLine_medium (textFirstLine_medium + FONT_HEIGHT_SMALL) #define textThirdLine_medium (textSecondLine_medium + FONT_HEIGHT_SMALL) #define textFourthLine_medium (textThirdLine_medium + FONT_HEIGHT_SMALL) @@ -36,6 +36,22 @@ namespace graphics #define textFifthLine_large (textFourthLine_large + (FONT_HEIGHT_SMALL + 5)) #define textSixthLine_large (textFifthLine_large + (FONT_HEIGHT_SMALL + 5)) +#ifndef BASEUI_HEADER_MARGIN +#define BASEUI_HEADER_MARGIN 0 +#endif +#ifndef BASEUI_HEADER_LR_MARGIN +#define BASEUI_HEADER_LR_MARGIN 0 +#endif +#ifndef BASEUI_BODY_LR_MARGIN +#define BASEUI_BODY_LR_MARGIN 0 +#endif +#ifndef BASEUI_BELOW_HEADER_MARGIN +#define BASEUI_BELOW_HEADER_MARGIN 0 +#endif +#ifndef ROUNDED_SCREEN +#define ROUNDED_SCREEN false +#endif + // Quick screen access #define SCREEN_WIDTH display->getWidth() #define SCREEN_HEIGHT display->getHeight() diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 9ee16c85f..a3af9e7ff 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -17,6 +17,92 @@ extern SX1509 gpioExtender; #endif +#ifdef TFT_MESH_OVERRIDE +uint16_t TFT_MESH = TFT_MESH_OVERRIDE; +#else +uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94); +#endif + +#if defined(CO5300_CS) +#include // Graphics and font library for AMOLED driver chip +class LGFX : public lgfx::LGFX_Device +{ + lgfx::Panel_CO5300 _panel_instance; + lgfx::Bus_SPI _bus_instance; + + public: + LGFX(void) + { + { + auto cfg = _bus_instance.config(); + + // configure SPI + cfg.spi_host = CO5300_SPI_HOST; // ESP32-S2,S3,C3 : SPI2_HOST or SPI3_HOST / ESP32 : VSPI_HOST or HSPI_HOST + cfg.spi_mode = SPI_MODE0; + cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing + // 80MHz by an integer) + cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving + cfg.spi_3wire = false; // Set to true if reception is done on the MOSI pin + cfg.use_lock = true; // Set to true to use transaction locking + cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch / + // SPI_DMA_CH_AUTO=auto setting) + cfg.pin_sclk = CO5300_SCK; // Set SPI SCLK pin number + cfg.pin_io0 = CO5300_IO0; + cfg.pin_io1 = CO5300_IO1; + cfg.pin_io2 = CO5300_IO2; + cfg.pin_io3 = CO5300_IO3; + + _bus_instance.config(cfg); // applies the set value to the bus. + _panel_instance.setBus(&_bus_instance); // set the bus on the panel. + } + + { // Set the display panel control. + auto cfg = _panel_instance.config(); // Gets a structure for display panel settings. + + cfg.pin_cs = CO5300_CS; // Pin number where CS is connected (-1 = disable) + cfg.pin_rst = CO5300_RESET; // Pin number where RST is connected (-1 = disable) + cfg.panel_width = TFT_WIDTH; // actual displayable width + cfg.panel_height = TFT_HEIGHT; // actual displayable height + cfg.offset_rotation = TFT_OFFSET_ROTATION; // Rotation direction value offset 0~7 (4~7 is upside down) + cfg.offset_x = TFT_OFFSET_X; + cfg.offset_y = TFT_OFFSET_Y; + cfg.dummy_read_pixel = 8; // Number of bits for dummy read before pixel readout + cfg.dummy_read_bits = 1; // Number of bits for dummy read before non-pixel data read + cfg.readable = true; // Set to true if data can be read + cfg.invert = false; // Set to true if the light/darkness of the panel is reversed + cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped + cfg.dlen_16bit = false; // Set to true for panels that transmit data length in 16-bit units + cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.) + + // Set the following only when the display is shifted with a driver with a variable number of pixels + cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC + cfg.memory_height = TFT_HEIGHT; // Maximum height supported by the driver IC + _panel_instance.config(cfg); + } + + setPanel(&_panel_instance); + } + + bool init() + { +#ifdef CO5300_RESET + LOG_DEBUG("LGFX_Panel_CO5300::init()"); + lgfx::pinMode(CO5300_RESET, lgfx::pin_mode_t::output); + lgfx::gpio_hi(CO5300_RESET); + delay(20); + lgfx::gpio_lo(CO5300_RESET); + delay(30); + lgfx::gpio_hi(CO5300_RESET); + delay(20); +#endif + return lgfx::LGFX_Device::init(); + } +}; + +static LGFX *tft = nullptr; + +#endif + #if defined(ST7735S) #include // Graphics and font library for ST7735 driver chip @@ -1394,6 +1480,70 @@ void TFTDisplay::display(bool fromBlank) } // Step 3: Copy only the changed span into the pixel line buffer. +#if defined(CO5300_CS) + constexpr uint32_t kCO5300MinTransferBytes = 80; + constexpr uint32_t kCO5300BytesPerColumn = sizeof(uint16_t) * 2; // two rows, RGB565 + constexpr uint32_t kCO5300MinColumns = (kCO5300MinTransferBytes + kCO5300BytesPerColumn - 1) / kCO5300BytesPerColumn; + + // CO5300 workaround: widen very small updates so LovyanGFX avoids tiny SPI writes. + uint32_t span = x_LastPixelUpdate - x_FirstPixelUpdate + 1; + if (span < kCO5300MinColumns) { + uint32_t needed = kCO5300MinColumns - span; + uint32_t growLeft = needed / 2; + uint32_t growRight = needed - growLeft; + + const uint32_t availableLeft = x_FirstPixelUpdate; + if (growLeft > availableLeft) + growLeft = availableLeft; + x_FirstPixelUpdate -= growLeft; + needed -= growLeft; + + const uint32_t availableRight = (displayWidth - 1) - x_LastPixelUpdate; + const uint32_t extendRight = (needed < availableRight) ? needed : availableRight; + x_LastPixelUpdate += extendRight; + needed -= extendRight; + + const uint32_t extendLeft = (needed < x_FirstPixelUpdate) ? needed : x_FirstPixelUpdate; + x_FirstPixelUpdate -= extendLeft; + } + + // Keep transfer edges aligned as before for DMA-friendly boundaries. + x_FirstPixelUpdate &= ~1U; + x_LastPixelUpdate = (x_LastPixelUpdate | 1U); + if (x_LastPixelUpdate >= displayWidth) { + x_LastPixelUpdate = displayWidth - 1; + } + + // snap y down to the even-row pair (AMOLED requires 2-row aligned writes) + const uint32_t y_draw = y & ~1U; + span = x_LastPixelUpdate - x_FirstPixelUpdate + 1; + const int y_offset = (int)y_draw - (int)y; + for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) { + const uint32_t col = x - x_FirstPixelUpdate; + uint32_t bi = (y_draw / 8) * displayWidth; + isset = buffer[x + bi] & (1 << (y_draw & 7)); +#if GRAPHICS_TFT_COLORING_ENABLED + linePixelBuffer[x_FirstPixelUpdate + col] = + hasColorRegions ? graphics::resolveTFTColorPixel(static_cast(x), static_cast(y_draw), isset, + colorTftWhite, colorTftBlack) + : (isset ? colorTftWhite : colorTftBlack); +#else + linePixelBuffer[x_FirstPixelUpdate + col] = isset ? colorTftWhite : colorTftBlack; +#endif + bi = ((y_draw + 1) / 8) * displayWidth; + isset = buffer[x + bi] & (1 << ((y_draw + 1) & 7)); +#if GRAPHICS_TFT_COLORING_ENABLED + linePixelBuffer[x_FirstPixelUpdate + span + col] = + hasColorRegions ? graphics::resolveTFTColorPixel(static_cast(x), static_cast(y_draw + 1), + isset, colorTftWhite, colorTftBlack) + : (isset ? colorTftWhite : colorTftBlack); +#else + linePixelBuffer[x_FirstPixelUpdate + span + col] = isset ? colorTftWhite : colorTftBlack; +#endif + } + const uint8_t lines_updated = 2; +#else + int y_offset = 0; #if GRAPHICS_TFT_COLORING_ENABLED if (hasColorRegions) graphics::beginTFTColorRow(static_cast(y)); @@ -1411,13 +1561,16 @@ void TFTDisplay::display(bool fromBlank) linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack; #endif } + const uint8_t lines_updated = 1; +#endif + #if defined(HACKADAY_COMMUNICATOR) tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate], (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1); #else // Step 4: Send the changed pixels on this line to the screen as a single block transfer. // This function accepts pixel data MSB first so it can dump the memory straight out the SPI port. - tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1, + tft->pushImage(x_FirstPixelUpdate, y + y_offset, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), lines_updated, &linePixelBuffer[x_FirstPixelUpdate]); #endif somethingChanged = true; @@ -1485,7 +1638,7 @@ void TFTDisplay::sendCommand(uint8_t com) // handle display on/off directly switch (com) { case DISPLAYON: { - // LOG_DEBUG("Display on"); + LOG_DEBUG("Display on"); backlightEnable->set(true); #if ARCH_PORTDUINO display(true); @@ -1513,7 +1666,7 @@ void TFTDisplay::sendCommand(uint8_t com) break; } case DISPLAYOFF: { - // LOG_DEBUG("Display off"); + LOG_DEBUG("Display off"); backlightEnable->set(false); #if ARCH_PORTDUINO tft->clear(); @@ -1620,8 +1773,8 @@ bool TFTDisplay::connect() #endif } - backlightEnable->set(true); LOG_INFO("Power to TFT Backlight"); + backlightEnable->set(true); #ifdef UNPHONE unphone.backlight(true); // using unPhone library @@ -1649,7 +1802,7 @@ bool TFTDisplay::connect() tft->setRotation(1); // T-Deck has the TFT in landscape #elif defined(T_WATCH_S3) tft->setRotation(2); // T-Watch S3 left-handed orientation -#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) +#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) || defined(T_WATCH_ULTRA) tft->setRotation(0); // use config.yaml to set rotation #else tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label @@ -1657,7 +1810,11 @@ bool TFTDisplay::connect() tft->fillScreen(getThemeDefaultOffColor()); if (this->linePixelBuffer == NULL) { +#if defined(CO5300_CS) + this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * 2); +#else this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth); +#endif if (!this->linePixelBuffer) { LOG_ERROR("Not enough memory to create TFT line buffer"); diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 5cca4c4aa..bddc6e8d1 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -59,17 +59,18 @@ void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, i // === Header === graphics::drawCommonHeader(display, x, y, titleStr); + y += BASEUI_BELOW_HEADER_MARGIN; const char *wifiName = config.network.wifi_ssid; if (WiFi.status() != WL_CONNECTED) { - display->drawString(x, getTextPositions(display)[line++], "WiFi: Not Connected"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "WiFi: Not Connected"); } else { - display->drawString(x, getTextPositions(display)[line++], "WiFi: Connected"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "WiFi: Connected"); char rssiStr[32]; snprintf(rssiStr, sizeof(rssiStr), "RSSI: %d", WiFi.RSSI()); - display->drawString(x, getTextPositions(display)[line++], rssiStr); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, rssiStr); } /* @@ -87,36 +88,36 @@ void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, i if (WiFi.status() == WL_CONNECTED) { char ipStr[64]; snprintf(ipStr, sizeof(ipStr), "IP: %s", WiFi.localIP().toString().c_str()); - display->drawString(x, getTextPositions(display)[line++], ipStr); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, ipStr); } else if (WiFi.status() == WL_NO_SSID_AVAIL) { - display->drawString(x, getTextPositions(display)[line++], "SSID Not Found"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "SSID Not Found"); } else if (WiFi.status() == WL_CONNECTION_LOST) { - display->drawString(x, getTextPositions(display)[line++], "Connection Lost"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "Connection Lost"); } else if (WiFi.status() == WL_IDLE_STATUS) { - display->drawString(x, getTextPositions(display)[line++], "Idle ... Reconnecting"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "Idle ... Reconnecting"); } else if (WiFi.status() == WL_CONNECT_FAILED) { - display->drawString(x, getTextPositions(display)[line++], "Connection Failed"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "Connection Failed"); } #ifdef ARCH_ESP32 else { // Codes: // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/wifi.html#wi-fi-reason-code - display->drawString(x, getTextPositions(display)[line++], + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, WiFi.disconnectReasonName(static_cast(getWifiDisconnectReason()))); } #else else { char statusStr[32]; snprintf(statusStr, sizeof(statusStr), "Unknown status: %d", WiFi.status()); - display->drawString(x, getTextPositions(display)[line++], statusStr); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, statusStr); } #endif char ssidStr[64]; snprintf(ssidStr, sizeof(ssidStr), "SSID: %s", wifiName); - display->drawString(x, getTextPositions(display)[line++], ssidStr); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, ssidStr); - display->drawString(x, getTextPositions(display)[line++], "URL: http://meshtastic.local"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line++] + y, "URL: http://meshtastic.local"); graphics::drawCommonFooter(display, x, y); @@ -144,9 +145,11 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, // === Header === graphics::drawCommonHeader(display, x, y, titleStr); + y += BASEUI_BELOW_HEADER_MARGIN; // === First Row: Region / BLE Name === - graphics::UIRenderer::drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, 0, true, ""); + graphics::UIRenderer::drawNodes(display, x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + 2 + y, nodeStatus, 0, + true, ""); uint8_t dmac[6]; char shortnameble[35]; @@ -158,8 +161,8 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, snprintf(shortnameble, sizeof(shortnameble), "BLE: %s", screen->ourId); } int textWidth = display->getStringWidth(shortnameble); - int nameX = (SCREEN_WIDTH - textWidth); - display->drawString(nameX, getTextPositions(display)[line++], shortnameble); + int nameX = (SCREEN_WIDTH - textWidth - BASEUI_BODY_LR_MARGIN); + display->drawString(nameX, getTextPositions(display)[line++] + y, shortnameble); if (!graphics::isCompactPanel(display)) { // === Second Row: Role === @@ -168,7 +171,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, snprintf(device_role, sizeof(device_role), "Role: %s", role); textWidth = display->getStringWidth(device_role); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], device_role); + display->drawString(nameX, getTextPositions(display)[line++] + y, device_role); } // === Third Row: Radio Preset === @@ -194,7 +197,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, } textWidth = display->getStringWidth(regionradiopreset); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], regionradiopreset); + display->drawString(nameX, getTextPositions(display)[line++] + y, regionradiopreset); // === Fourth Row: Frequency / ChanNum === char frequencyslot[35]; @@ -220,14 +223,14 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, } textWidth = display->getStringWidth(frequencyslot); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], frequencyslot); + display->drawString(nameX, getTextPositions(display)[line++] + y, frequencyslot); #if !defined(OLED_TINY) // === Fifth Row: Channel Utilization === if (!config.lora.tx_enabled) { const char *txdisabled = "Transmit Disabled"; textWidth = display->getStringWidth(txdisabled); - display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line], txdisabled); + display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line] + y, txdisabled); } else { const char *chUtil = "ChUtil:"; @@ -236,7 +239,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5; - int chUtil_y = getTextPositions(display)[line] + 3; + int chUtil_y = getTextPositions(display)[line] + 3 + y; int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50; int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border @@ -250,7 +253,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; int starting_position = centerofscreen - total_line_content_width; - display->drawString(starting_position, getTextPositions(display)[line], chUtil); + display->drawString(starting_position, getTextPositions(display)[line] + y, chUtil); // Force 61% or higher to show a full 100% bar, text would still show related percent. if (chutil_percent >= 61) { @@ -297,7 +300,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); } - display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++], + display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++] + y, chUtilPercentage); } #endif @@ -318,11 +321,12 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x // === Header === graphics::drawCommonHeader(display, x, y, titleStr); + y += BASEUI_BELOW_HEADER_MARGIN; // === Layout === int line = 1; const int barHeight = 6; - const int labelX = x; + const int labelX = x + BASEUI_BODY_LR_MARGIN; int barsOffset = (currentResolution == ScreenResolution::High) ? 24 : 0; #ifdef USE_EINK #ifndef T_DECK_PRO @@ -353,7 +357,11 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x } int textWidth = display->getStringWidth(combinedStr); - int adjustedBarWidth = SCREEN_WIDTH - barX - textWidth - 6; + int labelWidth = display->getStringWidth(label); + if (barX < BASEUI_BODY_LR_MARGIN + labelWidth) { + barX = BASEUI_BODY_LR_MARGIN + labelWidth; + } + int adjustedBarWidth = SCREEN_WIDTH - barX - textWidth - 6 - BASEUI_BODY_LR_MARGIN; if (adjustedBarWidth < 10) adjustedBarWidth = 10; @@ -361,10 +369,10 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x // Label display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawString(labelX, getTextPositions(display)[line], label); + display->drawString(labelX, getTextPositions(display)[line] + y, label); #if !defined(OLED_TINY) // Bar - int barY = getTextPositions(display)[line] + (FONT_HEIGHT_SMALL - barHeight) / 2; + int barY = getTextPositions(display)[line] + y + (FONT_HEIGHT_SMALL - barHeight) / 2; display->setColor(WHITE); display->drawRect(barX, barY, adjustedBarWidth, barHeight); @@ -384,7 +392,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x #endif // Value string display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(SCREEN_WIDTH, getTextPositions(display)[line], combinedStr); + display->drawString(SCREEN_WIDTH - BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + y, combinedStr); }; // === Memory values === @@ -473,7 +481,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x int textWidth = display->getStringWidth(appversionstr); int nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], appversionstr); + display->drawString(nameX, getTextPositions(display)[line++] + y, appversionstr); if (!graphics::isCompactPanel(display) && (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5))) { // Only show uptime if the screen can show it @@ -481,7 +489,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr)); textWidth = display->getStringWidth(uptimeStr); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], uptimeStr); + display->drawString(nameX, getTextPositions(display)[line++] + y, uptimeStr); } if (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5)) { // Only show API state if the screen can show it @@ -528,7 +536,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x } #endif if (api_state[0] != '\0') { - display->drawString((SCREEN_WIDTH - display->getStringWidth(api_state)) / 2, getTextPositions(display)[line++], + display->drawString((SCREEN_WIDTH - display->getStringWidth(api_state)) / 2, getTextPositions(display)[line++] + y, api_state); } } diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 1b2372917..acfc4d11b 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -437,12 +437,13 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 display->setFont(FONT_SMALL); const bool compactPanel = graphics::isCompactPanel(display); // Compact panels: no bottom nav row anymore (see UIRenderer::drawNavigationBar), full height available. - const int navHeight = compactPanel ? 0 : FONT_HEIGHT_SMALL; + const int navHeight = compactPanel ? 0 : FONT_HEIGHT_SMALL + BASEUI_BELOW_HEADER_MARGIN + BASEUI_HEADER_MARGIN; const int scrollBottom = SCREEN_HEIGHT - navHeight; - const int contentTop = compactPanel ? 0 : getTextPositions(display)[1]; + // Rounded screens start the body below the header margin; getTextPositions(display)[1] + BASEUI_BELOW_HEADER_MARGIN + const int contentTop = compactPanel ? 0 : navHeight; const int usableHeight = compactPanel ? scrollBottom - contentTop : scrollBottom; - constexpr int LEFT_MARGIN = 2; - constexpr int RIGHT_MARGIN = 2; + constexpr int LEFT_MARGIN = 2 + BASEUI_BODY_LR_MARGIN; + constexpr int RIGHT_MARGIN = 2 + BASEUI_BODY_LR_MARGIN; constexpr int SCROLLBAR_WIDTH = 3; constexpr int BUBBLE_PAD_X = 3; constexpr int BUBBLE_PAD_Y = 4; @@ -453,6 +454,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 // Check if bubbles are enabled const bool showBubbles = config.display.enable_message_bubbles && !compactPanel; const int textIndent = showBubbles ? (BUBBLE_PAD_X + BUBBLE_TEXT_INDENT) : LEFT_MARGIN; + // Bubbles carry their own padding, so the rounded-screen inset has to come from here + const int contentLeft = x + (showBubbles ? BASEUI_BODY_LR_MARGIN : 0); // Derived widths const int leftTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - (showBubbles ? (BUBBLE_PAD_X * 2) : 0); @@ -873,10 +876,10 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 if (b.mine) { bubbleX = rightEdge - bubbleW; } else { - bubbleX = x; + bubbleX = contentLeft; } - if (bubbleX < x) - bubbleX = x; + if (bubbleX < contentLeft) + bubbleX = contentLeft; if (bubbleX + bubbleW > rightEdge) bubbleW = std::max(1, rightEdge - bubbleX); @@ -953,7 +956,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 if (headerX < LEFT_MARGIN) headerX = LEFT_MARGIN; } else { - headerX = x + textIndent; + headerX = contentLeft + textIndent; } graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1, true); @@ -1002,7 +1005,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 drawStringWithEmotes(display, rightX, lineY, cachedLines[i], emotes, numEmotes); } else { - drawStringWithEmotes(display, x + textIndent, lineY, cachedLines[i], emotes, numEmotes); + drawStringWithEmotes(display, contentLeft + textIndent, lineY, cachedLines[i], emotes, numEmotes); } } } diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 7d7bf5a6e..24a5eeaf6 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -47,6 +47,20 @@ void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t * } } +void drawScaledXBitmap3x(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display) +{ + for (int row = 0; row < height; row++) { + uint8_t rowMask = (1 << row); + for (int col = 0; col < width; col++) { + uint8_t colData = pgm_read_byte(&bitmapXBM[col]); + if (colData & rowMask) { + // Note: rows become X, columns become Y after transpose + display->fillRect(x + row * 3, y + col * 3, 3, 3); + } + } + } +} + // Static variables for dynamic cycling static ListMode_Node currentMode_Nodes = MODE_LAST_HEARD; static ListMode_Location currentMode_Location = MODE_DISTANCE; @@ -606,7 +620,7 @@ void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, EntryRenderer renderer, NodeExtrasRenderer extras, float headingRadian, double lat, double lon) { - const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1; + const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1 + BASEUI_HEADER_MARGIN; // Compact panels: 4 rows fit (0,9,18,27), a 5th pages instead of cramming in. const int rowYOffset = graphics::isCompactPanel(display) ? (FONT_HEIGHT_SMALL - 4) : (FONT_HEIGHT_SMALL - 3); bool locationScreen = false; @@ -622,7 +636,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t // Compact panels have no header (see drawCommonHeader) - don't reserve space for one. if (!graphics::isCompactPanel(display)) - y += COMMON_HEADER_HEIGHT; + y += COMMON_HEADER_HEIGHT + BASEUI_BELOW_HEADER_MARGIN; firstRowY = y; int totalColumns = 1; // Default to 1 column @@ -638,7 +652,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t } else { if (SCREEN_WIDTH <= 64) { totalColumns = 1; - } else if (SCREEN_WIDTH > 64 && SCREEN_WIDTH <= 240) { + } else if ((SCREEN_WIDTH > 64 && SCREEN_WIDTH <= 240) || ROUNDED_SCREEN) { totalColumns = 2; } else { totalColumns = 3; @@ -691,11 +705,20 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t auto *node = nodeDB->getMeshNode(nodeNum); int xPos = x + (col * columnWidth); int yPos = y + yOffset; + int effectiveColumnWidth = columnWidth; + if (BASEUI_BODY_LR_MARGIN) { + if (col == 0) { + xPos += BASEUI_BODY_LR_MARGIN; + effectiveColumnWidth -= BASEUI_BODY_LR_MARGIN; + } else if (col == (totalColumns - 1)) { + effectiveColumnWidth -= BASEUI_BODY_LR_MARGIN; + } + } - renderer(display, node, xPos, yPos, columnWidth); + renderer(display, node, xPos, yPos, effectiveColumnWidth); if (extras) - extras(display, node, xPos, yPos, columnWidth, headingRadian, lat, lon); + extras(display, node, xPos, yPos, effectiveColumnWidth, headingRadian, lat, lon); lastNodeY = max(lastNodeY, yPos + FONT_HEIGHT_SMALL); yOffset += rowYOffset; diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index 69c4bc067..d1e8bac1d 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -65,6 +65,7 @@ void scrollDown(); // Bitmap drawing function void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display); +void drawScaledXBitmap3x(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display); } // namespace NodeListRenderer diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index fad540cad..804f949ff 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -451,7 +451,8 @@ static bool computeBottomCompassPlacement(OLEDDisplay *display, int16_t xOffset, int16_t margin, int16_t *compassX, int16_t *compassY, int16_t *compassRadius) { // Return false when content leaves no room for a readable compass. - int availableHeight = SCREEN_HEIGHT - yBelowContent - bottomReserved - margin; + int availableHeight = + SCREEN_HEIGHT - yBelowContent - bottomReserved - margin - BASEUI_HEADER_MARGIN - BASEUI_BELOW_HEADER_MARGIN; if (availableHeight < FONT_HEIGHT_SMALL * 2) { return false; } @@ -581,12 +582,12 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, if (!gps->getIsConnected() && !config.position.fixed_position) { if (strcmp(mode, "line1") == 0) { strcpy(displayLine, "No GPS present"); - display->drawString(x, y, displayLine); + display->drawString(x + BASEUI_BODY_LR_MARGIN, y, displayLine); } } else if (!gps->getHasLock() && !config.position.fixed_position) { if (strcmp(mode, "line1") == 0) { strcpy(displayLine, gps->getHasTime() ? "GPS Time Only" : "No GPS Lock"); - display->drawString(x, y, displayLine); + display->drawString(x + BASEUI_BODY_LR_MARGIN, y, displayLine); } } else { @@ -665,13 +666,14 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, } if (strcmp(mode, "line1") == 0) { - display->drawString(x, y, coordinateLine_1); + display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_1); } else if (strcmp(mode, "line2") == 0) { - display->drawString(x, y, coordinateLine_2); + display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_2); } else if (strcmp(mode, "combined") == 0) { display->drawString(x, y, coordinateLine_1); if (coordinateLine_2[0] != '\0') { - display->drawString(x + display->getStringWidth(coordinateLine_1), y, coordinateLine_2); + display->drawString(x + BASEUI_BODY_LR_MARGIN + display->getStringWidth(coordinateLine_1), y, + coordinateLine_2); } } @@ -683,12 +685,12 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, snprintf(coordinateLine_2, sizeof(coordinateLine_2), "Lon: %3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(), geoCoord.getDMSLonMin(), geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP()); if (strcmp(mode, "line1") == 0) { - display->drawString(x, y, coordinateLine_1); + display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_1); } else if (strcmp(mode, "line2") == 0) { - display->drawString(x, y, coordinateLine_2); + display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_2); } else { // both - display->drawString(x, y, coordinateLine_1); - display->drawString(x, y + 10, coordinateLine_2); + display->drawString(x + BASEUI_BODY_LR_MARGIN, y, coordinateLine_1); + display->drawString(x + BASEUI_BODY_LR_MARGIN, y + 10, coordinateLine_2); } } } @@ -933,6 +935,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } #endif + y += BASEUI_BELOW_HEADER_MARGIN; // ===== DYNAMIC ROW STACKING WITH YOUR MACROS ===== // 1. Each potential info row has a macro-defined Y position (not regular increments!). // 2. Each row is only shown if it has valid data. @@ -1304,7 +1307,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } // **************************** -// * Device Focused Screen * +// * Home Frame * // **************************** void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { @@ -1313,6 +1316,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta display->setFont(FONT_SMALL); int line = 1; const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + bool origBold = config.display.heading_bold; // === Header === if (currentResolution == ScreenResolution::UltraLow) { @@ -1320,11 +1324,11 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta } else { graphics::drawCommonHeader(display, x, y, ""); } + y += BASEUI_BELOW_HEADER_MARGIN; // === Content below header === // === First Row: Region / Channel Utilization and Uptime === - bool origBold = config.display.heading_bold; config.display.heading_bold = false; const bool compactPanel = graphics::isCompactPanel(display); @@ -1333,19 +1337,20 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta const char *txdisabled = "Transmit Disabled"; if (compactPanel) { int textWidth = display->getStringWidth(txdisabled); - display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line], txdisabled); + display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line] + y, txdisabled); } else { - display->drawString(x, getTextPositions(display)[line], txdisabled); + display->drawString(x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + y, txdisabled); } } else if (compactPanel) { // No room for a separate left/right column layout - center it instead. - drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online", true); + drawNodes(display, x, getTextPositions(display)[line] + y + 2, nodeStatus, -1, false, "online", true); } else { // Display Region and Channel Utilization if (currentResolution == ScreenResolution::UltraLow) { - drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online"); + drawNodes(display, x, getTextPositions(display)[line] + y + 2, nodeStatus, -1, false, "online"); } else { - drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online"); + drawNodes(display, x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + y + 2, nodeStatus, -1, false, + "online"); } } char uptimeStr[32] = ""; @@ -1353,7 +1358,8 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr)); } if (!compactPanel) { - display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr); + display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr) - BASEUI_BODY_LR_MARGIN, + getTextPositions(display)[line++] + y, uptimeStr); } else { line++; } @@ -1362,7 +1368,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta config.display.heading_bold = false; #if HAS_GPS - UIRenderer::drawGps(display, x, getTextPositions(display)[line], gpsStatus, compactPanel); + UIRenderer::drawGps(display, x + BASEUI_BODY_LR_MARGIN, getTextPositions(display)[line] + y, gpsStatus, compactPanel); #endif #if defined(OLED_TINY) @@ -1374,7 +1380,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta char chUtilStr[16]; snprintf(chUtilStr, sizeof(chUtilStr), "ChUtil %d%%", chutil_percent); int chUtilWidth = display->getStringWidth(chUtilStr); - display->drawString((SCREEN_WIDTH - chUtilWidth) / 2, getTextPositions(display)[line++], chUtilStr); + display->drawString((SCREEN_WIDTH - chUtilWidth) / 2, getTextPositions(display)[line++] + y, chUtilStr); // === Node Identity: long name (falls back to short), truncated with "..." if too wide === const char *longName = (nodeInfoLiteHasUser(ourNode) && ourNode->long_name[0]) ? ourNode->long_name : ""; @@ -1384,14 +1390,14 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta UIRenderer::truncateStringWithEmotes(display, rawName, nodeName, sizeof(nodeName), SCREEN_WIDTH - 4); int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName); int nameX = (SCREEN_WIDTH - textWidth) / 2; - UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], nodeName, FONT_HEIGHT_SMALL, 1, + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, nodeName, FONT_HEIGHT_SMALL, 1, false); } else { // === Node Identity === const char *shortName = owner.short_name[0] ? owner.short_name : ""; int textWidth = UIRenderer::measureStringWithEmotes(display, shortName); int nameX = (SCREEN_WIDTH - textWidth) / 2; - UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, shortName, FONT_HEIGHT_SMALL, 1, false); } #else @@ -1400,9 +1406,11 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta int batV = powerStatus->getBatteryVoltageMv() / 1000; int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; snprintf(batStr, sizeof(batStr), "%01d.%02dV", batV, batCv); - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(batStr), getTextPositions(display)[line++], batStr); + display->drawString(x + SCREEN_WIDTH - BASEUI_BODY_LR_MARGIN - display->getStringWidth(batStr), + getTextPositions(display)[line++] + y, batStr); } else { - display->drawString(x + SCREEN_WIDTH - display->getStringWidth("USB"), getTextPositions(display)[line++], "USB"); + display->drawString(x + SCREEN_WIDTH - BASEUI_BODY_LR_MARGIN - display->getStringWidth("USB"), + getTextPositions(display)[line++] + y, "USB"); } config.display.heading_bold = origBold; @@ -1413,9 +1421,8 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta int chutil_percent = static_cast(airTime->channelUtilizationPercent() + 0.5f); snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%d%%", chutil_percent); - int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10 - : display->getStringWidth(chUtil) + 5; - int chUtil_y = getTextPositions(display)[line] + 3; + int chUtil_width = display->getStringWidth(chUtil); + int chUtil_y = getTextPositions(display)[line] + 3 + y; int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50; int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border @@ -1433,10 +1440,15 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta } const int raw_chutil_percent = chutil_percent; - // With BT disabled we pin this row left to make room for the extra "BT off" indicator. - const int starting_position = config.bluetooth.enabled ? x : 0; + // Center the row; with BT disabled reserve the width of the extra "BT off" indicator. + int starting_position = + (SCREEN_WIDTH - chUtil_width - chutil_bar_width - extraoffset - display->getStringWidth(chUtilPercentage)); + if (!config.bluetooth.enabled) { + starting_position -= (display->getStringWidth("BT off") + extraoffset); + } + starting_position /= 2; - display->drawString(starting_position, getTextPositions(display)[line], chUtil); + display->drawString(starting_position, getTextPositions(display)[line] + y, chUtil); // Force 61% or higher to show a full 100% bar, text would still show related percent. if (chutil_percent >= 61) { @@ -1446,7 +1458,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta int fillRight = computeChannelUtilizationFill(chutil_percent, chutil_bar_max_fill); // Draw outline - display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height); + display->drawRect(starting_position + chUtil_width, chUtil_y, chutil_bar_width, chutil_bar_height); // Fill progress if (fillRight > 0) { @@ -1458,16 +1470,18 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta UtilizationFillColor = TFTPalette::Medium; } setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, - starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); + starting_position + chUtil_width + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); #endif - display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); + display->fillRect(starting_position + chUtil_width + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); } - display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line], + display->drawString(starting_position + chUtil_width + chutil_bar_width + extraoffset, getTextPositions(display)[line] + y, chUtilPercentage); if (!config.bluetooth.enabled) { - display->drawString(SCREEN_WIDTH - display->getStringWidth("BT off"), getTextPositions(display)[line], "BT off"); + display->drawString(starting_position + chUtil_width + chutil_bar_width + extraoffset + + display->getStringWidth(chUtilPercentage) + extraoffset, + getTextPositions(display)[line] + y, "BT off"); } line += 1; @@ -1491,21 +1505,28 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) { textWidth = UIRenderer::measureStringWithEmotes(display, combinedName); nameX = (SCREEN_WIDTH - textWidth) / 2; - UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + yOffset, combinedName, + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + yOffset + y, combinedName, FONT_HEIGHT_SMALL, 1, false); } else { // === LongName Centered === textWidth = UIRenderer::measureStringWithEmotes(display, longName); nameX = (SCREEN_WIDTH - textWidth) / 2; - UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], longName, FONT_HEIGHT_SMALL, 1, + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, longName, FONT_HEIGHT_SMALL, 1, false); // === ShortName Centered === textWidth = UIRenderer::measureStringWithEmotes(display, shortName); nameX = (SCREEN_WIDTH - textWidth) / 2; - UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, shortName, FONT_HEIGHT_SMALL, 1, false); } +#ifdef SHOW_STEP_COUNTER + std::string stepsLine = "Steps: " + std::to_string(screen->steps); + textWidth = UIRenderer::measureStringWithEmotes(display, stepsLine.c_str()); + nameX = (SCREEN_WIDTH - textWidth) / 2; + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + y, stepsLine.c_str(), FONT_HEIGHT_SMALL, + 1, false); +#endif #endif graphics::drawCommonFooter(display, x, y); } @@ -1776,6 +1797,7 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU // === Header === graphics::drawCommonHeader(display, x, y, titleStr); + y += BASEUI_BELOW_HEADER_MARGIN; const int *textPos = getTextPositions(display); const bool compactPanel = graphics::isCompactPanel(display); @@ -1804,7 +1826,7 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU bool origBold = config.display.heading_bold; config.display.heading_bold = false; - UIRenderer::drawGps(display, x, textPos[line++], gpsStatus, compactPanel); + UIRenderer::drawGps(display, x + BASEUI_BODY_LR_MARGIN, textPos[line++] + y, gpsStatus, compactPanel); config.display.heading_bold = origBold; @@ -1914,18 +1936,18 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), true); #endif - display->drawString(0, textPos[line++], uptimeStr); + display->drawString(x + BASEUI_BODY_LR_MARGIN, textPos[line++] + y, uptimeStr); } else { - display->drawString(0, textPos[line++], "Last: ?"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, textPos[line++] + y, "Last: ?"); } // === Third Row: Line 1 GPS Info === - UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line1"); + UIRenderer::drawGpsCoordinates(display, x, textPos[line++] + y, gpsStatus, "line1"); if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC && uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) { // === Fourth Row: Line 2 GPS Info === - UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line2"); + UIRenderer::drawGpsCoordinates(display, x, textPos[line++] + y, gpsStatus, "line2"); } // === Final Row: Altitude === @@ -1936,21 +1958,21 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU } else { snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0im", alt); } - display->drawString(x, textPos[line++], altitudeLine); + display->drawString(x + BASEUI_BODY_LR_MARGIN, textPos[line++] + y, altitudeLine); } #if !defined(OLED_TINY) // === Draw Compass === if (validHeading || statusLine1) { // --- Compass Rendering: landscape (wide) screens use original side-aligned logic --- if (SCREEN_WIDTH > SCREEN_HEIGHT) { - const int16_t topY = textPos[1]; - const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height + const int16_t topY = textPos[1] + y; + const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1) - y; // nav row height const int16_t usableHeight = bottomY - topY - 5; int16_t compassRadius = usableHeight / 2; if (compassRadius < 8) compassRadius = 8; - const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8; + const int16_t compassX = x + BASEUI_BODY_LR_MARGIN + SCREEN_WIDTH - compassRadius - 8; // Center vertically and nudge down slightly to keep "N" clear of header const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; @@ -2085,7 +2107,11 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta lastFrameChangeTime = millis(); } +#ifdef OLED_HUGE + const int iconSize = 24; +#else const int iconSize = (currentResolution == ScreenResolution::High) ? 16 : 8; +#endif const int spacing = (currentResolution == ScreenResolution::High) ? 8 : 4; const int bigOffset = (currentResolution == ScreenResolution::High) ? 1 : 0; const bool compactPanel = graphics::isCompactPanel(display); @@ -2153,7 +2179,11 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta } #endif +#if BASEUI_HEADER_LR_MARGIN + const int navPadding = BASEUI_HEADER_LR_MARGIN; +#else const int navPadding = compactPanel ? 8 : ((currentResolution == ScreenResolution::High) ? 24 : 12); +#endif int usableWidth = SCREEN_WIDTH - (navPadding * 2); if (usableWidth < iconSize) @@ -2253,12 +2283,15 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta display->setColor(BLACK); #endif } - +#ifdef OLED_HUGE + NodeListRenderer::drawScaledXBitmap3x(x, y, 8, 8, icon, display); +#else if (currentResolution == ScreenResolution::High) { NodeListRenderer::drawScaledXBitmap16x16(x, y, 8, 8, icon, display); } else { display->drawXbm(x, y, iconSize, iconSize, icon); } +#endif if (isActive) { display->setColor(WHITE); diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index d66406abf..528b8fdff 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -52,6 +52,10 @@ class UIRenderer // though drawNavigationBar itself never ran while the screen (and its OSThread) was off. static void notifyScreenWoke(); + // screen frames + // First two pointers are self explanatory + // x and y are the offset everything should be drawn at, to support sliding transitions between frames. + static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); // Compact panels: toggle between compass+distance view and status/telemetry view static void scrollFavoriteDown(); diff --git a/src/input/TouchScreenBase.cpp b/src/input/TouchScreenBase.cpp index 2e39e52d6..8512300f7 100644 --- a/src/input/TouchScreenBase.cpp +++ b/src/input/TouchScreenBase.cpp @@ -192,7 +192,7 @@ int32_t TouchScreenBase::runOnce() void TouchScreenBase::hapticFeedback() { -#ifdef T_WATCH_S3 +#if defined(T_WATCH_S3) || defined(T_WATCH_ULTRA) drv.setWaveform(0, 75); drv.setWaveform(1, 0); // end waveform drv.go(); diff --git a/src/main.cpp b/src/main.cpp index df87cfcde..3f606706f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -322,6 +322,8 @@ __attribute__((weak, noinline)) bool loopCanSleep() __attribute__((noinline)) void lateInitVariant() __attribute__((weak)); __attribute__((noinline)) void lateInitVariant() {} +// earlyInitVariant() runs before consoleInit(): a LOG_* macro here CRASHES the device, +// it is not a silent no-op. Defer any logging to lateInitVariant() or later. __attribute__((noinline)) void earlyInitVariant() __attribute__((weak)); __attribute__((noinline)) void earlyInitVariant() {} diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e2557e8d0..ca4b30155 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1033,7 +1033,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \ defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2) || \ - defined(ELECROW_ThinkNode_M9)) && \ + defined(ELECROW_ThinkNode_M9) || defined(T_WATCH_ULTRA)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; @@ -1117,7 +1117,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.display.wake_on_tap_or_motion = true; #endif -#if defined(T_WATCH_S3) || defined(SENSECAP_INDICATOR) +#if defined(T_WATCH_S3) || defined(SENSECAP_INDICATOR) || defined(T_WATCH_ULTRA) config.display.screen_on_secs = 30; config.display.wake_on_tap_or_motion = true; #endif diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 001301275..7551ac7bb 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -211,10 +211,13 @@ void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, snprintf(header, sizeof(header), "To: @%s", getNodeName(this->dest)); } - const int maxWidth = std::max(0, display->getWidth() - x); + // First row of text: inset horizontally by the header L/R margin and pushed down by the header margin + const int headerX = x + BASEUI_HEADER_LR_MARGIN; + const int headerY = y + BASEUI_HEADER_MARGIN; + const int maxWidth = std::max(0, display->getWidth() - headerX - BASEUI_HEADER_LR_MARGIN); char truncatedHeader[96]; graphics::UIRenderer::truncateStringWithEmotes(display, header, truncatedHeader, sizeof(truncatedHeader), maxWidth); - graphics::UIRenderer::drawStringWithEmotes(display, x, y, truncatedHeader, FONT_HEIGHT_SMALL, 1, false); + graphics::UIRenderer::drawStringWithEmotes(display, headerX, headerY, truncatedHeader, FONT_HEIGHT_SMALL, 1, false); } void CannedMessageModule::resetSearch() @@ -1475,7 +1478,9 @@ void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState { int outerSize = *(&this->keyboard[this->charSet] + 1) - this->keyboard[this->charSet]; - int xOffset = 0; + // Inset the key grid horizontally by the body L/R margin (keeps touch aligned since + // keyForCoordinates() reads the same per-key rects stored below) + int xOffset = BASEUI_BODY_LR_MARGIN; int yOffset = 56; @@ -1485,7 +1490,8 @@ void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState display->setColor(OLEDDISPLAY_COLOR::WHITE); - display->drawStringMaxWidth(0, 0, display->getWidth(), + // Free text being typed is the first row of text: inset by header margins + display->drawStringMaxWidth(BASEUI_HEADER_LR_MARGIN, BASEUI_HEADER_MARGIN, display->getWidth() - 2 * BASEUI_HEADER_LR_MARGIN, cannedMessageModule->drawWithCursor(cannedMessageModule->freetext, cannedMessageModule->cursor)); display->setFont(FONT_MEDIUM); @@ -1507,7 +1513,7 @@ void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState } } - int cellWidth = display->width() / innerSize; + int cellWidth = (display->width() - 2 * BASEUI_BODY_LR_MARGIN) / innerSize; for (int8_t innerIndex = 0; innerIndex < innerSize; innerIndex++) { xOffset += innerIndex > 0 ? cellWidth : 0; @@ -1580,7 +1586,7 @@ void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState } } - xOffset = 0; + xOffset = BASEUI_BODY_LR_MARGIN; } this->highlight = 0x00; @@ -1670,8 +1676,8 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - // Header - int titleY = 2; + // Header (first row): pushed down by the header margin; centered, so no L/R inset needed + int titleY = 2 + BASEUI_HEADER_MARGIN; String titleText = "Select Destination"; titleText += searchQuery.length() > 0 ? " [" + searchQuery + "]" : " [ ]"; display->setTextAlignment(TEXT_ALIGN_CENTER); @@ -1723,7 +1729,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O } } - int availWidth = display->getWidth() - + int availWidth = display->getWidth() - 2 * BASEUI_BODY_LR_MARGIN - ((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) - ((nodeInfoLiteIsFavorite(node)) ? 10 : 0); if (availWidth < 0) @@ -1749,12 +1755,14 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O // Highlight background (if selected) if (itemIndex == destIndex) { int scrollPadding = 8; // Reserve space for scrollbar - display->fillRect(0, yOffset + 2, display->getWidth() - scrollPadding, FONT_HEIGHT_SMALL - 5); + display->fillRect(BASEUI_BODY_LR_MARGIN, yOffset + 2, display->getWidth() - scrollPadding - 2 * BASEUI_BODY_LR_MARGIN, + FONT_HEIGHT_SMALL - 5); display->setColor(BLACK); } // Draw entry text - graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2, yOffset, entryText.c_str(), FONT_HEIGHT_SMALL, 1, false); + graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2 + BASEUI_BODY_LR_MARGIN, yOffset, entryText.c_str(), + FONT_HEIGHT_SMALL, 1, false); display->setColor(WHITE); // Draw key icon (after highlight) @@ -1783,7 +1791,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O if (totalEntries > visibleRows) { int scrollbarHeight = visibleRows * (FONT_HEIGHT_SMALL - 4); int totalScrollable = totalEntries; - int scrollTrackX = display->getWidth() - 6; + int scrollTrackX = display->getWidth() - 6 - BASEUI_BODY_LR_MARGIN; display->drawRect(scrollTrackX, rowYOffset, 4, scrollbarHeight); int scrollHeight = (scrollbarHeight * visibleRows) / totalScrollable; int scrollPos = rowYOffset + (scrollbarHeight * scrollIndex) / totalScrollable; @@ -1801,8 +1809,8 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla const int maxEmoteHeight = graphics::EmoteRenderer::maxEmoteHeight(); const int rowHeight = maxEmoteHeight + 2; - // Place header at top, then compute start of emote list - int headerY = y; + // Place header at top (pushed down by the header margin), then compute start of emote list + int headerY = y + BASEUI_HEADER_MARGIN; int listTop = headerY + headerFontHeight + headerMargin; int _visibleRows = (display->getHeight() - listTop - 2) / rowHeight; @@ -1841,12 +1849,13 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla // Draw highlight box 2px taller than emote (1px margin above and below) if (emoteIdx == emotePickerIndex) { - display->fillRect(x, rowY, display->getWidth() - 8, emote.height + 2); + display->fillRect(x + BASEUI_BODY_LR_MARGIN, rowY, display->getWidth() - 8 - 2 * BASEUI_BODY_LR_MARGIN, + emote.height + 2); display->setColor(BLACK); } // Emote bitmap (left), centered inside the row - int labelStartX = x + bitmapGapX; + int labelStartX = x + BASEUI_BODY_LR_MARGIN + bitmapGapX; const int emoteY = rowY + ((rowHeight - emote.height) / 2); display->drawXbm(labelStartX, emoteY, emote.width, emote.height, emote.bitmap); labelStartX += emote.width; @@ -1863,7 +1872,7 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla // Draw scrollbar if needed if (numEmotes > _visibleRows) { int scrollbarHeight = _visibleRows * rowHeight; - int scrollTrackX = display->getWidth() - 6; + int scrollTrackX = display->getWidth() - 6 - BASEUI_BODY_LR_MARGIN; display->drawRect(scrollTrackX, listTop, 4, scrollbarHeight); int scrollBarLen = std::max(6, (scrollbarHeight * _visibleRows) / numEmotes); int scrollBarPos = listTop + (scrollbarHeight * topIndex) / numEmotes; @@ -1900,7 +1909,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st if (this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) { display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawString(10 + x, 0 + y + FONT_HEIGHT_SMALL, "Canned Message\nModule disabled."); + display->drawString(10 + x + BASEUI_BODY_LR_MARGIN, y + FONT_HEIGHT_SMALL + BASEUI_HEADER_MARGIN, + "Canned Message\nModule disabled."); return; } @@ -1927,7 +1937,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st uint16_t charsLeft = meshtastic_Constants_DATA_PAYLOAD_LEN - this->freetext.length() - (moduleConfig.canned_message.send_bell ? 1 : 0); snprintf(buffer, sizeof(buffer), "%d left", charsLeft); - display->drawString(x + display->getWidth() - display->getStringWidth(buffer), y + 0, buffer); + display->drawString(x + display->getWidth() - display->getStringWidth(buffer) - BASEUI_HEADER_LR_MARGIN, + y + BASEUI_HEADER_MARGIN, buffer); } #if INPUTBROKER_SERIAL_TYPE == 1 @@ -2014,9 +2025,11 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st // Draw Free Text input with multi-emote support and proper line wrapping display->setColor(WHITE); { - int inputY = 0 + y + FONT_HEIGHT_SMALL; + int inputY = y + FONT_HEIGHT_SMALL + BASEUI_HEADER_MARGIN; + int inputX = x + BASEUI_BODY_LR_MARGIN; String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor); - drawWrappedEmoteText(display, x, inputY, msgWithCursor.c_str(), display->getWidth() - x, FONT_HEIGHT_SMALL); + drawWrappedEmoteText(display, inputX, inputY, msgWithCursor.c_str(), + display->getWidth() - inputX - BASEUI_BODY_LR_MARGIN, FONT_HEIGHT_SMALL); } #endif return; @@ -2037,7 +2050,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st drawHeader(display, x, y, buffer); // Shift message list upward by 3 pixels to reduce spacing between header and first message - const int listYOffset = y + FONT_HEIGHT_SMALL - 3; + // Push the list below the header margin so the body starts clear of the reserved top area + const int listYOffset = y + FONT_HEIGHT_SMALL - 3 + BASEUI_HEADER_MARGIN; _visibleRows = (display->getHeight() - listYOffset) / baseRowSpacing; // Figure out which messages are visible and their needed heights @@ -2059,16 +2073,17 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st int textYOffset = (rowHeight - FONT_HEIGHT_SMALL) / 2; #ifdef USE_EINK - int nextX = x + (_highlight ? 12 : 0); + int nextX = x + BASEUI_BODY_LR_MARGIN + (_highlight ? 12 : 0); if (_highlight) - display->drawString(x + 0, lineY + textYOffset, ">"); + display->drawString(x + BASEUI_BODY_LR_MARGIN, lineY + textYOffset, ">"); #else int scrollPadding = 8; if (_highlight) { - display->fillRect(x + 0, lineY, display->getWidth() - scrollPadding, rowHeight); + display->fillRect(x + BASEUI_BODY_LR_MARGIN, lineY, + display->getWidth() - scrollPadding - 2 * BASEUI_BODY_LR_MARGIN, rowHeight); display->setColor(BLACK); } - int nextX = x + (_highlight ? 2 : 0); + int nextX = x + BASEUI_BODY_LR_MARGIN + (_highlight ? 2 : 0); #endif if (msg && *msg) @@ -2084,7 +2099,7 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st // Scrollbar if (messagesCount > _visibleRows) { int scrollHeight = display->getHeight() - listYOffset; - int scrollTrackX = display->getWidth() - 6; + int scrollTrackX = display->getWidth() - 6 - BASEUI_BODY_LR_MARGIN; display->drawRect(scrollTrackX, listYOffset, 4, scrollHeight); int barHeight = (scrollHeight * _visibleRows) / messagesCount; int scrollPos = listYOffset + (scrollHeight * topMsg) / messagesCount; diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 40ad53d3a..f11839bd7 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -71,7 +71,7 @@ bool PositionModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes if (config.position.fixed_position) { LOG_DEBUG("Ignore own position update except time: position.fixed_position true"); -#ifdef T_WATCH_S3 +#if defined(T_WATCH_S3) || defined(T_WATCH_ULTRA) // Since we return early if position.fixed_position is true, set the T-Watch's RTC to the time received from the // client device here if (p.time && channels.getByIndex(mp.channel).role == meshtastic_Channel_Role_PRIMARY) { diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 63009aecc..8657a13e8 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -30,6 +30,9 @@ #ifdef HAS_STK8XXX #include "STK8XXXSensor.h" #endif +#ifdef HAS_BHI260AP +#include "BHI260APSensor.h" +#endif extern ScanI2C::DeviceAddress accelerometer_found; @@ -152,6 +155,11 @@ class AccelerometerThread : public concurrency::OSThread case ScanI2C::DeviceType::QMI8658: sensor.reset(new QMI8658Sensor(device)); break; +#endif +#ifdef HAS_BHI260AP + case ScanI2C::DeviceType::BHI260AP: + sensor.reset(new BHI260APSensor(device)); + break; #endif default: disable(); diff --git a/src/motion/BHI260APSensor.cpp b/src/motion/BHI260APSensor.cpp new file mode 100644 index 000000000..90c6e1f82 --- /dev/null +++ b/src/motion/BHI260APSensor.cpp @@ -0,0 +1,80 @@ +#include "BHI260APSensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && defined(HAS_BHI260AP) && __has_include() +#define BOSCH_BHI260_KLIO +#define USING_DATA_HELPER + +#include +BHI260APSensor::BHI260APSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} +// https://github.com/lewisxhe/SensorLib/blob/master/examples/Sensors/IMU/BHI260AP_InterruptSettings/BHI260AP_InterruptSettings.ino + +bool BHI260APSensor::init() +{ + LOG_WARN("Initializing BHI260AP sensor %u", deviceAddress()); + sensor.setFirmware(bosch_firmware_image, bosch_firmware_size, bosch_firmware_type); + sensor.setBootFromFlash(bosch_firmware_type); + if (sensor.begin(Wire, deviceAddress())) { + sensor.setRemapAxes(SensorBHI260AP::TOP_LAYER_BOTTOM_RIGHT_CORNER); + BoschSensorInfo info = sensor.getSensorInfo(); + + LOG_INFO("Product ID : %02x\n", info.product_id); + LOG_INFO("Kernel version : %04u\n", info.kernel_version); + LOG_INFO("User version : %04u\n", info.user_version); + LOG_INFO("ROM version : %04u\n", info.rom_version); + LOG_INFO("Power state : %s\n", (info.host_status & BHY2_HST_POWER_STATE) ? "sleeping" : "active"); + LOG_INFO("Host interface : %s\n", (info.host_status & BHY2_HST_HOST_PROTOCOL) ? "SPI" : "I2C"); + LOG_INFO("Feature status : 0x%02x\n", info.feat_status); + + stepCounter = new SensorStepCounter(sensor); + // stepDetector = new SensorStepDetector(sensor); + + // sensor.configAccelerometer(sensor.RANGE_2G, sensor.ODR_100HZ, sensor.BW_NORMAL_AVG4, sensor.PERF_CONTINUOUS_MODE); + // sensor.enableAccelerometer(); + // sensor.configInterrupt(); + +#ifdef BHI260AP_INT + pinMode(BHI260AP_INT, INPUT); + attachInterrupt( + BHI260AP_INT, + [] { + // Set interrupt to set irq value to true + }, + RISING); // Select the interrupt mode according to the actual circuit +#endif + +#ifdef T_WATCH_S3 + // Need to raise the wrist function, need to set the correct axis + sensor.setRemapAxes(sensor.REMAP_TOP_LAYER_RIGHT_CORNER); +#else + // sensor.setRemapAxes(sensor.REMAP_BOTTOM_LAYER_BOTTOM_LEFT_CORNER); +#endif + + // stepDetector->enable(1.0, 0); + stepCounter->enable(1.0, 0); + LOG_DEBUG("BHI260AP init ok"); + return true; + } + LOG_DEBUG("BHI260AP init failed"); + return false; +} + +int32_t BHI260APSensor::runOnce() +{ + sensor.update(); + if (stepCounter->hasUpdated()) { + steps = stepCounter->getStepCount(); + LOG_WARN("Step count updated: %u", steps); + if (screen) + screen->steps = steps; + } + // LOG_WARN("Step count: %u", stepCounter->getStepCount()); + // if (sensor.readIrqStatus()) { + // if (sensor.isTilt() || sensor.isDoubleTap()) { + // wakeScreen(); + // return 500; + // } + //} + return 1000; +} + +#endif \ No newline at end of file diff --git a/src/motion/BHI260APSensor.h b/src/motion/BHI260APSensor.h new file mode 100644 index 000000000..b0a406487 --- /dev/null +++ b/src/motion/BHI260APSensor.h @@ -0,0 +1,31 @@ +#pragma once +#ifndef _BHI260AP_SENSOR_H_ +#define _BHI260AP_SENSOR_H_ + +#include "MotionSensor.h" + +#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && defined(HAS_BHI260AP) && __has_include() + +// Sensor lib +#include +#include +#include + +class BHI260APSensor : public MotionSensor +{ + private: + SensorBHI260AP sensor; + volatile bool BHI_IRQ = false; + SensorStepCounter *stepCounter; + SensorStepDetector *stepDetector; + uint32_t steps = 0; + + public: + explicit BHI260APSensor(ScanI2C::FoundDevice foundDevice); + virtual bool init() override; + virtual int32_t runOnce() override; +}; + +#endif + +#endif \ No newline at end of file diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 4cc4461cf..3f6a05d91 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -208,6 +208,8 @@ #define HW_VENDOR meshtastic_HardwareModel_M5STACK_C6L #elif defined(HELTEC_WIRELESS_TRACKER_V2) #define HW_VENDOR meshtastic_HardwareModel_HELTEC_WIRELESS_TRACKER_V2 +#elif defined(T_WATCH_ULTRA) +#define HW_VENDOR meshtastic_HardwareModel_T_WATCH_ULTRA #elif defined(M5STACK_CARDPUTER_ADV) #define HW_VENDOR meshtastic_HardwareModel_M5STACK_CARDPUTER_ADV #elif defined(MESHNOLOGY_W10) diff --git a/src/platform/esp32/esp_partition_read_mmap_wrap.c b/src/platform/esp32/esp_partition_read_mmap_wrap.c new file mode 100644 index 000000000..b8bf96d60 --- /dev/null +++ b/src/platform/esp32/esp_partition_read_mmap_wrap.c @@ -0,0 +1,42 @@ +// Workaround for the IDF 5.5 manual esp_flash read regression on t-watch-ultra. +// +// On this board (Winbond W25Q128JW, ef:8018), the IDF 5.5 *direct* flash read path +// (esp_flash_read / esp_partition_read) returns 0x00 for data that is physically +// correct on flash. We proved the *memory-mapped* (cache) read returns the right +// data, writes work, and it's not read-mode/HPM/timing-tuning/PSRAM. So route every +// esp_partition_read through esp_partition_mmap + memcpy, which uses the working +// cache path. Activated by `-Wl,--wrap=esp_partition_read` (t-watch-ultra only). +#if defined(T_WATCH_ULTRA) + +#include "esp_partition.h" +#include + +extern esp_err_t __real_esp_partition_read(const esp_partition_t *partition, size_t src_offset, void *dst, size_t size); + +esp_err_t __wrap_esp_partition_read(const esp_partition_t *partition, size_t src_offset, void *dst, size_t size) +{ + if (partition == NULL || dst == NULL) + return ESP_ERR_INVALID_ARG; + if (size == 0) + return ESP_OK; + + // mmap requires a 64KB-aligned start; map the containing page span and copy + // out from the requested offset. + const size_t PAGE = 0x10000; + size_t aligned = src_offset & ~(PAGE - 1); + size_t delta = src_offset - aligned; + + const void *ptr = NULL; + esp_partition_mmap_handle_t handle; + esp_err_t err = esp_partition_mmap(partition, aligned, delta + size, ESP_PARTITION_MMAP_DATA, &ptr, &handle); + if (err != ESP_OK) { + // Encrypted partitions / regions mmap can't serve: fall back to the real + // read (may be wrong on this board, but better than failing the call). + return __real_esp_partition_read(partition, src_offset, dst, size); + } + memcpy(dst, (const uint8_t *)ptr + delta, size); + esp_partition_munmap(handle); + return ESP_OK; +} + +#endif // T_WATCH_ULTRA diff --git a/src/platform/extra_variants/README.md b/src/platform/extra_variants/README.md index 838014c4f..3fd66a9a7 100644 --- a/src/platform/extra_variants/README.md +++ b/src/platform/extra_variants/README.md @@ -7,6 +7,8 @@ This directory tree is designed to solve two problems. So we are borrowing the initVariant() ideas here (by using weak gcc references). You can now define earlyInitVariant() and lateInitVariant() if your board needs them. earlyInitVariant() runs at the beginning of setup() directly after waitUntilPowerLevelSafe(); while lateInitVariant() runs after the LoRa radio is initialized. +**Important:** earlyInitVariant() runs _before_ consoleInit(), so the logging subsystem isn't set up yet. Calling a `LOG_*` macro there **crashes the device** - it is not a silent no-op. Never use `LOG_*` in earlyInitVariant(); defer any logging to lateInitVariant() or later. + If you'd like a board specific variant to be run, add the variant.cpp file to an appropriately named subdirectory and check for \_VARIANT_boardname in the cpp file (so that your code is only built for your board). You'll need to define \_VARIANT_boardname in your corresponding variant.h file. diff --git a/src/platform/extra_variants/t-watch-ultra/variant.cpp b/src/platform/extra_variants/t-watch-ultra/variant.cpp new file mode 100644 index 000000000..f77c1e97b --- /dev/null +++ b/src/platform/extra_variants/t-watch-ultra/variant.cpp @@ -0,0 +1,83 @@ +#include "configuration.h" + +#ifdef T_WATCH_ULTRA + +// Board-specific init lives here (rather than in variants/esp32s3/t-watch-ultra/variant.cpp) +// so that PlatformIO's library dependency finder can resolve headers such as +// input/TouchScreenImpl1.h (which transitively pulls in the ArduinoThread "Thread.h"), +// ExtensionIOXL9555.hpp and TouchDrvCSTXXX.hpp. Files compiled from outside src/ only get +// include paths for libraries they reference directly, so the transitive Thread.h include +// is not found there. See src/platform/extra_variants/README.md. + +#include "TouchDrvCSTXXX.hpp" +#include "input/TouchScreenImpl1.h" +#include +#include + +static ExtensionIOXL9555 io; +static TouchDrvCST92xx touchDrv; + +void earlyInitVariant() +{ + pinMode(LORA_CS, OUTPUT); + digitalWrite(LORA_CS, HIGH); + pinMode(DISP_CS, OUTPUT); + digitalWrite(DISP_CS, HIGH); + pinMode(SDCARD_CS, OUTPUT); + digitalWrite(SDCARD_CS, HIGH); + pinMode(NFC_CS, OUTPUT); + digitalWrite(NFC_CS, HIGH); + pinMode(I2C_SDA, INPUT_PULLUP); + pinMode(I2C_SCL, INPUT_PULLUP); + + if (io.begin(Wire, XL9555_SLAVE_ADDRESS0)) { + io.pinMode(EXPANDS_DRV_EN, OUTPUT); + io.digitalWrite(EXPANDS_DRV_EN, HIGH); + delay(1); + io.pinMode(EXPANDS_DISP_EN, OUTPUT); + io.digitalWrite(EXPANDS_DISP_EN, HIGH); + delay(1); + io.pinMode(EXPANDS_TOUCH_RST, OUTPUT); + io.digitalWrite(EXPANDS_TOUCH_RST, LOW); + delay(20); + io.digitalWrite(EXPANDS_TOUCH_RST, HIGH); + delay(60); + io.pinMode(EXPANDS_LORA_RF_SW, OUTPUT); + io.digitalWrite(EXPANDS_LORA_RF_SW, HIGH); // set RF switch to built-in LoRa antenna + // io.pinMode(EXPANDS_SD_DET, INPUT); + } + // NOTE: deliberately no LOG_* on the io.begin() failure path. earlyInitVariant() runs + // before consoleInit(), where calling a LOG_* macro crashes the device (see + // extra_variants/README.md). On failure the EXPANDS_* pins stay on their defaults. +} + +static bool readTouch(int16_t *x, int16_t *y) +{ + int16_t x_array[1], y_array[1]; + uint8_t touched = touchDrv.getPoint(x_array, y_array, 1); + if (touched > 0) { + *x = (x_array[0]); + *y = (y_array[0]); + // Check bounds + if (*x < 0 || *x >= TFT_WIDTH || *y < 0 || *y >= TFT_HEIGHT) { + return false; + } + return true; // Valid touch detected + } + return false; // No valid touch data +} + +void lateInitVariant() +{ + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + pinMode(SCREEN_TOUCH_INT, INPUT_PULLUP); + touchDrv.setPins(-1, SCREEN_TOUCH_INT); + if (touchDrv.begin(Wire, TOUCH_SLAVE_ADDRESS, -1, -1)) { + touchScreenImpl1 = new TouchScreenImpl1(TFT_WIDTH, TFT_HEIGHT, readTouch); + touchScreenImpl1->init(); + } else { + LOG_ERROR("failed to initialize CST92xx"); + } + } +} +#endif diff --git a/src/sleep.cpp b/src/sleep.cpp index 0a6a37978..6ed3084e1 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -370,7 +370,7 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN // t-beam v1.2 radio power channel PMU->disablePowerOutput(XPOWERS_ALDO2); // lora radio power channel } else if (HW_VENDOR == meshtastic_HardwareModel_LILYGO_TBEAM_S3_CORE || - HW_VENDOR == meshtastic_HardwareModel_T_WATCH_S3) { + HW_VENDOR == meshtastic_HardwareModel_T_WATCH_S3 || HW_VENDOR == meshtastic_HardwareModel_T_WATCH_ULTRA) { PMU->disablePowerOutput(XPOWERS_ALDO3); // lora radio power channel } } else if (model == XPOWERS_AXP192) { diff --git a/variants/esp32s3/t-deck-pro/variant.h b/variants/esp32s3/t-deck-pro/variant.h index d95f07f3a..8fa7e1740 100644 --- a/variants/esp32s3/t-deck-pro/variant.h +++ b/variants/esp32s3/t-deck-pro/variant.h @@ -30,6 +30,7 @@ // vibration motor #define PIN_VIBRATION 2 +#define HAS_DRV2605 1 // Have SPI interface SD card slot #define HAS_SDCARD diff --git a/variants/esp32s3/t-watch-ultra/pins_arduino.h b/variants/esp32s3/t-watch-ultra/pins_arduino.h new file mode 100644 index 000000000..18d029ef8 --- /dev/null +++ b/variants/esp32s3/t-watch-ultra/pins_arduino.h @@ -0,0 +1,94 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +// #ifndef digitalPinToInterrupt +// #define digitalPinToInterrupt(p) (((p) < 48) ? (p) : -1) +// #endif + +#define USB_VID 0x303a +#define USB_PID 0x8227 +#define USB_MANUFACTURER "LILYGO" +#define USB_PRODUCT "T-Watch-Ultra" + +#define DISP_WIDTH 502 +#define DISP_HEIGHT 410 + +// QSPI interface display +#define DISP_D0 (38) +#define DISP_D1 (39) +#define DISP_D2 (42) +#define DISP_D3 (45) +#define DISP_SCK (40) +#define DISP_CS (41) +#define DISP_RST (37) +#define DISP_TE (6) + +// Interrupt IO port +#define TP_INT (12) +#define RTC_INT (1) +#define PMU_INT (7) +#define NFC_INT (5) +#define SENSOR_INT (8) +#define NFC_CS (4) + +// PDM microphone +#define MIC_SCK (17) +#define MIC_DAT (18) + +// MAX98357A +#define I2S_BCLK (9) +#define I2S_WCLK (10) +#define I2S_DOUT (11) + +#define SD_CS (21) + +// TX, RX pin connected to GPS +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +// BHI260,PCF85063,AXP2101,DRV2605L share I2C Bus +static const uint8_t SDA = 3; +static const uint8_t SCL = 2; + +// Default sd cs pin +static const uint8_t SS = SD_CS; +static const uint8_t MOSI = 34; +static const uint8_t MISO = 33; +static const uint8_t SCK = 35; + +#define GPS_TX (TX) +#define GPS_RX (RX) +#define GPS_PPS (13) + +#define TP_SDA (SDA) +#define TP_SCL (SCL) + +// LoRa and SD card share SPI bus -> variant.h +// #define LORA_SCK (SCK) // share spi bus +// #define LORA_MISO (MISO) // share spi bus +// #define LORA_MOSI (MOSI) // share spi bus +// #define LORA_CS (36) +// #define LORA_RST (47) +// #define LORA_BUSY (48) +// #define LORA_IRQ (14) + +// External expansion chip IO definition +#define EXPANDS_DRV_EN (6) +#define EXPANDS_DISP_EN (7) +#define EXPANDS_TOUCH_RST (8) +#define EXPANDS_SD_DET (10) +#define EXPANDS_LORA_RF_SW (11) + +// Peripheral definition exists +#define USING_XL9555_EXPANDS +#define USING_PCM_AMPLIFIER +#define USING_PDM_MICROPHONE +#define USING_PMU_MANAGE +#define USING_INPUT_DEV_TOUCHPAD +#define USING_ST25R3916 +#define USING_BHI260_SENSOR +#define HAS_SD_CARD_SOCKET + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/t-watch-ultra/platformio.ini b/variants/esp32s3/t-watch-ultra/platformio.ini new file mode 100644 index 000000000..8d242d4a3 --- /dev/null +++ b/variants/esp32s3/t-watch-ultra/platformio.ini @@ -0,0 +1,96 @@ +; LilyGo T-Watch S3 +[env:t-watch-ultra] +custom_meshtastic_hw_model = 114 +custom_meshtastic_hw_model_slug = T_WATCH_ULTRA +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = T-Watch Ultra +custom_meshtastic_images = t-watch-ultra.svg +custom_meshtastic_tags = LilyGo +custom_meshtastic_requires_dfu = false +custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true + +extends = esp32s3_base +board = t-watch-ultra +board_level = release +board_build.partitions = default_16MB.csv +upload_protocol = esptool + +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + ; Keep esp_littlefs buffers in internal RAM (off the shared-bus PSRAM). + CONFIG_LITTLEFS_MALLOC_STRATEGY_INTERNAL=y + CONFIG_SPI_FLASH_SHARE_SPI1_BUS=y + +build_flags = ${esp32_base.build_flags} -Ivariants/esp32s3/t-watch-ultra + ; Route flash reads through the cache/mmap path (esp_partition_read_mmap_wrap.c) + ; to dodge the IDF 5.5 manual-read regression on this board's flash. + -Wl,--wrap=esp_partition_read + -D T_WATCH_ULTRA + -D RADIOLIB_EXCLUDE_SX128X=1 + -D RADIOLIB_EXCLUDE_SX127X=1 + -D RADIOLIB_EXCLUDE_LR11X0=1 + -UMESHTASTIC_EXCLUDE_ACCELEROMETER + -D HAS_SDCARD + -D SDCARD_USE_SPI1 + -D SD_SPI_FREQUENCY=75000000 + -D SPI_MISO=33 + -D SPI_MOSI=34 + -D SPI_SCK=35 + -D SDCARD_CS=21 +; -DHAS_BMA423=1 + +build_src_filter = + ${esp32s3_base.build_src_filter} + +<../variants/esp32s3/t-watch-ultra> + +lib_deps = ${esp32s3_base.lib_deps} + https://github.com/lovyan03/LovyanGFX/archive/tags/1.2.27.zip + adafruit/Adafruit DRV2605 Library@^1.2.4 + # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix + https://github.com/earlephilhower/ESP8266Audio/archive/05f2fb0045cc294b4e0d1a1a9747b89c22c1fea4.zip + # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM + earlephilhower/ESP8266SAM@1.1.0 + lewisxhe/SensorLib@0.3.1 + +[env:t-watch-ultra-tft] +board_level = extra +extends = env:t-watch-ultra +build_flags = + ${env:t-watch-ultra.build_flags} + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D INPUTDRIVER_BUTTON_TYPE=0 + -D HAS_SCREEN=1 + -D HAS_TFT=1 + -D USE_I2S_BUZZER + -D RAM_SIZE=5120 + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE + -D LV_USE_SYSMON=0 + -D LV_USE_PROFILER=0 + -D LV_USE_PERF_MONITOR=0 + -D LV_USE_MEM_MONITOR=0 + -D LV_USE_LOG=0 + -D USE_LOG_DEBUG + -D LOG_DEBUG_INC=\"DebugConfiguration.h\" + -D RADIOLIB_SPI_PARANOID=0 + -D LGFX_SCREEN_WIDTH=410 + -D LGFX_SCREEN_HEIGHT=502 + -D LGFX_AMOLED_ROUNDER=1 + -D LGFX_BUFSIZE=308732 + -D DISPLAY_SIZE=410x502 ; portrait mode + -D DISPLAY_SET_RESOLUTION + -D LGFX_DRIVER=LGFX_TWATCH_ULTRA + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_T_WATCH_ULTRA.h\" +; -D LVGL_DRIVER=LVGL_T_WATCH_ULTRA + -D VIEW_320x240 + -D USE_PACKET_API + -D MAP_FULL_REDRAW + -D CUSTOM_TOUCH_DRIVER + +lib_deps = + ${env:t-watch-ultra.lib_deps} + ${device-ui_base.lib_deps} \ No newline at end of file diff --git a/variants/esp32s3/t-watch-ultra/variant.h b/variants/esp32s3/t-watch-ultra/variant.h new file mode 100644 index 000000000..22f4f62d0 --- /dev/null +++ b/variants/esp32s3/t-watch-ultra/variant.h @@ -0,0 +1,101 @@ + +// CO5300 TFT AMOLED +#define CO5300_CS 41 +#define CO5300_SCK 40 +#define CO5300_RESET 37 +#define CO5300_TE 6 +#define CO5300_IO0 38 +#define CO5300_IO1 39 +#define CO5300_IO2 42 +#define CO5300_IO3 45 +#define CO5300_SPI_HOST SPI2_HOST +#define SPI_FREQUENCY 75000000 +#define SPI_READ_FREQUENCY 16000000 // irrelevant +#define TFT_HEIGHT 502 +#define TFT_WIDTH 410 +#define TFT_OFFSET_X 22 +#define TFT_OFFSET_Y 0 +#define TFT_OFFSET_ROTATION 0 +#define SCREEN_TRANSITION_FRAMERATE 5 // fps +#define USE_TFTDISPLAY 1 +#define HAS_SCREEN 1 +#define TFT_RESET_AFTER_SLEEP +#define OLED_HUGE +#define ROUNDED_SCREEN true +#define BASEUI_HEADER_MARGIN 15 +#define BASEUI_HEADER_LR_MARGIN 55 +#define BASEUI_BELOW_HEADER_MARGIN 15 +#define BASEUI_BODY_LR_MARGIN 35 + +#define HAS_TOUCHSCREEN 1 +#define HAS_SPI_TFT 1 +#define ENABLE_TOUCH_INT 1 +#define VARIANT_TOUCHSCREEN 1 +#define SCREEN_TOUCH_INT 12 +#define TOUCH_I2C_PORT 0 +#define TOUCH_SLAVE_ADDRESS 0x1A +#define WAKE_ON_TOUCH + +#define BUTTON_PIN 0 + +#define USE_POWERSAVE +#define SLEEP_TIME 120 + +// External expansion chip XL9555 +#define USE_XL9555 + +// PCF85063 RTC Module +#define PCF85063_RTC 0x51 +#define HAS_RTC 1 + +// MAX98357A +#define HAS_I2S +#define DAC_I2S_BCK 9 +#define DAC_I2S_WS 10 +#define DAC_I2S_DOUT 11 +#define DAC_I2S_MCLK -1 // TODO + +#define HAS_AXP2101 +#define PMU_IRQ 7 +#define PMU_POWER_BUTTON_IS_CANCEL +#define HAS_DRV2605 1 + +#define HAS_BHI260AP +#define BHI260AP_INT 8 +#undef MESHTASTIC_EXCLUDE_ACCELEROMETER +#define SHOW_STEP_COUNTER + +#define I2C_SDA 3 +#define I2C_SCL 2 +#define I2C_NO_RESCAN + +#define HAS_GPS 1 +#define GPS_BAUDRATE 38400 +#define GPS_RX_PIN 44 +#define GPS_TX_PIN 43 +#define PIN_GPS_PPS 13 + +#define USE_SX1262 +// #define USE_SX1280 +#define HW_SPI1_DEVICE + +#define LORA_SCK 35 +#define LORA_MISO 33 +#define LORA_MOSI 34 +#define LORA_CS 36 + +#define LORA_DIO0 -1 // a No connect on the SX1262 module +#define LORA_RESET 47 +#define LORA_DIO1 14 // SX1262 IRQ +#define LORA_DIO2 48 // SX1262 BUSY +#define LORA_DIO3 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define USE_VIRTUAL_KEYBOARD 1 +#define DISPLAY_CLOCK_FRAME 1 diff --git a/variants/nrf52840/t-echo-plus/variant.h b/variants/nrf52840/t-echo-plus/variant.h index 7ebdf48c0..edc6ff66a 100644 --- a/variants/nrf52840/t-echo-plus/variant.h +++ b/variants/nrf52840/t-echo-plus/variant.h @@ -59,7 +59,7 @@ static const uint8_t A0 = PIN_A0; #define WIRE_INTERFACES_COUNT 1 #define PIN_WIRE_SDA (0 + 26) #define PIN_WIRE_SCL (0 + 27) -#define HAS_BHI260AP +// #define HAS_BHI260AP ; lewisxhe/SensorLib too big for nrf52 #define TP_SER_IO (0 + 11) From 68bfe015e6ab9ec2ab8f1657066898b7880eaf63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 20 Aug 2026 14:42:28 +0000 Subject: [PATCH 097/109] ci: build newly added variants in the PR matrix (#11549) * ci: build newly added variants in the PR matrix A new board declares board_level = release, so it gets no CI build until after merge. Build the first env of each platformio.ini added by a PR, regardless of board_level. Only added files qualify; adding an env to an existing config does not. * ci: also detect added variants in merge_group runs merge_group uses the same --level pr subset as pull_request, so a newly added variant was skipped there. Derive the diff base from github.event.merge_group.base_sha for those runs. * ci: fail the matrix step when the variant diff errors Process substitution hides the exit status, so a failed diff silently yielded an empty list and dropped the new board from the matrix. Capture into a variable so 'set -e' aborts the step instead. --- .github/workflows/main_matrix.yml | 27 ++++++++++++++++++++++++++- bin/generate_ci_matrix.py | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 91eb2690e..59cc44c8c 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -55,6 +55,9 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 + with: + # Needed to diff against the base branch for newly added variants. + fetch-depth: 0 - uses: actions/setup-python@v6 with: python-version: 3.x @@ -62,11 +65,33 @@ jobs: - run: pip install -U platformio - name: Generate matrix id: jsonStep + env: + BASE_REF: ${{ github.base_ref }} + MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} run: | + # A new board is 'release' and gets no CI until after merge, so force-build the + # first env of each ADDED variant config. A new env in an existing one does not count. + DIFF_BASE="" + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + git fetch --no-tags --depth=1 origin "$BASE_REF" + DIFF_BASE=$(git merge-base FETCH_HEAD HEAD) + elif [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then + DIFF_BASE="$MERGE_GROUP_BASE_SHA" + fi + ADDED_ARGS=() + if [[ -n "$DIFF_BASE" ]]; then + # Assign rather than pipe: a failing diff must abort the step under 'set -e', + # not silently yield an empty list and drop the new board from the matrix. + ADDED_CONFIGS=$(git diff --name-only --diff-filter=A \ + "$DIFF_BASE" HEAD -- 'variants/**/platformio.ini') + while IFS= read -r cfg; do + [[ -n "$cfg" ]] && ADDED_ARGS+=(--added-config "$cfg") + done <<<"$ADDED_CONFIGS" + fi # PRs and (for now) merge_group builds use the narrowed --level pr board # subset. Full-matrix builds run on push / schedule / workflow_dispatch. if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "merge_group" ]]; then - TARGETS=$(./bin/generate_ci_matrix.py all --level pr) + TARGETS=$(./bin/generate_ci_matrix.py all --level pr "${ADDED_ARGS[@]}") else TARGETS=$(./bin/generate_ci_matrix.py all) fi diff --git a/bin/generate_ci_matrix.py b/bin/generate_ci_matrix.py index c3235c279..02155b59a 100755 --- a/bin/generate_ci_matrix.py +++ b/bin/generate_ci_matrix.py @@ -23,10 +23,29 @@ parser.add_argument( default=[], help="Board level to build for (omit for the 'pr' + 'release' matrix)", ) +parser.add_argument( + "--added-config", + action="append", + default=[], + metavar="PATH", + help="platformio.ini added by this PR; its first env is built regardless of board_level", +) args = parser.parse_args() outlist = [] +# A brand-new board is normally 'release', so it would get no CI until after merge. +# Build the first env of each newly added config so it is compiled at least once. +forced_envs = set() +for added_path in args.added_config: + try: + with open(added_path, encoding="utf-8") as added_file: + first_env = re.search(r"^[ \t]*\[env:([^\]]+)\]", added_file.read(), re.MULTILINE) + except OSError: + continue + if first_env: + forced_envs.add(first_env.group(1).strip()) + cfg = ProjectConfig.get_instance() pio_envs = cfg.envs() @@ -69,6 +88,9 @@ for env in all_envs: # Always include board_level = 'pr' if env["board_level"] == "pr": outlist.append(env["ci"]) + # Include the first env of a platformio.ini added by this PR + elif env["ci"]["board"] in forced_envs: + outlist.append(env["ci"]) # Include board_level = 'extra' when requested elif "extra" in args.level and env["board_level"] == "extra": outlist.append(env["ci"]) From 1afcdabbe9a85205f5055fac5a9412fceff223e2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:47:19 +0000 Subject: [PATCH 098/109] chore(deps): update esp8266audio digest to 3430246 (#11557) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32s3/t-watch-ultra/platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/esp32s3/t-watch-ultra/platformio.ini b/variants/esp32s3/t-watch-ultra/platformio.ini index 8d242d4a3..cb2045447 100644 --- a/variants/esp32s3/t-watch-ultra/platformio.ini +++ b/variants/esp32s3/t-watch-ultra/platformio.ini @@ -50,7 +50,7 @@ lib_deps = ${esp32s3_base.lib_deps} https://github.com/lovyan03/LovyanGFX/archive/tags/1.2.27.zip adafruit/Adafruit DRV2605 Library@^1.2.4 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix - https://github.com/earlephilhower/ESP8266Audio/archive/05f2fb0045cc294b4e0d1a1a9747b89c22c1fea4.zip + https://github.com/earlephilhower/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM earlephilhower/ESP8266SAM@1.1.0 lewisxhe/SensorLib@0.3.1 From bc035bb8124975ce916e38f1677ec8ef9edec040 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:48:14 +0000 Subject: [PATCH 099/109] feat(lora): state a pinned userPrefs preset as the unset region's intent (#11507) * feat(lora): state a pinned userPrefs preset as the unset region's intent A vendor build can pin USERPREFS_LORACONFIG_MODEM_PRESET while leaving the region unset, so a fresh flash comes up as region UNSET plus a deliberate preset. Stock installs come up as region UNSET plus the LONG_FAST placeholder, and nothing in FromRadio told the two apart - so clients treat every unset-region node as factory-fresh and replace its preset with the region default as soon as the user picks a region. A mesh pinned to SHORT_TURBO loses every new node to LONG_FAST or LONG_TURBO, silently. getRegionPresetMap() now emits an UNSET entry when, and only when, the build pins a preset, stating that preset as both the group's sole entry and its default. Stock builds are unchanged on the wire: no UNSET entry, which clients already read as unconstrained. This is intent, not enforcement. supportsPreset() still accepts any known preset while the region is unset (#11496) and the radio is held silent either way, so the device continues to honour whatever the user or an admin sets. Costs one group slot and one region slot on pinned builds only (6->7 of 8, 34->35 of 38); exhaustion is logged and degrades to the existing unconstrained behaviour. * Trim comments to the project's one-to-two-line limit --- src/mesh/RadioInterface.cpp | 20 +++++++++++++++++ test/test_radio/test_main.cpp | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 58bd498c5..5db23ab54 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -744,6 +744,26 @@ void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map) rg.region = r->code; rg.group_index = (uint8_t)gi; } + +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + // A pinned preset is a statement of intent, not enforcement: supportsPreset() still accepts any + // known preset while unset. Stock builds emit no UNSET entry, which clients read as unconstrained. + if (map.groups_count < maxGroups && map.region_groups_count < maxRegions) { + const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + meshtastic_LoRaPresetGroup &grp = map.groups[map.groups_count]; + grp.presets_count = 1; + grp.presets[0] = USERPREFS_LORACONFIG_MODEM_PRESET; + grp.default_preset = USERPREFS_LORACONFIG_MODEM_PRESET; + grp.licensed_only = unset->profile->licensedOnly; + + meshtastic_LoRaRegionPresets &rg = map.region_groups[map.region_groups_count++]; + rg.region = unset->code; + rg.group_index = (uint8_t)map.groups_count++; + } else { + // Costs only the intent signal - clients fall back to unconstrained - but must not be silent. + LOG_ERROR("Region preset map full; UNSET intent omitted"); + } +#endif } /** diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index 4fb75631e..87f3f3724 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -298,7 +298,11 @@ static void test_regionPresetMap_coversAllRegionsWithinBounds() meshtastic_LoRaRegionPresetMap map; getRegionPresetMap(map); +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + const size_t known = countKnownRegions() + 1; // + the UNSET intent entry +#else const size_t known = countKnownRegions(); +#endif TEST_ASSERT_EQUAL_UINT((unsigned)known, (unsigned)map.region_groups_count); // Bounds derived from the generated nanopb arrays (mesh.options max_count), so @@ -334,6 +338,12 @@ static void test_regionPresetMap_matchesRegionTable() const meshtastic_LoRaPresetGroup &grp = map.groups[gi]; const RegionInfo *r = getRegion(code); +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + // UNSET states the pinned preset, not PROFILE_UNDEF's list, so the table checks below don't apply. + if (code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + continue; +#endif + // Group's list is non-empty and within the generated array bound. const size_t maxPresets = sizeof(grp.presets) / sizeof(grp.presets[0]); TEST_ASSERT_GREATER_THAN_UINT(0, grp.presets_count); @@ -373,6 +383,36 @@ static void test_regionPresetMap_matchesRegionTable() } } +// UNSET appears only when the build pins a preset, and then states exactly that preset. +// A stock build leaves it out entirely, which clients read as "unconstrained". +static void test_regionPresetMap_unsetCarriesUserprefsIntent() +{ + meshtastic_LoRaRegionPresetMap map; + getRegionPresetMap(map); + + const meshtastic_LoRaPresetGroup *grp = nullptr; + for (pb_size_t i = 0; i < map.region_groups_count; i++) + if (map.region_groups[i].region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + grp = &map.groups[map.region_groups[i].group_index]; + +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + const meshtastic_Config_LoRaConfig_ModemPreset pinned = USERPREFS_LORACONFIG_MODEM_PRESET; + TEST_ASSERT_NOT_NULL_MESSAGE(grp, "a build that pins a preset must state it for UNSET"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1, (unsigned)grp->presets_count, "the pinned preset is the sole entry"); + TEST_ASSERT_EQUAL(pinned, grp->presets[0]); + TEST_ASSERT_EQUAL(pinned, grp->default_preset); + TEST_ASSERT_FALSE_MESSAGE(grp->licensed_only, "UNSET is not a licensed-only region"); + + // Stating intent must not narrow what the device accepts: the firmware still takes any + // real preset while the region is unset (#11496), so the map cannot become enforcement. + const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST)); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO)); +#else + TEST_ASSERT_NULL_MESSAGE(grp, "a stock build must leave UNSET out of the map entirely"); +#endif +} + void setUp(void) { mockMeshService = new MockMeshService(); @@ -422,6 +462,7 @@ void setup() RUN_TEST(test_clampConfigLora_mediumTurboValidForUS); RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds); RUN_TEST(test_regionPresetMap_matchesRegionTable); + RUN_TEST(test_regionPresetMap_unsetCarriesUserprefsIntent); exit(UNITY_END()); } From 4c640270f2428e553ffc11a027f89648f0dd83ac Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:46:14 +0000 Subject: [PATCH 100/109] chore(deps): update lovyangfx to v1.2.27 (#11533) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32/chatter2/platformio.ini | 2 +- variants/esp32/m5stack_core/platformio.ini | 2 +- variants/esp32/wiphone/platformio.ini | 2 +- variants/esp32s3/elecrow_panel/platformio.ini | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 2 +- variants/esp32s3/heltec_v4_r8/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini | 2 +- variants/esp32s3/mesh-tab/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/platformio.ini | 2 +- variants/esp32s3/rak_wismesh_tap_v2/platformio.ini | 2 +- variants/esp32s3/t-deck/platformio.ini | 2 +- variants/esp32s3/t-watch-s3/platformio.ini | 2 +- variants/esp32s3/tlora-pager/platformio.ini | 2 +- variants/esp32s3/tracksenger/platformio.ini | 4 ++-- variants/esp32s3/unphone/platformio.ini | 2 +- variants/native/portduino.ini | 2 +- 18 files changed, 19 insertions(+), 19 deletions(-) diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index 4873b7f6d..723ecdf04 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -13,4 +13,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index edb0a48b9..16d562a90 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -36,4 +36,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index 4bc8bf6bc..9a5f50321 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index db682316d..800aafe8b 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -50,7 +50,7 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=TCA9534 packageName=hideakitai/library/TCA9534 hideakitai/TCA9534@0.1.1 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 custom_sdkconfig = ${esp32s3_base.custom_sdkconfig} diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index fcc20d045..4e38a34e7 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -133,7 +133,7 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini index 7c195cf82..a984b9154 100644 --- a/variants/esp32s3/heltec_v4_r8/platformio.ini +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -141,7 +141,7 @@ build_flags = lib_deps = ${heltec_v4_r8_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index 596a09f70..771d07081 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -27,4 +27,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index fa7835bbf..1a721a9be 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index 210cd47fc..454764360 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index 661f6aac8..c0b678615 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index 064c5f3ff..3fb177f3e 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index 6b58b22c3..af5140102 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 644877793..1b443582f 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index 6f4542b0e..ca9195786 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index 3e650171a..c753e8836 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index c3f2b1aac..5acdd9130 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -23,7 +23,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -50,7 +50,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index c3dc9f03c..fb6988e71 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -37,7 +37,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 7787adc9c..33e0a8b8b 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -26,7 +26,7 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.26 + lovyan03/LovyanGFX@1.2.27 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/meshtastic/libch341-spi-userspace gitBranch=main https://github.com/meshtastic/libch341-spi-userspace/archive/03bf505d6e5904092c1c389c45b01098f7a302fe.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library From 4d9d0f8a16efb3c9dfea52044046966078de7f0e Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 21 Aug 2026 11:59:26 -0400 Subject: [PATCH 101/109] chore(deps): Correct library dependencies for T-Deck Pro and T-Watch Ultra (#11561) --- variants/esp32s3/t-deck-pro/platformio.ini | 2 ++ variants/esp32s3/t-watch-ultra/platformio.ini | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/variants/esp32s3/t-deck-pro/platformio.ini b/variants/esp32s3/t-deck-pro/platformio.ini index bac234cc4..2ab39a1d7 100644 --- a/variants/esp32s3/t-deck-pro/platformio.ini +++ b/variants/esp32s3/t-deck-pro/platformio.ini @@ -41,3 +41,5 @@ lib_deps = https://github.com/CIRCUITSTATE/CSE_CST328/archive/refs/tags/v0.0.4.zip # renovate: datasource=git-refs depName=BQ27220 packageName=https://github.com/mverch67/BQ27220 gitBranch=main https://github.com/mverch67/BQ27220/archive/07d92be846abd8a0258a50c23198dac0858b22ed.zip + # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library + adafruit/Adafruit DRV2605 Library@1.2.4 diff --git a/variants/esp32s3/t-watch-ultra/platformio.ini b/variants/esp32s3/t-watch-ultra/platformio.ini index cb2045447..56acbd07a 100644 --- a/variants/esp32s3/t-watch-ultra/platformio.ini +++ b/variants/esp32s3/t-watch-ultra/platformio.ini @@ -47,12 +47,15 @@ build_src_filter = +<../variants/esp32s3/t-watch-ultra> lib_deps = ${esp32s3_base.lib_deps} + # renovate: datasource=github-tags depName=LovyanGFX packageName=lovyan03/LovyanGFX https://github.com/lovyan03/LovyanGFX/archive/tags/1.2.27.zip - adafruit/Adafruit DRV2605 Library@^1.2.4 + # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library + adafruit/Adafruit DRV2605 Library@1.2.4 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/earlephilhower/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM earlephilhower/ESP8266SAM@1.1.0 + # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.1 [env:t-watch-ultra-tft] From 5f7077c44ec9e38c23a205fbd380afb0aad8f521 Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 21 Aug 2026 12:25:39 -0400 Subject: [PATCH 102/109] fix t-deck-pro-v1.1: disable BHI260AP support until SensorLib replacement is available (#11562) --- variants/esp32s3/t-deck-pro-v1_1/variant.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/variants/esp32s3/t-deck-pro-v1_1/variant.h b/variants/esp32s3/t-deck-pro-v1_1/variant.h index d7accfe82..9997fd92e 100644 --- a/variants/esp32s3/t-deck-pro-v1_1/variant.h +++ b/variants/esp32s3/t-deck-pro-v1_1/variant.h @@ -57,7 +57,8 @@ // gyroscope BHI260AP // #define BOARD_1V8_EN 38 //Deck-Pro remove 1.8v en pin -#define HAS_BHI260AP +// Disabled until a SensorLib replacement is available +// #define HAS_BHI260AP // battery charger BQ25896 #define HAS_PPM 1 From f22ce82f5aa6d574b5611d1ea7b2d4e75cd5d029 Mon Sep 17 00:00:00 2001 From: vidplace7 Date: Fri, 21 Aug 2026 12:42:16 -0400 Subject: [PATCH 103/109] fix t-deck-pro: disable BHI260AP support until SensorLib replacement is available Missed in the previous commit --- variants/esp32s3/t-deck-pro/variant.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/variants/esp32s3/t-deck-pro/variant.h b/variants/esp32s3/t-deck-pro/variant.h index 8fa7e1740..82d8747d9 100644 --- a/variants/esp32s3/t-deck-pro/variant.h +++ b/variants/esp32s3/t-deck-pro/variant.h @@ -55,7 +55,8 @@ // gyroscope BHI260AP #define BOARD_1V8_EN 38 -#define HAS_BHI260AP +// Disabled until a SensorLib replacement is available +// #define HAS_BHI260AP // battery charger BQ25896 #define HAS_PPM 1 From 4de20187f57c6cf02795b6a7cd62b0eccb1e5245 Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 21 Aug 2026 13:58:58 -0400 Subject: [PATCH 104/109] Actions: Update to trunk-io/trunk-action v2 -- remove annotations (#11563) trunk-action v2 removed support for PR annotations (they have been broken for a while anyways) --- .github/workflows/nightly.yml | 4 ++-- .github/workflows/trunk_annotate_pr.yml | 27 ------------------------- .github/workflows/trunk_check.yml | 4 +--- 3 files changed, 3 insertions(+), 32 deletions(-) delete mode 100644 .github/workflows/trunk_annotate_pr.yml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1cb1fd8e9..46578ad6f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v7 - name: Trunk Check - uses: trunk-io/trunk-action@v1 + uses: trunk-io/trunk-action@v2.0.0 with: trunk-token: ${{ secrets.TRUNK_TOKEN }} @@ -34,6 +34,6 @@ jobs: uses: actions/checkout@v7 - name: Trunk Upgrade - uses: trunk-io/trunk-action/upgrade@v1 + uses: trunk-io/trunk-action/upgrade@v2.0.0 with: base: develop diff --git a/.github/workflows/trunk_annotate_pr.yml b/.github/workflows/trunk_annotate_pr.yml deleted file mode 100644 index 4bfe27d63..000000000 --- a/.github/workflows/trunk_annotate_pr.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Annotate PR with trunk issues -# See: https://github.com/trunk-io/trunk-action/blob/v1/readme.md#getting-inline-annotations-for-fork-prs - -on: - workflow_run: - workflows: [Pull Request] # Name from `trunk_check.yml` - types: [completed] - -permissions: read-all - -jobs: - trunk_check: - name: Trunk Code Quality Annotate - runs-on: ubuntu-24.04 - permissions: - checks: write # For trunk to post annotations - contents: read # For repo checkout - - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Trunk Check - uses: trunk-io/trunk-action@v1 - with: - post-annotations: true - cache: false diff --git a/.github/workflows/trunk_check.yml b/.github/workflows/trunk_check.yml index 278e8db61..d2b6d704b 100644 --- a/.github/workflows/trunk_check.yml +++ b/.github/workflows/trunk_check.yml @@ -11,7 +11,6 @@ jobs: name: Trunk Check Runner runs-on: ubuntu-24.04 permissions: - checks: write # For trunk to post annotations contents: read # For repo checkout steps: @@ -19,7 +18,6 @@ jobs: uses: actions/checkout@v7 - name: Trunk Check - uses: trunk-io/trunk-action@v1 + uses: trunk-io/trunk-action@v2.0.0 with: - save-annotations: true cache: false From f6f116a39d4da7ad1586ab3595f93b5c1648dec1 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 22 Aug 2026 14:34:49 +0000 Subject: [PATCH 105/109] Fill in device registry metadata for recently added hardware (#11567) Audit of the custom_meshtastic_* manifest on the variants backing the newest boards, against the protobuf HardwareModel enum, the compiled HW_VENDOR, the board flash size and the artwork actually published by the web flasher. No support flag changes here - actively_supported is left exactly as each variant already had it. ThinkNode M9 had no HW_VENDOR arm, so every M9 has been reporting PRIVATE_HW while its manifest advertised 131; add the mapping and rename the slug to the enum name (THINKNODE_M9) it is meant to mirror. Seeed SenseCAP Mesh-Tracker X1 moves from the PR matrix to release, and its images entry now points at seeed_mesh_tracker_x1.svg, which is what the flasher actually ships - the hyphenated name resolved to nothing. T-Beam BPF, T-Beam 1W and Heltec Wireless Tracker V2 declared the architecture as "esp32s3"; the value is copied verbatim into the manifest, and the flash flow matches on the normalized "esp32-s3". T-Beam BPF and M5Stack Unit C6L both build default_16MB.csv on 16 MB flash but declared no partition scheme, which leaves the flasher on the 4 MB fallback offsets for a legacy clean install. Meshnology W10 and W12 gain the artwork and vendor tag that already exist for them. --- src/platform/esp32/architecture.h | 2 ++ variants/esp32c6/m5stack_unitc6l/platformio.ini | 1 + variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini | 2 +- variants/esp32s3/meshnology-w10/platformio.ini | 2 ++ variants/esp32s3/meshnology-w12/platformio.ini | 2 ++ variants/esp32s3/t-beam-1w/platformio.ini | 2 +- variants/esp32s3/t-beam-bpf/platformio.ini | 3 ++- variants/nrf52840/seeed_mesh_tracker_X1/platformio.ini | 4 ++-- 9 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 3f6a05d91..c91ead476 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -214,6 +214,8 @@ #define HW_VENDOR meshtastic_HardwareModel_M5STACK_CARDPUTER_ADV #elif defined(MESHNOLOGY_W10) #define HW_VENDOR meshtastic_HardwareModel_MESHNOLOGY_W10 +#elif defined(ELECROW_ThinkNode_M9) +#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M9 #else #define HW_VENDOR meshtastic_HardwareModel_PRIVATE_HW #endif diff --git a/variants/esp32c6/m5stack_unitc6l/platformio.ini b/variants/esp32c6/m5stack_unitc6l/platformio.ini index e99c806e8..7b479eadb 100644 --- a/variants/esp32c6/m5stack_unitc6l/platformio.ini +++ b/variants/esp32c6/m5stack_unitc6l/platformio.ini @@ -7,6 +7,7 @@ custom_meshtastic_support_level = 1 custom_meshtastic_display_name = M5Stack Unit C6L custom_meshtastic_images = m5_c6l.svg custom_meshtastic_tags = M5Stack +custom_meshtastic_partition_scheme = 16MB extends = esp32c6_base board_level = release diff --git a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini index 7aea2c5fb..7b43b40b5 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini +++ b/variants/esp32s3/ELECROW-ThinkNode-M9/platformio.ini @@ -1,6 +1,6 @@ [thinknode_m9_base] custom_meshtastic_hw_model = 131 -custom_meshtastic_hw_model_slug = ELECROW_ThinkNode_M9 +custom_meshtastic_hw_model_slug = THINKNODE_M9 custom_meshtastic_architecture = esp32-s3 custom_meshtastic_actively_supported = true custom_meshtastic_support_level = 1 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index 454764360..b86d60670 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -10,7 +10,7 @@ board_build.partitions = default_8MB.csv upload_protocol = esptool custom_meshtastic_hw_model = 113 custom_meshtastic_hw_model_slug = HELTEC_WIRELESS_TRACKER_V2 -custom_meshtastic_architecture = esp32s3 +custom_meshtastic_architecture = esp32-s3 custom_meshtastic_display_name = Heltec Wireless Tracker V2 custom_meshtastic_actively_supported = true diff --git a/variants/esp32s3/meshnology-w10/platformio.ini b/variants/esp32s3/meshnology-w10/platformio.ini index bf6692162..73168ac56 100644 --- a/variants/esp32s3/meshnology-w10/platformio.ini +++ b/variants/esp32s3/meshnology-w10/platformio.ini @@ -5,6 +5,8 @@ custom_meshtastic_architecture = esp32-s3 custom_meshtastic_actively_supported = true custom_meshtastic_support_level = 1 custom_meshtastic_display_name = Meshnology W10 +custom_meshtastic_images = meshnology_w10.svg +custom_meshtastic_tags = Meshnology custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 16MB diff --git a/variants/esp32s3/meshnology-w12/platformio.ini b/variants/esp32s3/meshnology-w12/platformio.ini index a43a490f9..a4fd2414b 100644 --- a/variants/esp32s3/meshnology-w12/platformio.ini +++ b/variants/esp32s3/meshnology-w12/platformio.ini @@ -7,6 +7,8 @@ custom_meshtastic_architecture = esp32-s3 custom_meshtastic_actively_supported = true custom_meshtastic_support_level = 1 custom_meshtastic_display_name = Meshnology W12 +custom_meshtastic_images = meshnology_w12.svg +custom_meshtastic_tags = Meshnology custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 16MB diff --git a/variants/esp32s3/t-beam-1w/platformio.ini b/variants/esp32s3/t-beam-1w/platformio.ini index 74921e4c1..a363e02b6 100644 --- a/variants/esp32s3/t-beam-1w/platformio.ini +++ b/variants/esp32s3/t-beam-1w/platformio.ini @@ -2,7 +2,7 @@ [env:t-beam-1w] custom_meshtastic_hw_model = 122 custom_meshtastic_hw_model_slug = TBEAM_1_WATT -custom_meshtastic_architecture = esp32s3 +custom_meshtastic_architecture = esp32-s3 custom_meshtastic_actively_supported = true custom_meshtastic_support_level = 1 custom_meshtastic_display_name = LILYGO T-Beam 1W diff --git a/variants/esp32s3/t-beam-bpf/platformio.ini b/variants/esp32s3/t-beam-bpf/platformio.ini index bc19bb4bb..3c03f9d55 100644 --- a/variants/esp32s3/t-beam-bpf/platformio.ini +++ b/variants/esp32s3/t-beam-bpf/platformio.ini @@ -2,12 +2,13 @@ [env:t-beam-bpf] custom_meshtastic_hw_model = 124 custom_meshtastic_hw_model_slug = TBEAM_BPF -custom_meshtastic_architecture = esp32s3 +custom_meshtastic_architecture = esp32-s3 custom_meshtastic_actively_supported = true custom_meshtastic_support_level = 3 custom_meshtastic_display_name = LILYGO T-Beam BPF custom_meshtastic_images = tbeam-bpf.svg custom_meshtastic_tags = LilyGo +custom_meshtastic_partition_scheme = 16MB extends = esp32s3_base board_level = release diff --git a/variants/nrf52840/seeed_mesh_tracker_X1/platformio.ini b/variants/nrf52840/seeed_mesh_tracker_X1/platformio.ini index e12fbfd9a..8e5ba2c1c 100644 --- a/variants/nrf52840/seeed_mesh_tracker_X1/platformio.ini +++ b/variants/nrf52840/seeed_mesh_tracker_X1/platformio.ini @@ -1,6 +1,6 @@ [env:seeed_mesh_tracker_X1] custom_meshtastic_support_level = 1 -custom_meshtastic_images = seeed-mesh-tracker-x1.svg +custom_meshtastic_images = seeed_mesh_tracker_x1.svg custom_meshtastic_tags = Seeed custom_meshtastic_hw_model = 128 custom_meshtastic_hw_model_slug = MESH_TRACKER_X1 @@ -10,7 +10,7 @@ custom_meshtastic_actively_supported = true extends = nrf52840_base board = mesh-tracker-x1 -board_level = pr +board_level = release build_flags = ${nrf52840_base.build_flags} -Ivariants/nrf52840/seeed_mesh_tracker_X1 -Isrc/platform/nrf52/softdevice From 73f7b35bea59df8bd74bcd3cbe00b30a64e49c52 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 22 Aug 2026 17:00:47 -0500 Subject: [PATCH 106/109] Report the right hardware model on four boards (#11570) Four variants declare a custom_meshtastic_hw_model that the build never reaches, so the device announces something else in NodeInfo and the apps cannot match it for OTA. Mini ePaper S3 (125) and Heltec V4 R8 (132) had no arm in the esp32 HW_VENDOR chain at all, so both fell through to #else and reported PRIVATE_HW. Heltec Mesh Node T096 (127) had none in the nrf52 chain and reported NRF52_UNKNOWN. WisMesh Tap V2 defines both RAK3312 and RAK_WISMESH_TAP_V2, and the generic RAK3312 arm sat first, so the board reported RAK3312 (106) instead of WISMESH_TAP_V2 (116). Order the specific arm ahead of the generic one, the same way the nrf52 chain already keeps custom RAK4630 boards ahead of the generic RAK4630. Verified by preprocessing each platform's HW_VENDOR chain with the env's full define set - build flags resolved through extends, the board JSON's build.extra_flags, and the bare #defines in the variant's own variant.h. All four now match their manifest, and rak3312, heltec-v4, heltec-v4-tft and the ThinkNode M9 arm added in #11567 are unchanged. --- src/platform/esp32/architecture.h | 9 +++++++-- src/platform/nrf52/architecture.h | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index c91ead476..48618b641 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -188,10 +188,11 @@ #define HW_VENDOR meshtastic_HardwareModel_HELTEC_SENSOR_HUB #elif defined(ELECROW_PANEL) #define HW_VENDOR meshtastic_HardwareModel_CROWPANEL -#elif defined(RAK3312) -#define HW_VENDOR meshtastic_HardwareModel_RAK3312 +// The WisMesh Tap V2 is a RAK3312 board, so it must be matched before the generic one. #elif defined(RAK_WISMESH_TAP_V2) #define HW_VENDOR meshtastic_HardwareModel_WISMESH_TAP_V2 +#elif defined(RAK3312) +#define HW_VENDOR meshtastic_HardwareModel_RAK3312 #elif defined(LINK_32) #define HW_VENDOR meshtastic_HardwareModel_LINK_32 #elif defined(T_DECK_PRO) @@ -216,6 +217,10 @@ #define HW_VENDOR meshtastic_HardwareModel_MESHNOLOGY_W10 #elif defined(ELECROW_ThinkNode_M9) #define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M9 +#elif defined(HELTEC_V4_R8) +#define HW_VENDOR meshtastic_HardwareModel_HELTEC_V4_R8 +#elif defined(MINI_EPAPER_S3) +#define HW_VENDOR meshtastic_HardwareModel_MINI_EPAPER_S3 #else #define HW_VENDOR meshtastic_HardwareModel_PRIVATE_HW #endif diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index e9abbbc29..4a6afaeae 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -147,6 +147,8 @@ #define HW_VENDOR meshtastic_HardwareModel_MUZI_BASE #elif defined(HELTEC_MESH_TOWER_V2) #define HW_VENDOR meshtastic_HardwareModel_HELTEC_MESH_TOWER_V2 +#elif defined(HELTEC_MESH_NODE_T096) +#define HW_VENDOR meshtastic_HardwareModel_HELTEC_MESH_NODE_T096 #else #define HW_VENDOR meshtastic_HardwareModel_NRF52_UNKNOWN #endif From 05f6474108278c6d9e4663bfe3b8a1b04aae11bd Mon Sep 17 00:00:00 2001 From: zelo533 Date: Sat, 22 Aug 2026 19:06:33 -0500 Subject: [PATCH 107/109] meshnology-w10: define HAS_SPI_TFT so the TFT screen initializes again (#11042) #10803 refactored main.cpp to key SPI-TFT Screen creation on HAS_SPI_TFT instead of the per-controller define list. The W10 variant (#10911) was written before that refactor and crossed it mid-air, so it never defines HAS_SPI_TFT and develop builds fall through to the I2C-OLED autodetect branch: no Screen is ever constructed and the display stays dark, while everything else (radio, GPS, BLE) works. Verified on a real W10: with the define, the boot log shows TFTDisplay creation, backlight power-on and the boot screen, and the ST7789 panel renders the UI again. Co-authored-by: Ben Meadors --- variants/esp32s3/meshnology-w10/variant.h | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/esp32s3/meshnology-w10/variant.h b/variants/esp32s3/meshnology-w10/variant.h index 98677b218..cd1fe1e65 100644 --- a/variants/esp32s3/meshnology-w10/variant.h +++ b/variants/esp32s3/meshnology-w10/variant.h @@ -87,6 +87,7 @@ #define TFT_DC 16 // pg1: OLED_DC=GPIO16 #define TFT_BL 6 // pg1: OLED_BL=GPIO6 (backlight PWM) #define TFT_RST -1 // panel reset is EXIO1 on the expander, toggled in mcp23017EarlyInit() +#define HAS_SPI_TFT 1 // main.cpp keys SPI-TFT Screen creation on this since #10803 #define USE_TFTDISPLAY 1 #define SPI_FREQUENCY 75000000 From ac330e6a6b9fca267fe3faab27ee50c4e91bee28 Mon Sep 17 00:00:00 2001 From: Tadayoshi MIURA <11958457+t-miura@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:00:30 +0000 Subject: [PATCH 108/109] fix(radio): MeshBeacon heap leak and runtime packet payload size check (#11573) * Fix for MeshBeacon packet leakage * fix: add runtime payload size check against radiobuffer * review fix for PR#11573: clear target radio settings before MeshBeacon packet release * add unit test for radio buffer capacity check, removing related assert for the test * review fix for PR#11573: add explicit verifaction against rejected packets --- src/mesh/RadioInterface.cpp | 8 +++++++- src/mesh/RadioLibInterface.cpp | 16 ++++++++++++++-- src/modules/MeshBeaconModule.cpp | 10 ++++++++-- test/test_radio/test_main.cpp | 29 +++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 5db23ab54..c0212f4de 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -1518,7 +1518,13 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p) // if the sender nodenum is zero, that means uninitialized assert(radioBuffer.header.from); - assert(p->encrypted.size <= sizeof(radioBuffer.payload)); + // Runtime packet payload size bounds check against radioBuffer to prevent overflow in memcpy() + if (static_cast(p->encrypted.size) > sizeof(radioBuffer.payload)) { + LOG_ERROR("Packet payload size %u exceeds radioBuffer capacity %u", static_cast(p->encrypted.size), + static_cast(sizeof(radioBuffer.payload))); + packetPool.release(p); + return 0; + } memcpy(radioBuffer.payload, p->encrypted.bytes, p->encrypted.size); sendingPacket = p; diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 195a5738a..3018a34dd 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -597,12 +597,13 @@ void RadioLibInterface::completeSending() printPacket("Completed sending", p); #if !MESHTASTIC_EXCLUDE_BEACON MeshBeaconModule::clearTargetRadioSettings(p); - MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); #endif - // We are done sending that packet, release it packetPool.release(p); } +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); +#endif } void RadioLibInterface::handleReceiveInterrupt() @@ -782,7 +783,18 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) } else { configHardwareForSend(); // must be after setStandby +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(txp); +#endif size_t numbytes = beginSending(txp); + if (numbytes == 0) { + if (!sendingPacket) { + completeSending(); + powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); + startReceive(); + } + return false; + } int res = iface->startTransmit((uint8_t *)&radioBuffer, numbytes); if (res != RADIOLIB_ERR_NONE) { diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index a74762183..9982f8d15 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -286,7 +286,10 @@ void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, mesht const bool cryptoOverride = has_channel && overrideChannel && (overrideChannel->name[0] != '\0' || overrideChannel->psk.size > 0); if (!cryptoOverride) { - router->send(p); + if (router->send(p) == ERRNO_SHOULD_RELEASE) { + MeshBeaconModule::clearTargetRadioSettings(p); + packetPool.release(p); + } return; } @@ -300,7 +303,10 @@ void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, mesht primary.settings = beaconChannelSettings(saved, targetPreset, overrideChannel); channels.fixupChannel(channels.getPrimaryIndex()); - router->send(p); // encrypts with the beacon channel's key and stamps its hash + if (router->send(p) == ERRNO_SHOULD_RELEASE) { // encrypts with the beacon channel's key and stamps its hash + MeshBeaconModule::clearTargetRadioSettings(p); + packetPool.release(p); + } primary.settings = saved; channels.fixupChannel(channels.getPrimaryIndex()); diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index 87f3f3724..f8a701f2a 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -60,6 +60,10 @@ class TestableRadioInterface : public RadioInterface uint8_t getSf() const { return sf; } float getBw() const { return bw; } + size_t beginSendingPublic(meshtastic_MeshPacket *p) { return beginSending(p); } + meshtastic_MeshPacket *getSendingPacket() const { return sendingPacket; } + size_t getRadioBufferPayloadCapacity() const { return sizeof(radioBuffer.payload); } + // Override reconfigure to call the base which invokes applyModemConfig() bool reconfigure() override { return RadioInterface::reconfigure(); } @@ -413,6 +417,30 @@ static void test_regionPresetMap_unsetCarriesUserprefsIntent() #endif } +static void test_beginSending_oversizedPayloadAbortsSafely() +{ + meshtastic_MeshPacket *p = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(p); + p->from = 0x12345678; + p->to = 0x87654321; + p->id = 0x10203040; + p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + + // Set encrypted size larger than sizeof(radioBuffer.payload) (which is 256 - sizeof(PacketHeader)) + p->encrypted.size = testRadio->getRadioBufferPayloadCapacity() + 10; + + size_t result = testRadio->beginSendingPublic(p); + + TEST_ASSERT_EQUAL_UINT(0, result); + TEST_ASSERT_NULL(testRadio->getSendingPacket()); + + // Verify rejected packet was released to packetPool and its slot is reusable + meshtastic_MeshPacket *reallocated = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(reallocated); + TEST_ASSERT_EQUAL_PTR(p, reallocated); + packetPool.release(reallocated); +} + void setUp(void) { mockMeshService = new MockMeshService(); @@ -463,6 +491,7 @@ void setup() RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds); RUN_TEST(test_regionPresetMap_matchesRegionTable); RUN_TEST(test_regionPresetMap_unsetCarriesUserprefsIntent); + RUN_TEST(test_beginSending_oversizedPayloadAbortsSafely); exit(UNITY_END()); } From bfd1e1a2316eaccf4549f720f5bf145cb79390ca Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 23 Aug 2026 11:00:37 +0000 Subject: [PATCH 109/109] Add Heltec RC32, RC52 and RCC6 boards, and LC760CA GNSS support (#11572) * refactor(graphics): select Arduino_GFX panels with a capability flag TFTDisplay tested `defined(HACKADAY_COMMUNICATOR)` in a dozen places to mean "this panel is driven by Arduino_GFX rather than LovyanGFX". Every new Arduino_GFX board had to be appended to all of them. Move the decision into the variant as USE_ARDUINO_GFX so the display code stops naming individual boards. No behaviour change: the Hackaday Communicator is still the only board that sets it. * feat(boards): add Heltec RC32, RC52 and RCC6 Three boards around the same 128x220 NV3001B panel: RC32 (ESP32-S3), RCC6 (ESP32-C6) and RC52 (nRF52840). They differ only in how the panel bus is wired, so they share one branch in TFTDisplay behind TFT_NV3001B. RC32 and RC52 also carry a rotary encoder on a TCA6408 I2C expander. That lands as its own input source rather than as board conditionals inside i2cButton, which is the M5Stack UnitC6L button driver and stays untouched. On RC52 and RCC6 the panel is an add-on module, so probe it before reporting a screen. The probe reuses the bit-banged SPI helper that already backs the T114 ST7789 check. Arduino_GFX is pinned to the upstream commit that added the NV3001B driver; it has not shipped in a tagged release yet. Co-Authored-By: Quency-D <55523105+Quency-D@users.noreply.github.com> * feat(gps): detect and configure the LC760CA GNSS module The LC760CA is another Unicore part, so it joins the $PDTINFO probe family and reuses the CM121 message-rate setup. It answers with CC1161W. GNSS_MODEL_LC760CA goes immediately before GNSS_MODEL_GENERIC_NMEA: the sentinel has to stay last because isValidGnssModel() uses it as the exclusive upper bound on values the probe cache may hold. Placing the new model after it would leave LC760CA permanently uncacheable. Co-Authored-By: Quency-D <55523105+Quency-D@users.noreply.github.com> * fix(graphics): re-init the NV3001B after the panel rail comes back DISPLAYOFF de-asserts VTFT_CTRL, which cuts power to the panel, so the controller loses MADCTL, COLMOD and gamma. displayOn() only sends sleep-out and cannot restore them, leaving the panel dark or in the wrong format after wake. Re-run begin() once the rail has settled, and repaint in full since the re-init leaves display RAM undefined. Also stop the TCA6408 rotary polling from two threads at once. Registering as an InputPollable meant InputBroker's pollSoon task could call pollOnce() while runOnce() was mid-transfer on the main thread, with nothing serialising Wire or the decoder state. Drop InputPollable and have the interrupt wake the thread instead, the way ButtonThread does, so the bus and the decode stay on one thread. * fix(graphics): skip the NV3001B wake when re-init fails begin() reports whether the bus came up. Ignoring it meant a failed re-init still lit the backlight and drove a full-screen repaint at a panel that was never initialised. * chore(boards): ship the Heltec RC boards at release level release is the normal level for a variant; the matrix generator still builds each of these in this PR because they add a new platformio.ini. --------- Co-authored-by: Quency-D <55523105+Quency-D@users.noreply.github.com> --- boards/heltec_rc32.json | 43 +++++ boards/heltec_rc52.json | 54 ++++++ boards/heltec_rcc6.json | 29 ++++ src/gps/GPS.cpp | 10 +- src/gps/GPS.h | 3 + src/graphics/TFTDisplay.cpp | 62 +++++-- src/input/InputBroker.cpp | 8 + src/input/TCA6408Rotary.cpp | 156 ++++++++++++++++++ src/input/TCA6408Rotary.h | 40 +++++ src/mesh/NodeDB.cpp | 63 ++++++- src/platform/esp32/architecture.h | 4 + src/platform/nrf52/architecture.h | 2 + variants/esp32c6/heltec_rcc6/pins_arduino.h | 20 +++ variants/esp32c6/heltec_rcc6/platformio.ini | 35 ++++ variants/esp32c6/heltec_rcc6/variant.h | 59 +++++++ .../esp32s3/hackaday-communicator/variant.h | 1 + variants/esp32s3/heltec_rc32/pins_arduino.h | 60 +++++++ variants/esp32s3/heltec_rc32/platformio.ini | 28 ++++ variants/esp32s3/heltec_rc32/sdkconfig.h | 24 +++ variants/esp32s3/heltec_rc32/variant.h | 78 +++++++++ variants/nrf52840/heltec_rc52/platformio.ini | 30 ++++ variants/nrf52840/heltec_rc52/variant.cpp | 103 ++++++++++++ variants/nrf52840/heltec_rc52/variant.h | 136 +++++++++++++++ 23 files changed, 1031 insertions(+), 17 deletions(-) create mode 100644 boards/heltec_rc32.json create mode 100644 boards/heltec_rc52.json create mode 100644 boards/heltec_rcc6.json create mode 100644 src/input/TCA6408Rotary.cpp create mode 100644 src/input/TCA6408Rotary.h create mode 100644 variants/esp32c6/heltec_rcc6/pins_arduino.h create mode 100644 variants/esp32c6/heltec_rcc6/platformio.ini create mode 100644 variants/esp32c6/heltec_rcc6/variant.h create mode 100644 variants/esp32s3/heltec_rc32/pins_arduino.h create mode 100644 variants/esp32s3/heltec_rc32/platformio.ini create mode 100644 variants/esp32s3/heltec_rc32/sdkconfig.h create mode 100644 variants/esp32s3/heltec_rc32/variant.h create mode 100644 variants/nrf52840/heltec_rc52/platformio.ini create mode 100644 variants/nrf52840/heltec_rc52/variant.cpp create mode 100644 variants/nrf52840/heltec_rc52/variant.h diff --git a/boards/heltec_rc32.json b/boards/heltec_rc32.json new file mode 100644 index 000000000..b9bafa265 --- /dev/null +++ b/boards/heltec_rc32.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-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": "heltec_rc32" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "Heltec RC32 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "Heltec" +} diff --git a/boards/heltec_rc52.json b/boards/heltec_rc52.json new file mode 100644 index 000000000..2f87529ca --- /dev/null +++ b/boards/heltec_rc52.json @@ -0,0 +1,54 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + ["0x239A", "0x4405"], + ["0x239A", "0x0029"], + ["0x239A", "0x002A"], + ["0x2886", "0x1667"] + ], + "usb_product": "HT-n5262", + "mcu": "nrf52840", + "variant": "heltec_rc52", + "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": "Heltec RC52", + "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": "https://heltec.org/", + "vendor": "Heltec" +} diff --git a/boards/heltec_rcc6.json b/boards/heltec_rcc6.json new file mode 100644 index 000000000..972d2b864 --- /dev/null +++ b/boards/heltec_rcc6.json @@ -0,0 +1,29 @@ +{ + "build": { + "arduino": { + "partitions": "default_16MB.csv" + }, + "core": "esp32", + "f_cpu": "160000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32c6", + "variant": "heltec_rcc6" + }, + "connectivity": ["bluetooth", "wifi", "lora"], + "debug": { + "openocd_target": "esp32c6.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "Heltec RCC6", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "Heltec" +} diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 0bd0f3212..9f29c41f0 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -742,6 +742,11 @@ bool GPS::verifyCachedProbePresence() _serial_gps->write("$PDTINFO\r\n"); present = (getACK("CM121", 900) == GNSS_RESPONSE_OK); break; + case GNSS_MODEL_LC760CA: + cachedProbeModelName = "LC760CA"; + _serial_gps->write("$PDTINFO\r\n"); + present = (getACK("CC1161W", 900) == GNSS_RESPONSE_OK); + break; case GNSS_MODEL_UBLOX6: case GNSS_MODEL_UBLOX7: case GNSS_MODEL_UBLOX8: @@ -1115,7 +1120,7 @@ bool GPS::setup() } else { LOG_INFO("GNSS module config saved"); } - } else if (gnssModel == GNSS_MODEL_CM121) { + } else if (IS_ONE_OF(gnssModel, GNSS_MODEL_CM121, GNSS_MODEL_LC760CA)) { // only ask for RMC and GGA // enable GGA _serial_gps->write("$CFGMSG,0,0,1,1*1B\r\n"); @@ -1716,7 +1721,8 @@ GnssModel_t GPS::probe(int serialSpeed) std::vector unicore = {{"UC6580", "UC6580", GNSS_MODEL_UC6580}, {"UM600", "UM600", GNSS_MODEL_UC6580}, {"CM121", "CM121", GNSS_MODEL_CM121}, - {"CC1167Q", "CC1167Q", GNSS_MODEL_CM121}}; + {"CC1167Q", "CC1167Q", GNSS_MODEL_CM121}, + {"LC760CA", "CC1161W", GNSS_MODEL_LC760CA}}; PROBE_FAMILY("Unicore Family", "$PDTINFO", unicore, 500); currentDelay = 20; currentStep = 2; diff --git a/src/gps/GPS.h b/src/gps/GPS.h index a9a82795c..b9bd35717 100644 --- a/src/gps/GPS.h +++ b/src/gps/GPS.h @@ -52,6 +52,9 @@ typedef enum { GNSS_MODEL_AG3352, GNSS_MODEL_LS20031, GNSS_MODEL_CM121, + GNSS_MODEL_LC760CA, + // Keep GNSS_MODEL_GENERIC_NMEA last: isValidGnssModel() uses it as the exclusive upper bound + // for values the probe cache is allowed to hold. GNSS_MODEL_GENERIC_NMEA // generic NMEA source (e.g. gpsd); skips chip-specific probe and init } GnssModel_t; diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index a3af9e7ff..f5f857797 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -205,7 +205,7 @@ static void rak14014_tpIntHandle(void) _rak14014_touch_int = true; } -#elif defined(HACKADAY_COMMUNICATOR) +#elif defined(USE_ARDUINO_GFX) #include Arduino_GFX *tft = nullptr; @@ -1397,7 +1397,7 @@ void TFTDisplay::display(bool fromBlank) } } } -#if defined(HACKADAY_COMMUNICATOR) +#if defined(USE_ARDUINO_GFX) tft->draw16bitBeRGBBitmap(0, yStart, repaintChunkBuffer, displayWidth, rowsThisChunk); #else tft->pushImage(0, yStart, displayWidth, rowsThisChunk, repaintChunkBuffer); @@ -1564,7 +1564,7 @@ void TFTDisplay::display(bool fromBlank) const uint8_t lines_updated = 1; #endif -#if defined(HACKADAY_COMMUNICATOR) +#if defined(USE_ARDUINO_GFX) tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate], (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1); #else @@ -1639,12 +1639,24 @@ void TFTDisplay::sendCommand(uint8_t com) switch (com) { case DISPLAYON: { LOG_DEBUG("Display on"); +#if defined(TFT_NV3001B) + // DISPLAYOFF cuts the panel rail, so the controller loses its configuration and sleep-out + // alone cannot bring it back. Restore the rail, let it settle, then re-run the init sequence. + digitalWrite(VTFT_CTRL, TFT_EN_ON); + delay(10); + if (!tft->begin(SPI_FREQUENCY)) { + // Nothing below this point can reach the panel, so skip the wake instead of lighting + // the backlight and repainting over a bus that did not come up. + LOG_ERROR("NV3001B re-init failed on wake"); + break; + } +#endif backlightEnable->set(true); #if ARCH_PORTDUINO display(true); if (portduino_config.displayBacklight.pin > 0) digitalWrite(portduino_config.displayBacklight.pin, TFT_BACKLIGHT_ON); -#elif defined(HACKADAY_COMMUNICATOR) +#elif defined(USE_ARDUINO_GFX) tft->displayOn(); #elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) && \ !defined(HELTEC_MESH_NODE_T1) @@ -1652,7 +1664,13 @@ void TFTDisplay::sendCommand(uint8_t com) tft->powerSaveOff(); #endif -#ifdef VTFT_CTRL +#if defined(TFT_NV3001B) + // Re-init left display RAM undefined, so repaint in full rather than diff against a + // buffer that no longer describes the panel. + display(true); +#endif + +#if defined(VTFT_CTRL) && !defined(TFT_NV3001B) // NV3001B panels already powered the rail above digitalWrite(VTFT_CTRL, LOW); #endif #ifdef UNPHONE @@ -1660,7 +1678,7 @@ void TFTDisplay::sendCommand(uint8_t com) #endif #if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1) #elif !defined(M5STACK) && !defined(ST7789_CS) && \ - !defined(HACKADAY_COMMUNICATOR) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function + !defined(USE_ARDUINO_GFX) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function tft->setBrightness(172); #endif break; @@ -1672,7 +1690,7 @@ void TFTDisplay::sendCommand(uint8_t com) tft->clear(); if (portduino_config.displayBacklight.pin > 0) digitalWrite(portduino_config.displayBacklight.pin, !TFT_BACKLIGHT_ON); -#elif defined(HACKADAY_COMMUNICATOR) +#elif defined(USE_ARDUINO_GFX) tft->displayOff(); #elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) && \ !defined(HELTEC_MESH_NODE_T1) @@ -1687,7 +1705,7 @@ void TFTDisplay::sendCommand(uint8_t com) unphone.backlight(false); // using unPhone library #endif #if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1) -#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) +#elif !defined(M5STACK) && !defined(USE_ARDUINO_GFX) tft->setBrightness(0); #endif break; @@ -1703,7 +1721,7 @@ void TFTDisplay::setDisplayBrightness(uint8_t _brightness) { #if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1) // todo -#elif !defined(HACKADAY_COMMUNICATOR) +#elif !defined(USE_ARDUINO_GFX) tft->setBrightness(_brightness); LOG_DEBUG("Brightness is set to value: %i ", _brightness); #endif @@ -1721,7 +1739,7 @@ bool TFTDisplay::hasTouch(void) { #ifdef RAK14014 return true; -#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1) +#elif !defined(M5STACK) && !defined(USE_ARDUINO_GFX) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1) return tft->touch() != nullptr; #else return false; @@ -1740,7 +1758,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y) } else { return false; } -#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1) +#elif !defined(M5STACK) && !defined(USE_ARDUINO_GFX) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1) return tft->getTouch(x, y); #else return false; @@ -1768,6 +1786,21 @@ bool TFTDisplay::connect() tft = new Arduino_NV3007(bus, 40, 0 /* rotation */, false /* IPS */, 142 /* width */, 428 /* height */, 12 /* col offset 1 */, 0 /* row offset 1 */, 14 /* col offset 2 */, 0 /* row offset 2 */, nv3007_279_init_operations, sizeof(nv3007_279_init_operations)); +#elif defined(TFT_NV3001B) + // The Heltec RC panels all use the same controller and differ only in how the bus is wired. +#if defined(HELTEC_RC52) + // nRF52840: the panel sits on SPI1, clear of the LoRa radio on SPI0. + Arduino_DataBus *bus = new Arduino_HWSPI(TFT_RS, TFT_CS, &SPI1, true /* is_shared_interface */); +#elif defined(HELTEC_RCC6) + // ESP32-C6: the panel shares pins with the LoRa host, so bit-bang it rather than claim the peripheral. + Arduino_DataBus *bus = new Arduino_SWSPI(TFT_RS, TFT_CS, TFT_SCL, TFT_SDA, GFX_NOT_DEFINED /* MISO */); +#else + // ESP32-S3: keep the panel off the LoRa FSPI host, since Arduino_GFX reconfigures whichever bus it is handed. + Arduino_DataBus *bus = + new Arduino_ESP32SPI(TFT_RS, TFT_CS, TFT_SCL, TFT_SDA, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */); +#endif + tft = new Arduino_NV3001B(bus, TFT_RST, 3 /* rotation */, true /* IPS */, TFT_WIDTH, TFT_HEIGHT, 0 /* col offset 1 */, + 0 /* row offset 1 */, 0 /* col offset 2 */, 0 /* row offset 2 */); #else tft = new LGFX; #endif @@ -1779,8 +1812,13 @@ bool TFTDisplay::connect() #ifdef UNPHONE unphone.backlight(true); // using unPhone library #endif -#ifdef HACKADAY_COMMUNICATOR +#ifdef USE_ARDUINO_GFX +#if defined(TFT_NV3001B) + // Arduino_SWSPI ignores the clock argument, so this only bites on the hardware-SPI variants. + bool beginStatus = tft->begin(SPI_FREQUENCY); +#else bool beginStatus = tft->begin(); +#endif if (beginStatus) LOG_DEBUG("TFT Success"); else diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index 7b0f830c5..8e247bfcc 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -20,6 +20,7 @@ #include "input/RotaryEncoderImpl.h" #include "input/RotaryEncoderInterruptImpl1.h" #include "input/SerialKeyboardImpl.h" +#include "input/TCA6408Rotary.h" #include "input/UpDownInterruptImpl1.h" #include "input/i2cButton.h" #if HAS_TRACKBALL @@ -468,6 +469,13 @@ void InputBroker::Init() #if defined(M5STACK_UNITC6L) i2cButton = new i2cButtonThread("i2cButtonThread"); #endif +#if defined(HAS_TCA6408_ROTARY) + tca6408Rotary = new TCA6408Rotary("TCA6408Rotary"); + if (!tca6408Rotary->init()) { + delete tca6408Rotary; + tca6408Rotary = nullptr; + } +#endif #ifdef INPUTBROKER_MATRIX_TYPE kbMatrixImpl = new KbMatrixImpl(); kbMatrixImpl->init(); diff --git a/src/input/TCA6408Rotary.cpp b/src/input/TCA6408Rotary.cpp new file mode 100644 index 000000000..3e1f8a9a0 --- /dev/null +++ b/src/input/TCA6408Rotary.cpp @@ -0,0 +1,156 @@ +#include "TCA6408Rotary.h" + +#if defined(HAS_TCA6408_ROTARY) + +#include "Throttle.h" +#include "main.h" +#include + +namespace +{ +constexpr uint8_t TCA6408_ADDR = 0x20; +constexpr uint8_t TCA6408_INPUT_REG = 0x00; +constexpr uint8_t TCA6408_POLARITY_REG = 0x02; +constexpr uint8_t TCA6408_CONFIG_REG = 0x03; +constexpr uint8_t TCA6408_ROTARY_A_MASK = 0x01; +constexpr uint8_t TCA6408_ROTARY_B_MASK = 0x02; +constexpr uint8_t TCA6408_ROTARY_MASK = TCA6408_ROTARY_A_MASK | TCA6408_ROTARY_B_MASK; +constexpr uint32_t TCA6408_DEBOUNCE_MS = 5; +constexpr uint32_t TCA6408_POLL_MS = 100; + +enum class RotaryAction : uint8_t { NONE, UP, DOWN }; +} // namespace + +TCA6408Rotary *tca6408Rotary; +TCA6408Rotary *TCA6408Rotary::instance = nullptr; + +TCA6408Rotary::TCA6408Rotary(const char *name) + : concurrency::OSThread(name, TCA6408_POLL_MS), _originName(name), inputState(TCA6408_ROTARY_MASK) +{ +} + +bool TCA6408Rotary::init() +{ + if (!inputBroker) + return false; + + powerSensorBus(); + pinMode(SENSOR_INT, INPUT_PULLUP); + + // No input inversion, all eight pins configured as inputs. + if (!writeRegister(TCA6408_POLARITY_REG, 0x00) || !writeRegister(TCA6408_CONFIG_REG, 0xFF) || !readInput(inputState)) { + LOG_INFO("TCA6408 rotary not detected"); + concurrency::OSThread::disable(); + return false; + } + + inputBroker->registerSource(this); + instance = this; + attachInterrupt(digitalPinToInterrupt(SENSOR_INT), interruptHandler, FALLING); + ready = true; + LOG_INFO("TCA6408 rotary ready at 0x%02x", TCA6408_ADDR); + return true; +} + +int32_t TCA6408Rotary::runOnce() +{ + if (!ready) + return concurrency::OSThread::disable(); + + uint8_t newState = 0; + if (!readInput(newState)) { + LOG_DEBUG("TCA6408 rotary read failed"); + return TCA6408_POLL_MS; + } + + handleTransition(newState); + inputState = newState; + return TCA6408_POLL_MS; +} + +// Only wakes the thread. Reading the expander here would put an I2C transfer in interrupt +// context and race runOnce() for the bus and the decoder state. +void TCA6408Rotary::interruptHandler() +{ + if (!instance) + return; + + instance->setIntervalFromNow(0); + runASAP = true; + BaseType_t higherWake = 0; + concurrency::mainDelay.interruptFromISR(&higherWake); +} + +void TCA6408Rotary::powerSensorBus() +{ +#ifdef SENSOR_POWER_CTRL_PIN + pinMode(SENSOR_POWER_CTRL_PIN, OUTPUT); + digitalWrite(SENSOR_POWER_CTRL_PIN, SENSOR_POWER_ON); +#ifdef PERIPHERAL_WARMUP_MS + delay(PERIPHERAL_WARMUP_MS); +#else + delay(20); +#endif +#endif +} + +bool TCA6408Rotary::writeRegister(uint8_t reg, uint8_t value) +{ + Wire.beginTransmission(TCA6408_ADDR); + Wire.write(reg); + Wire.write(value); + return Wire.endTransmission() == 0; +} + +bool TCA6408Rotary::readInput(uint8_t &value) +{ + Wire.beginTransmission(TCA6408_ADDR); + Wire.write(TCA6408_INPUT_REG); + if (Wire.endTransmission(false) != 0) + return false; + if (Wire.requestFrom(TCA6408_ADDR, static_cast(1)) != 1) + return false; + + value = Wire.read(); + return true; +} + +// Whichever of A/B falls first decides the direction for the whole detent; activeLowPhase then +// suppresses further events until both inputs come back high. The rising-edge cases below catch +// detents whose falling edge was missed because the poll landed mid-rotation. +void TCA6408Rotary::handleTransition(uint8_t newState) +{ + const uint8_t changed = (inputState ^ newState) & TCA6408_ROTARY_MASK; + const bool aLow = (newState & TCA6408_ROTARY_A_MASK) == 0; + const bool bLow = (newState & TCA6408_ROTARY_B_MASK) == 0; + RotaryAction action = RotaryAction::NONE; + + if (!aLow && !bLow) + activeLowPhase = false; // back at the detent, arm for the next turn + + if (!activeLowPhase) { + if ((changed & TCA6408_ROTARY_A_MASK) && aLow && !bLow) { + action = RotaryAction::UP; + activeLowPhase = true; + } else if ((changed & TCA6408_ROTARY_B_MASK) && bLow && !aLow) { + action = RotaryAction::DOWN; + activeLowPhase = true; + } else if ((changed & TCA6408_ROTARY_A_MASK) && !aLow && bLow) { + action = RotaryAction::UP; + } else if ((changed & TCA6408_ROTARY_B_MASK) && !bLow && aLow) { + action = RotaryAction::DOWN; + } + } + + if (action == RotaryAction::NONE || Throttle::isWithinTimespanMs(lastEventMs, TCA6408_DEBOUNCE_MS)) + return; + + lastEventMs = millis(); + InputEvent event = {}; + event.source = _originName; + event.inputEvent = action == RotaryAction::DOWN ? INPUT_BROKER_DOWN : INPUT_BROKER_UP; + LOG_DEBUG("TCA6408 rotary event %d state=0x%02x", event.inputEvent, newState); + notifyObservers(&event); +} + +#endif diff --git a/src/input/TCA6408Rotary.h b/src/input/TCA6408Rotary.h new file mode 100644 index 000000000..600311bb9 --- /dev/null +++ b/src/input/TCA6408Rotary.h @@ -0,0 +1,40 @@ +#pragma once + +#include "InputBroker.h" +#include "concurrency/OSThread.h" +#include "configuration.h" + +#if defined(HAS_TCA6408_ROTARY) + +/** + * Rotary encoder on the A/B inputs of a TCA6408 I2C GPIO expander. SENSOR_INT goes low on any + * input change; the interrupt only wakes this thread, so all I2C and decoder state stay on it. + */ +class TCA6408Rotary : public Observable, public concurrency::OSThread +{ + public: + explicit TCA6408Rotary(const char *name); + bool init(); + int32_t runOnce() override; + + private: + static void interruptHandler(); + void powerSensorBus(); + bool writeRegister(uint8_t reg, uint8_t value); + bool readInput(uint8_t &value); + void handleTransition(uint8_t newState); + + const char *_originName; + uint8_t inputState; + uint32_t lastEventMs = 0; + bool ready = false; + // Latched between the first falling edge of a detent and both inputs returning high, + // so one detent reports exactly one event. + bool activeLowPhase = false; + + static TCA6408Rotary *instance; +}; + +extern TCA6408Rotary *tca6408Rotary; + +#endif diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index ca4b30155..87aab5ad9 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -107,7 +107,7 @@ __attribute__((noinline)) void variantDefaultConfig() {} __attribute__((noinline)) void variantDefaultModuleConfig() __attribute__((weak)); __attribute__((noinline)) void variantDefaultModuleConfig() {} -#ifdef HELTEC_MESH_NODE_T114 +#if defined(HELTEC_MESH_NODE_T114) || defined(TFT_NV3001B_DETECT) uint32_t read8(uint8_t bits, uint8_t dummy, uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst) { @@ -157,6 +157,10 @@ uint32_t readwrite8(uint8_t cmd, uint8_t bits, uint8_t dummy, uint8_t cs, uint8_ return ret; } +#endif + +#ifdef HELTEC_MESH_NODE_T114 + uint32_t get_st7789_id(uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst) { pinMode(cs, OUTPUT); @@ -178,6 +182,57 @@ uint32_t get_st7789_id(uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_ #endif +#ifdef TFT_NV3001B_DETECT + +// The NV3001B panel is an add-on module on these boards, so probe for it before assuming a screen. +static constexpr uint32_t NV3001B_PANEL_ID = 0x300101; +static constexpr uint32_t NV3001B_RESET_DELAY_MS = 120; // NV3001B_RST_DELAY, per the Arduino_GFX driver + +bool nv3001bPanelPresent(uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst, uint8_t en, uint8_t bl) +{ + pinMode(en, OUTPUT); + digitalWrite(en, TFT_EN_ON); + pinMode(bl, OUTPUT); + digitalWrite(bl, TFT_BACKLIGHT_ON); + delay(NV3001B_RESET_DELAY_MS); + + pinMode(cs, OUTPUT); + digitalWrite(cs, HIGH); + pinMode(sck, OUTPUT); + digitalWrite(sck, LOW); + pinMode(mosi, OUTPUT); + pinMode(dc, OUTPUT); + pinMode(rst, OUTPUT); + digitalWrite(rst, HIGH); + delay(NV3001B_RESET_DELAY_MS); + digitalWrite(rst, LOW); // Hardware Reset + delay(NV3001B_RESET_DELAY_MS); + digitalWrite(rst, HIGH); + delay(NV3001B_RESET_DELAY_MS); + + // 0x04 reports the whole 24-bit display ID; 0xDA/0xDB/0xDC report it one byte at a time. + // A panel that answers either way is present. + uint32_t rddid = readwrite8(0x04, 24, 1, cs, sck, mosi, dc, rst); + uint32_t rdid = (readwrite8(0xDA, 8, 0, cs, sck, mosi, dc, rst) << 16) | + (readwrite8(0xDB, 8, 0, cs, sck, mosi, dc, rst) << 8) | readwrite8(0xDC, 8, 0, cs, sck, mosi, dc, rst); + LOG_INFO("NV3001B probe RDDID=0x%06x RDID=0x%06x", (unsigned int)rddid, (unsigned int)rdid); + + if (rddid == NV3001B_PANEL_ID || rdid == NV3001B_PANEL_ID) { + LOG_INFO("NV3001B panel detected"); + return true; + } + + // All ones means the data line floated, all zeroes means something held it low; either way no panel + // answered, so drop the rail again rather than leave an empty header powered. + LOG_INFO("NV3001B panel not detected"); + digitalWrite(bl, TFT_BACKLIGHT_OFF); + digitalWrite(en, TFT_EN_OFF); + pinMode(en, INPUT); + return false; +} + +#endif + // When armed by loadFromDisk, the decode callback writes satellite entries // straight into these maps instead of the temp vectors. Nullptr = legacy // push_back-to-vector path for backup/restore and other decoders. @@ -1046,12 +1101,14 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #if defined(USE_EINK) || defined(HAS_SPI_TFT) || defined(USE_SPISSD1306) bool hasScreen = true; -#ifdef HELTEC_MESH_NODE_T114 +#if defined(TFT_NV3001B_DETECT) + hasScreen = nv3001bPanelPresent(TFT_CS, TFT_SCL, TFT_SDA, TFT_RS, TFT_RST, TFT_EN, TFT_BL); +#elif defined(HELTEC_MESH_NODE_T114) uint32_t st7789_id = get_st7789_id(ST7789_NSS, ST7789_SCK, ST7789_SDA, ST7789_RS, ST7789_RESET); if (st7789_id == 0xFFFFFF) { hasScreen = false; } -#endif // HELTEC_MESH_NODE_T114 +#endif // TFT_NV3001B_DETECT / HELTEC_MESH_NODE_T114 #elif ARCH_PORTDUINO bool hasScreen = false; if (portduino_config.displayPanel) diff --git a/src/platform/esp32/architecture.h b/src/platform/esp32/architecture.h index 48618b641..2c409b0b8 100644 --- a/src/platform/esp32/architecture.h +++ b/src/platform/esp32/architecture.h @@ -221,6 +221,10 @@ #define HW_VENDOR meshtastic_HardwareModel_HELTEC_V4_R8 #elif defined(MINI_EPAPER_S3) #define HW_VENDOR meshtastic_HardwareModel_MINI_EPAPER_S3 +#elif defined(HELTEC_RC32) +#define HW_VENDOR meshtastic_HardwareModel_HELTEC_RC32 +#elif defined(HELTEC_RCC6) +#define HW_VENDOR meshtastic_HardwareModel_HELTEC_RCC6 #else #define HW_VENDOR meshtastic_HardwareModel_PRIVATE_HW #endif diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index 4a6afaeae..279ad7575 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -149,6 +149,8 @@ #define HW_VENDOR meshtastic_HardwareModel_HELTEC_MESH_TOWER_V2 #elif defined(HELTEC_MESH_NODE_T096) #define HW_VENDOR meshtastic_HardwareModel_HELTEC_MESH_NODE_T096 +#elif defined(HELTEC_RC52) +#define HW_VENDOR meshtastic_HardwareModel_HELTEC_RC52 #else #define HW_VENDOR meshtastic_HardwareModel_NRF52_UNKNOWN #endif diff --git a/variants/esp32c6/heltec_rcc6/pins_arduino.h b/variants/esp32c6/heltec_rcc6/pins_arduino.h new file mode 100644 index 000000000..91604ac60 --- /dev/null +++ b/variants/esp32c6/heltec_rcc6/pins_arduino.h @@ -0,0 +1,20 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303A +#define USB_PID 0x1001 + +static const uint8_t TX = 16; +static const uint8_t RX = 17; + +static const int8_t SDA = -1; +static const int8_t SCL = -1; + +static const uint8_t MISO = 20; +static const uint8_t SCK = 21; +static const uint8_t MOSI = 22; +static const uint8_t SS = 23; + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32c6/heltec_rcc6/platformio.ini b/variants/esp32c6/heltec_rcc6/platformio.ini new file mode 100644 index 000000000..66042e1cf --- /dev/null +++ b/variants/esp32c6/heltec_rcc6/platformio.ini @@ -0,0 +1,35 @@ +[env:heltec-rcc6] +custom_meshtastic_hw_model = 143 +custom_meshtastic_hw_model_slug = HELTEC_RCC6 +custom_meshtastic_architecture = esp32-c6 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec RCC6 +custom_meshtastic_images = heltec-rcc6.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = false + +extends = esp32c6_base +board = heltec_rcc6 +board_level = release +board_build.partitions = default_16MB.csv +upload_protocol = esptool + +build_flags = + ${esp32c6_base.build_flags} + -D HELTEC_RCC6 + -I variants/esp32c6/heltec_rcc6 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_USB_MODE=1 + -DMESHTASTIC_EXCLUDE_AUDIO=1 + +lib_deps = + ${esp32c6_base.lib_deps} + # renovate: datasource=git-refs depName=moononournation-Arduino_GFX packageName=https://github.com/moononournation/Arduino_GFX gitBranch=master + # Pinned to a commit rather than a tag: the NV3001B driver has not shipped in a release yet. + https://github.com/moononournation/Arduino_GFX/archive/4d5afb05ce70a51895fe65efc7838d606b2a95a7.zip + +lib_ignore = + ${esp32c6_base.lib_ignore} + ESP32 Codec2 diff --git a/variants/esp32c6/heltec_rcc6/variant.h b/variants/esp32c6/heltec_rcc6/variant.h new file mode 100644 index 000000000..3436aff05 --- /dev/null +++ b/variants/esp32c6/heltec_rcc6/variant.h @@ -0,0 +1,59 @@ +#pragma once + +#define _VARIANT_HELTEC_RCC6_ + +#define BUTTON_PIN 9 + +#define HAS_SCREEN 1 +#define HAS_SPI_TFT 1 +#define USE_TFTDISPLAY 1 +#define USE_ARDUINO_GFX 1 +#define TFT_NV3001B 1 +#define TFT_NV3001B_DETECT 1 +#define HAS_GPS 0 +#define HAS_WIRE 0 +#undef GPS_RX_PIN +#undef GPS_TX_PIN + +#define USE_SX1262 +#define LORA_SCK 21 +#define LORA_MISO 20 +#define LORA_MOSI 22 +#define LORA_CS 23 +#define LORA_RESET 8 +#define LORA_DIO1 19 +#define LORA_BUSY 10 +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_BUSY +#define SX126X_RESET LORA_RESET +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define BATTERY_PIN 6 +#define ADC_CHANNEL ADC_CHANNEL_6 +#define ADC_CTRL 5 +#define ADC_CTRL_ENABLED HIGH +#define ADC_MULTIPLIER 4.95f + +#define TFT_SCL 4 +#define TFT_SDA 15 +#define TFT_CS 18 +#define TFT_RS 3 +#define TFT_DC TFT_RS +#define TFT_RST 0 +#define TFT_EN 2 +#define TFT_EN_ON LOW +#define TFT_EN_OFF HIGH +#define VTFT_CTRL TFT_EN +#define TFT_BL 1 +#define TFT_BLK TFT_BL +#define TFT_BACKLIGHT_ON HIGH +#define TFT_BACKLIGHT_OFF LOW +#define TFT_WIDTH 128 +#define TFT_HEIGHT 220 +#define TFT_BLACK 0 +#define SPI_FREQUENCY 4000000 +#define SCREEN_ROTATE + +#define SERIAL_PRINT_PORT 1 diff --git a/variants/esp32s3/hackaday-communicator/variant.h b/variants/esp32s3/hackaday-communicator/variant.h index ab15e3e6d..b34ea861d 100644 --- a/variants/esp32s3/hackaday-communicator/variant.h +++ b/variants/esp32s3/hackaday-communicator/variant.h @@ -12,6 +12,7 @@ #define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness #define USE_TFTDISPLAY 1 #define HAS_SPI_TFT 1 +#define USE_ARDUINO_GFX 1 #define USE_POWERSAVE #define SLEEP_TIME 120 diff --git a/variants/esp32s3/heltec_rc32/pins_arduino.h b/variants/esp32s3/heltec_rc32/pins_arduino.h new file mode 100644 index 000000000..1df1080bf --- /dev/null +++ b/variants/esp32s3/heltec_rc32/pins_arduino.h @@ -0,0 +1,60 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 21; +static const uint8_t SCL = 18; + +static const uint8_t SS = 10; +static const uint8_t MOSI = 12; +static const uint8_t MISO = 13; +static const uint8_t SCK = 11; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +static const uint8_t RST_LoRa = 9; +static const uint8_t BUSY_LoRa = 1; +static const uint8_t DIO1_LoRa = 14; + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/heltec_rc32/platformio.ini b/variants/esp32s3/heltec_rc32/platformio.ini new file mode 100644 index 000000000..827f7c302 --- /dev/null +++ b/variants/esp32s3/heltec_rc32/platformio.ini @@ -0,0 +1,28 @@ +[env:heltec-rc32] +custom_meshtastic_hw_model = 141 +custom_meshtastic_hw_model_slug = HELTEC_RC32 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_display_name = Heltec RC32 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_images = heltec-rc32.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = false + +extends = esp32s3_base +board = heltec_rc32 +board_level = release +board_build.partitions = default_16MB.csv +build_flags = + ${esp32s3_base.build_flags} + -D HELTEC_RC32 + -D USE_PIN_BUZZER=PIN_BUZZER + -D BOARD_HAS_PSRAM + -I variants/esp32s3/heltec_rc32 +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=git-refs depName=moononournation-Arduino_GFX packageName=https://github.com/moononournation/Arduino_GFX gitBranch=master + # Pinned to a commit rather than a tag: the NV3001B driver has not shipped in a release yet. + https://github.com/moononournation/Arduino_GFX/archive/4d5afb05ce70a51895fe65efc7838d606b2a95a7.zip diff --git a/variants/esp32s3/heltec_rc32/sdkconfig.h b/variants/esp32s3/heltec_rc32/sdkconfig.h new file mode 100644 index 000000000..d7d875010 --- /dev/null +++ b/variants/esp32s3/heltec_rc32/sdkconfig.h @@ -0,0 +1,24 @@ +#pragma once + +#include_next "sdkconfig.h" + +// qio_opi enables this, but Meshtastic removes the matching IDF component. +#ifdef CONFIG_NETWORK_PROV_NETWORK_TYPE_WIFI +#undef CONFIG_NETWORK_PROV_NETWORK_TYPE_WIFI +#endif +#define CONFIG_NETWORK_PROV_NETWORK_TYPE_WIFI 0 + +#ifndef CONFIG_LITTLEFS_PAGE_SIZE +#define CONFIG_LITTLEFS_MAX_PARTITIONS 3 +#define CONFIG_LITTLEFS_PAGE_SIZE 256 +#define CONFIG_LITTLEFS_OBJ_NAME_LEN 64 +#define CONFIG_LITTLEFS_READ_SIZE 128 +#define CONFIG_LITTLEFS_WRITE_SIZE 128 +#define CONFIG_LITTLEFS_LOOKAHEAD_SIZE 128 +#define CONFIG_LITTLEFS_CACHE_SIZE 512 +#define CONFIG_LITTLEFS_BLOCK_CYCLES 512 +#define CONFIG_LITTLEFS_USE_MTIME 1 +#define CONFIG_LITTLEFS_MTIME_USE_SECONDS 1 +#define CONFIG_LITTLEFS_MALLOC_STRATEGY_DEFAULT 1 +#define CONFIG_LITTLEFS_ASSERTS 1 +#endif diff --git a/variants/esp32s3/heltec_rc32/variant.h b/variants/esp32s3/heltec_rc32/variant.h new file mode 100644 index 000000000..413d39d81 --- /dev/null +++ b/variants/esp32s3/heltec_rc32/variant.h @@ -0,0 +1,78 @@ +#ifndef _VARIANT_HELTEC_RC32_ +#define _VARIANT_HELTEC_RC32_ + +#define HAS_SCREEN 1 +#define HAS_SPI_TFT 1 +#define USE_TFTDISPLAY 1 +#define USE_ARDUINO_GFX 1 +#define TFT_NV3001B 1 +#define TFT_NV3001B_DETECT 1 +#define TFT_SCL 17 +#define TFT_SDA 38 +#define TFT_CS 39 +#define TFT_RS 16 +#define TFT_DC TFT_RS +#define TFT_RST 4 +#define TFT_EN 6 +#define TFT_EN_ON LOW +#define TFT_EN_OFF HIGH +#define VTFT_CTRL TFT_EN +#define TFT_BL 5 +#define TFT_BLK TFT_BL +#define TFT_BACKLIGHT_ON HIGH +#define TFT_BACKLIGHT_OFF LOW +#define TFT_WIDTH 128 +#define TFT_HEIGHT 220 +#define TFT_BLACK 0 +#define SPI_FREQUENCY 8000000 +#define SCREEN_ROTATE + +#define BUTTON_PIN 0 + +#define HAS_GPS 1 +#undef GPS_RX_PIN +#undef GPS_TX_PIN +#define GPS_RX_PIN 44 +#define GPS_TX_PIN 43 +#define PIN_GPS_EN 45 +#define GPS_EN_ACTIVE HIGH +#define PIN_GPS_RESET 40 +#define GPS_RESET_MODE LOW +#define PIN_GPS_PPS 41 + +#define I2C_SCL 18 +#define I2C_SDA 21 +#define SENSOR_INT 42 +#define SENSOR_RST 2 +#define SENSOR_POWER_CTRL_PIN 46 +#define SENSOR_POWER_ON HIGH +#define HAS_TCA6408_ROTARY 1 +#define PERIPHERAL_WARMUP_MS 100 + +#define LED_POWER 47 +#define PIN_BUZZER 48 + +#define USE_SX1262 +#define LORA_SCK 11 +#define LORA_MISO 13 +#define LORA_MOSI 12 +#define LORA_CS 10 +#define LORA_DIO0 RADIOLIB_NC +#define LORA_DIO1 14 +#define LORA_RESET 9 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY 1 +#define SX126X_RESET LORA_RESET +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define BATTERY_PIN 7 +#define ADC_CHANNEL ADC_CHANNEL_6 +#define ADC_CTRL 15 +#define ADC_CTRL_ENABLED HIGH +#define ADC_MULTIPLIER 4.9 +#define ADC_ATTENUATION ADC_ATTEN_DB_2_5 + +#endif diff --git a/variants/nrf52840/heltec_rc52/platformio.ini b/variants/nrf52840/heltec_rc52/platformio.ini new file mode 100644 index 000000000..f57e5f7f9 --- /dev/null +++ b/variants/nrf52840/heltec_rc52/platformio.ini @@ -0,0 +1,30 @@ +; Heltec RC52 nRF52840/SX1262 device +[env:heltec-rc52] +custom_meshtastic_hw_model = 142 +custom_meshtastic_hw_model_slug = HELTEC_RC52 +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec RC52 +custom_meshtastic_images = heltec-rc52.svg +custom_meshtastic_tags = Heltec + +extends = nrf52840_base +board = heltec_rc52 +board_level = release +debug_tool = jlink + +build_flags = ${nrf52840_base.build_flags} + -UOLEDDISPLAY_REDUCE_MEMORY ; TFTDisplay.cpp needs the lib's buffer_back for dirty-window diffing + -Ivariants/nrf52840/heltec_rc52 + -DHELTEC_RC52 + -D PIN_BUZZER=5 + -D USE_PIN_BUZZER=PIN_BUZZER + -DCONFIG_NFCT_PINS_AS_GPIOS=1 + +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_rc52> +lib_deps = + ${nrf52840_base.lib_deps} + # renovate: datasource=git-refs depName=moononournation-Arduino_GFX packageName=https://github.com/moononournation/Arduino_GFX gitBranch=master + # Pinned to a commit rather than a tag: the NV3001B driver has not shipped in a release yet. + https://github.com/moononournation/Arduino_GFX/archive/4d5afb05ce70a51895fe65efc7838d606b2a95a7.zip diff --git a/variants/nrf52840/heltec_rc52/variant.cpp b/variants/nrf52840/heltec_rc52/variant.cpp new file mode 100644 index 000000000..3af5c4032 --- /dev/null +++ b/variants/nrf52840/heltec_rc52/variant.cpp @@ -0,0 +1,103 @@ +#include "variant.h" +#include "Arduino.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +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(PIN_LED1, OUTPUT); + digitalWrite(PIN_LED1, LED_STATE_OFF); + + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE); + pinMode(PIN_GPS_RESET, OUTPUT); + digitalWrite(PIN_GPS_RESET, !GPS_RESET_MODE); + pinMode(PIN_GPS_PPS, INPUT); + + pinMode(SENSOR_POWER_CTRL_PIN, OUTPUT); + digitalWrite(SENSOR_POWER_CTRL_PIN, SENSOR_POWER_ON); + pinMode(SENSOR_RST, OUTPUT); + digitalWrite(SENSOR_RST, HIGH); + pinMode(SENSOR_INT, INPUT); + + pinMode(PIN_BUZZER, OUTPUT); + digitalWrite(PIN_BUZZER, LOW); +} + +void variant_shutdown() +{ + detachInterrupt(PIN_BUTTON1); + detachInterrupt(PIN_GPS_PPS); + detachInterrupt(SENSOR_INT); + + pinMode(PIN_GPS_EN, OUTPUT); + digitalWrite(PIN_GPS_EN, !GPS_EN_ACTIVE); + + pinMode(SENSOR_POWER_CTRL_PIN, OUTPUT); + digitalWrite(SENSOR_POWER_CTRL_PIN, !SENSOR_POWER_ON); + + pinMode(PIN_BUZZER, OUTPUT); + digitalWrite(PIN_BUZZER, LOW); + pinMode(PIN_LED1, OUTPUT); + digitalWrite(PIN_LED1, LOW); + pinMode(ADC_CTRL, OUTPUT); + digitalWrite(ADC_CTRL, !ADC_CTRL_ENABLED); + pinMode(RADIOCORE_FEM_EN, OUTPUT); + digitalWrite(RADIOCORE_FEM_EN, LOW); + pinMode(RADIOCORE_VFEM_CTRL, OUTPUT); + digitalWrite(RADIOCORE_VFEM_CTRL, LOW); + + pinMode(PIN_WIRE_SDA, OUTPUT); + digitalWrite(PIN_WIRE_SDA, LOW); + pinMode(PIN_WIRE_SCL, OUTPUT); + digitalWrite(PIN_WIRE_SCL, LOW); + nrf_gpio_cfg_default(PIN_WIRE_SDA); + nrf_gpio_cfg_default(PIN_WIRE_SCL); + + nrf_gpio_cfg_default(PIN_GPS_PPS); + nrf_gpio_cfg_default(GPS_RX_PIN); + nrf_gpio_cfg_default(GPS_TX_PIN); + nrf_gpio_cfg_default(PIN_GPS_RESET); + nrf_gpio_cfg_default(SENSOR_POWER_CTRL_PIN); + nrf_gpio_cfg_default(SENSOR_RST); + nrf_gpio_cfg_default(SENSOR_INT); + + nrf_gpio_cfg_default(SX126X_DIO1); + nrf_gpio_cfg_default(SX126X_BUSY); + nrf_gpio_cfg_default(SX126X_RESET); + nrf_gpio_cfg_default(PIN_SPI_MISO); + nrf_gpio_cfg_default(PIN_SPI_MOSI); + nrf_gpio_cfg_default(PIN_SPI_SCK); + nrf_gpio_cfg_default(RADIOCORE_FEM_EN); + nrf_gpio_cfg_default(RADIOCORE_VFEM_CTRL); + + pinMode(TFT_EN, OUTPUT); + digitalWrite(TFT_EN, HIGH); + pinMode(TFT_BL, OUTPUT); + digitalWrite(TFT_BL, LOW); + pinMode(TFT_CS, OUTPUT); + digitalWrite(TFT_CS, LOW); + pinMode(TFT_RS, OUTPUT); + digitalWrite(TFT_RS, LOW); + pinMode(TFT_SCL, OUTPUT); + digitalWrite(TFT_SCL, LOW); + pinMode(TFT_SDA, OUTPUT); + digitalWrite(TFT_SDA, LOW); + pinMode(TFT_RST, OUTPUT); + digitalWrite(TFT_RST, LOW); + nrf_gpio_cfg_default(TFT_EN); + nrf_gpio_cfg_default(TFT_BL); + nrf_gpio_cfg_default(TFT_CS); + nrf_gpio_cfg_default(TFT_RS); + nrf_gpio_cfg_default(TFT_SCL); + nrf_gpio_cfg_default(TFT_SDA); + nrf_gpio_cfg_default(TFT_RST); +} \ No newline at end of file diff --git a/variants/nrf52840/heltec_rc52/variant.h b/variants/nrf52840/heltec_rc52/variant.h new file mode 100644 index 000000000..22b0b9a1a --- /dev/null +++ b/variants/nrf52840/heltec_rc52/variant.h @@ -0,0 +1,136 @@ +#ifndef _VARIANT_HELTEC_RC52_ +#define _VARIANT_HELTEC_RC52_ + +#define VARIANT_MCK (64000000ul) +#define USE_LFXO // Board uses 32 kHz crystal for LF + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +#define PIN_LED1 (0 + 15) +#define LED_POWER PIN_LED1 +#define LED_BLUE (-1) +#define LED_GREEN PIN_LED1 +#define LED_STATE_ON 1 +#define LED_STATE_OFF (LED_STATE_ON ^ 1) + +#define HAS_SCREEN 1 +#define HAS_SPI_TFT 1 +#define USE_TFTDISPLAY 1 +#define USE_ARDUINO_GFX 1 +#define TFT_NV3001B 1 +#define TFT_NV3001B_DETECT 1 +#define HAS_GPS 1 +#define HAS_WIRE 1 + +/* + * Optional RS-T108 / NV3001B TFT module on P2 + */ +#define TFT_SCL (0 + 30) +#define TFT_SDA (32 + 2) +#define TFT_CS (32 + 4) +#define TFT_RS (0 + 28) +#define TFT_DC TFT_RS +#define TFT_RST (0 + 10) +#define TFT_EN (32 + 13) +#define TFT_EN_ON LOW +#define TFT_EN_OFF HIGH +#define VTFT_CTRL TFT_EN +#define TFT_BL (0 + 9) +#define TFT_BACKLIGHT_ON HIGH +#define TFT_BACKLIGHT_OFF LOW +#define TFT_WIDTH 128 +#define TFT_HEIGHT 220 +#define SPI_FREQUENCY 8000000 +#define SCREEN_ROTATE + +/* + * Buttons + */ +#define PIN_BUTTON1 (32 + 10) // P1.10, external pull-up, active low + +/* + * Sensor I2C and control pins + */ +#define WIRE_INTERFACES_COUNT 1 +#define PIN_WIRE_SDA (32 + 11) // P1.11 +#define PIN_WIRE_SCL (0 + 2) // P0.02 +#define SENSOR_POWER_CTRL_PIN (0 + 12) +#define SENSOR_POWER_ON HIGH +#define SENSOR_INT (0 + 20) +#define SENSOR_RST (32 + 15) +#define HAS_TCA6408_ROTARY 1 + +/* + * Serial interfaces + */ +#define GPS_RX_PIN (0 + 8) +#define GPS_TX_PIN (0 + 7) +#define PIN_GPS_EN (32 + 9) +#define GPS_EN_ACTIVE HIGH +#define PIN_GPS_PPS (32 + 1) +#define PIN_GPS_RESET (32 + 6) +#define GPS_RESET_MODE LOW +#define GPS_THREAD_INTERVAL 50 +#define PERIPHERAL_WARMUP_MS 100 + +#define PIN_SERIAL1_RX GPS_RX_PIN +#define PIN_SERIAL1_TX GPS_TX_PIN +#define PIN_SERIAL2_RX (-1) +#define PIN_SERIAL2_TX (-1) + +/* + * LoRa radio + */ +#define USE_SX1262 +#define SX126X_CS (0 + 13) // P0.13 +#define LORA_CS SX126X_CS +#define SX126X_DIO1 (0 + 11) // P0.11 +#define SX126X_BUSY (0 + 24) // P0.24 +#define SX126X_RESET (32 + 0) // P1.00 +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define RADIOCORE_FEM_EN (0 + 26) +#define RADIOCORE_VFEM_CTRL (0 + 16) + +/* + * SPI + */ +#define SPI_INTERFACES_COUNT 2 +#define PIN_SPI_MISO (0 + 14) +#define PIN_SPI_MOSI (0 + 22) +#define PIN_SPI_SCK (0 + 25) + +#define PIN_SPI1_MISO (-1) +#define PIN_SPI1_MOSI TFT_SDA +#define PIN_SPI1_SCK TFT_SCL + +/* + * Battery + */ +#define ADC_CTRL (0 + 4) +#define ADC_CTRL_ENABLED HIGH +#define BATTERY_PIN (0 + 31) // P0.31/AIN7 +#define ADC_RESOLUTION 14 + +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#undef AREF_VOLTAGE +#define AREF_VOLTAGE 3.0 +#define VBAT_AR_INTERNAL AR_INTERNAL_3_0 +#define ADC_MULTIPLIER (4.9F) + +#ifdef __cplusplus +} +#endif + +#endif