Compare commits

...
Author SHA1 Message Date
kevin 8e03a9579c up
CI / setup (all) (push) Canceled after 0s
CI / setup (check) (push) Canceled after 0s
CI / version (push) Canceled after 0s
Build Debian Source Package / build-debian-src (push) Canceled after 0s
Package PlatformIO Library Dependencies / pkg-pio-libdeps (push) Canceled after 0s
Run Tests on Native platform / Generate Test Reports (push) Canceled after 0s
Run Tests on Native platform / Native PlatformIO Tests (push) Canceled after 0s
Run Tests on Native platform / Native Simulator Tests (push) Canceled after 0s
CI / check (${{ fromJson(needs.setup.outputs.check) }}) (push) Canceled after 0s
CI / build (${{ fromJson(needs.setup.outputs.all) }}) (push) Canceled after 0s
CI / gather-artifacts (esp32) (push) Canceled after 0s
CI / gather-artifacts (esp32c3) (push) Canceled after 0s
CI / gather-artifacts (esp32c6) (push) Canceled after 0s
CI / gather-artifacts (esp32s3) (push) Canceled after 0s
CI / gather-artifacts (nrf52840) (push) Canceled after 0s
CI / gather-artifacts (rp2040) (push) Canceled after 0s
CI / gather-artifacts (rp2350) (push) Canceled after 0s
CI / gather-artifacts (stm32) (push) Canceled after 0s
CI / firmware-size-report (push) Canceled after 0s
CI / release-artifacts (push) Canceled after 0s
CI / release-firmware (esp32) (push) Canceled after 0s
CI / release-firmware (esp32c3) (push) Canceled after 0s
CI / release-firmware (esp32c6) (push) Canceled after 0s
CI / release-firmware (esp32s3) (push) Canceled after 0s
CI / release-firmware (nrf52840) (push) Canceled after 0s
CI / release-firmware (rp2040) (push) Canceled after 0s
CI / release-firmware (rp2350) (push) Canceled after 0s
CI / release-firmware (stm32) (push) Canceled after 0s
CI / publish-firmware (push) Canceled after 0s
CI / build-debian-src (push) Canceled after 0s
CI / MacOS (15) (push) Canceled after 0s
CI / MacOS (26) (push) Canceled after 0s
Build MacOS Binary / build-MacOS (push) Canceled after 0s
CI / package-pio-deps-native-tft (push) Canceled after 0s
CI / test-native (push) Canceled after 0s
CI / docker (alpine, native, linux/amd64) (push) Canceled after 0s
CI / docker (alpine, native, linux/arm64) (push) Canceled after 0s
CI / docker (alpine, native-tft, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/arm/v7) (push) Canceled after 0s
CI / docker (debian, native, linux/arm64) (push) Canceled after 0s
CI / docker (debian, native-tft, linux/amd64) (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
2026-07-05 12:19:53 +08:00
kevin 59dd837c0e up
CI / setup (all) (push) Canceled after 0s
CI / setup (check) (push) Canceled after 0s
CI / version (push) Canceled after 0s
Build Debian Source Package / build-debian-src (push) Canceled after 0s
Package PlatformIO Library Dependencies / pkg-pio-libdeps (push) Canceled after 0s
Run Tests on Native platform / Generate Test Reports (push) Canceled after 0s
Run Tests on Native platform / Native PlatformIO Tests (push) Canceled after 0s
Run Tests on Native platform / Native Simulator Tests (push) Canceled after 0s
CI / check (${{ fromJson(needs.setup.outputs.check) }}) (push) Canceled after 0s
CI / build (${{ fromJson(needs.setup.outputs.all) }}) (push) Canceled after 0s
CI / gather-artifacts (esp32) (push) Canceled after 0s
CI / gather-artifacts (esp32c3) (push) Canceled after 0s
CI / gather-artifacts (esp32c6) (push) Canceled after 0s
CI / gather-artifacts (esp32s3) (push) Canceled after 0s
CI / gather-artifacts (nrf52840) (push) Canceled after 0s
CI / gather-artifacts (rp2040) (push) Canceled after 0s
CI / gather-artifacts (rp2350) (push) Canceled after 0s
CI / gather-artifacts (stm32) (push) Canceled after 0s
CI / firmware-size-report (push) Canceled after 0s
CI / release-artifacts (push) Canceled after 0s
CI / release-firmware (esp32) (push) Canceled after 0s
CI / release-firmware (esp32c3) (push) Canceled after 0s
CI / release-firmware (esp32c6) (push) Canceled after 0s
CI / release-firmware (esp32s3) (push) Canceled after 0s
CI / release-firmware (nrf52840) (push) Canceled after 0s
CI / release-firmware (rp2040) (push) Canceled after 0s
CI / release-firmware (rp2350) (push) Canceled after 0s
CI / release-firmware (stm32) (push) Canceled after 0s
CI / publish-firmware (push) Canceled after 0s
CI / build-debian-src (push) Canceled after 0s
CI / MacOS (15) (push) Canceled after 0s
CI / MacOS (26) (push) Canceled after 0s
Build MacOS Binary / build-MacOS (push) Canceled after 0s
CI / package-pio-deps-native-tft (push) Canceled after 0s
CI / test-native (push) Canceled after 0s
CI / docker (alpine, native, linux/amd64) (push) Canceled after 0s
CI / docker (alpine, native, linux/arm64) (push) Canceled after 0s
CI / docker (alpine, native-tft, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/arm/v7) (push) Canceled after 0s
CI / docker (debian, native, linux/arm64) (push) Canceled after 0s
CI / docker (debian, native-tft, linux/amd64) (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
2026-07-05 04:13:37 +08:00
kevin 472809b827 up
CI / setup (all) (push) Canceled after 0s
CI / setup (check) (push) Canceled after 0s
CI / version (push) Canceled after 0s
CI / check (${{ fromJson(needs.setup.outputs.check) }}) (push) Canceled after 0s
CI / build (${{ fromJson(needs.setup.outputs.all) }}) (push) Canceled after 0s
Build Debian Source Package / build-debian-src (push) Canceled after 0s
Package PlatformIO Library Dependencies / pkg-pio-libdeps (push) Canceled after 0s
Run Tests on Native platform / Generate Test Reports (push) Canceled after 0s
Run Tests on Native platform / Native PlatformIO Tests (push) Canceled after 0s
Run Tests on Native platform / Native Simulator Tests (push) Canceled after 0s
CI / gather-artifacts (esp32) (push) Canceled after 0s
CI / gather-artifacts (esp32c3) (push) Canceled after 0s
CI / gather-artifacts (esp32c6) (push) Canceled after 0s
CI / gather-artifacts (esp32s3) (push) Canceled after 0s
CI / gather-artifacts (nrf52840) (push) Canceled after 0s
CI / gather-artifacts (rp2040) (push) Canceled after 0s
CI / gather-artifacts (rp2350) (push) Canceled after 0s
CI / gather-artifacts (stm32) (push) Canceled after 0s
CI / firmware-size-report (push) Canceled after 0s
CI / release-artifacts (push) Canceled after 0s
CI / release-firmware (esp32) (push) Canceled after 0s
CI / release-firmware (esp32c3) (push) Canceled after 0s
CI / release-firmware (esp32c6) (push) Canceled after 0s
CI / release-firmware (esp32s3) (push) Canceled after 0s
CI / release-firmware (nrf52840) (push) Canceled after 0s
CI / release-firmware (rp2040) (push) Canceled after 0s
CI / release-firmware (rp2350) (push) Canceled after 0s
CI / release-firmware (stm32) (push) Canceled after 0s
CI / publish-firmware (push) Canceled after 0s
CI / build-debian-src (push) Canceled after 0s
CI / MacOS (15) (push) Canceled after 0s
CI / MacOS (26) (push) Canceled after 0s
Build MacOS Binary / build-MacOS (push) Canceled after 0s
CI / package-pio-deps-native-tft (push) Canceled after 0s
CI / test-native (push) Canceled after 0s
CI / docker (alpine, native, linux/amd64) (push) Canceled after 0s
CI / docker (alpine, native, linux/arm64) (push) Canceled after 0s
CI / docker (alpine, native-tft, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/arm/v7) (push) Canceled after 0s
CI / docker (debian, native, linux/arm64) (push) Canceled after 0s
CI / docker (debian, native-tft, linux/amd64) (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
2026-06-25 21:31:21 +08:00
kevin d7cbac0156 up
CI / setup (all) (push) Canceled after 0s
CI / setup (check) (push) Canceled after 0s
CI / version (push) Canceled after 0s
CI / check (${{ fromJson(needs.setup.outputs.check) }}) (push) Canceled after 0s
CI / build (${{ fromJson(needs.setup.outputs.all) }}) (push) Canceled after 0s
Build Debian Source Package / build-debian-src (push) Canceled after 0s
Package PlatformIO Library Dependencies / pkg-pio-libdeps (push) Canceled after 0s
Run Tests on Native platform / Generate Test Reports (push) Canceled after 0s
Run Tests on Native platform / Native PlatformIO Tests (push) Canceled after 0s
Run Tests on Native platform / Native Simulator Tests (push) Canceled after 0s
CI / gather-artifacts (esp32) (push) Canceled after 0s
CI / gather-artifacts (esp32c3) (push) Canceled after 0s
CI / gather-artifacts (esp32c6) (push) Canceled after 0s
CI / gather-artifacts (esp32s3) (push) Canceled after 0s
CI / gather-artifacts (nrf52840) (push) Canceled after 0s
CI / gather-artifacts (rp2040) (push) Canceled after 0s
CI / gather-artifacts (rp2350) (push) Canceled after 0s
CI / gather-artifacts (stm32) (push) Canceled after 0s
CI / firmware-size-report (push) Canceled after 0s
CI / release-artifacts (push) Canceled after 0s
CI / release-firmware (esp32) (push) Canceled after 0s
CI / release-firmware (esp32c3) (push) Canceled after 0s
CI / release-firmware (esp32c6) (push) Canceled after 0s
CI / release-firmware (esp32s3) (push) Canceled after 0s
CI / release-firmware (nrf52840) (push) Canceled after 0s
CI / release-firmware (rp2040) (push) Canceled after 0s
CI / release-firmware (rp2350) (push) Canceled after 0s
CI / release-firmware (stm32) (push) Canceled after 0s
CI / publish-firmware (push) Canceled after 0s
CI / build-debian-src (push) Canceled after 0s
CI / MacOS (15) (push) Canceled after 0s
CI / MacOS (26) (push) Canceled after 0s
Build MacOS Binary / build-MacOS (push) Canceled after 0s
CI / package-pio-deps-native-tft (push) Canceled after 0s
CI / test-native (push) Canceled after 0s
CI / docker (alpine, native, linux/amd64) (push) Canceled after 0s
CI / docker (alpine, native, linux/arm64) (push) Canceled after 0s
CI / docker (alpine, native-tft, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/arm/v7) (push) Canceled after 0s
CI / docker (debian, native, linux/arm64) (push) Canceled after 0s
CI / docker (debian, native-tft, linux/amd64) (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
2026-06-25 21:24:54 +08:00
kevin bc241481b1 添加中文支持
CI / setup (all) (push) Canceled after 0s
CI / setup (check) (push) Canceled after 0s
CI / version (push) Canceled after 0s
CI / check (${{ fromJson(needs.setup.outputs.check) }}) (push) Canceled after 0s
CI / build (${{ fromJson(needs.setup.outputs.all) }}) (push) Canceled after 0s
Build Debian Source Package / build-debian-src (push) Canceled after 0s
Package PlatformIO Library Dependencies / pkg-pio-libdeps (push) Canceled after 0s
Run Tests on Native platform / Generate Test Reports (push) Canceled after 0s
Run Tests on Native platform / Native PlatformIO Tests (push) Canceled after 0s
Run Tests on Native platform / Native Simulator Tests (push) Canceled after 0s
CI / gather-artifacts (esp32) (push) Canceled after 0s
CI / gather-artifacts (esp32c3) (push) Canceled after 0s
CI / gather-artifacts (esp32c6) (push) Canceled after 0s
CI / gather-artifacts (esp32s3) (push) Canceled after 0s
CI / gather-artifacts (nrf52840) (push) Canceled after 0s
CI / gather-artifacts (rp2040) (push) Canceled after 0s
CI / gather-artifacts (rp2350) (push) Canceled after 0s
CI / gather-artifacts (stm32) (push) Canceled after 0s
CI / firmware-size-report (push) Canceled after 0s
CI / release-artifacts (push) Canceled after 0s
CI / release-firmware (esp32) (push) Canceled after 0s
CI / release-firmware (esp32c3) (push) Canceled after 0s
CI / release-firmware (esp32c6) (push) Canceled after 0s
CI / release-firmware (esp32s3) (push) Canceled after 0s
CI / release-firmware (nrf52840) (push) Canceled after 0s
CI / release-firmware (rp2040) (push) Canceled after 0s
CI / release-firmware (rp2350) (push) Canceled after 0s
CI / release-firmware (stm32) (push) Canceled after 0s
CI / publish-firmware (push) Canceled after 0s
CI / build-debian-src (push) Canceled after 0s
CI / MacOS (15) (push) Canceled after 0s
CI / MacOS (26) (push) Canceled after 0s
Build MacOS Binary / build-MacOS (push) Canceled after 0s
CI / package-pio-deps-native-tft (push) Canceled after 0s
CI / test-native (push) Canceled after 0s
CI / docker (alpine, native, linux/amd64) (push) Canceled after 0s
CI / docker (alpine, native, linux/arm64) (push) Canceled after 0s
CI / docker (alpine, native-tft, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/arm/v7) (push) Canceled after 0s
CI / docker (debian, native, linux/arm64) (push) Canceled after 0s
CI / docker (debian, native-tft, linux/amd64) (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
2026-06-25 21:00:37 +08:00
kevin ae62affeec 修复可能的卡死问题
CI / setup (all) (push) Canceled after 0s
CI / setup (check) (push) Canceled after 0s
CI / version (push) Canceled after 0s
CI / check (${{ fromJson(needs.setup.outputs.check) }}) (push) Canceled after 0s
CI / build (${{ fromJson(needs.setup.outputs.all) }}) (push) Canceled after 0s
Run Tests on Native platform / Generate Test Reports (push) Canceled after 0s
CI / gather-artifacts (esp32) (push) Canceled after 0s
CI / gather-artifacts (esp32c3) (push) Canceled after 0s
CI / gather-artifacts (esp32c6) (push) Canceled after 0s
CI / gather-artifacts (esp32s3) (push) Canceled after 0s
CI / gather-artifacts (nrf52840) (push) Canceled after 0s
CI / gather-artifacts (rp2040) (push) Canceled after 0s
CI / gather-artifacts (rp2350) (push) Canceled after 0s
CI / gather-artifacts (stm32) (push) Canceled after 0s
CI / firmware-size-report (push) Canceled after 0s
CI / release-artifacts (push) Canceled after 0s
CI / release-firmware (esp32) (push) Canceled after 0s
CI / release-firmware (esp32c3) (push) Canceled after 0s
CI / release-firmware (esp32c6) (push) Canceled after 0s
CI / release-firmware (esp32s3) (push) Canceled after 0s
CI / release-firmware (nrf52840) (push) Canceled after 0s
CI / release-firmware (rp2040) (push) Canceled after 0s
CI / release-firmware (rp2350) (push) Canceled after 0s
CI / release-firmware (stm32) (push) Canceled after 0s
CI / publish-firmware (push) Canceled after 0s
Build Debian Source Package / build-debian-src (push) Canceled after 0s
Package PlatformIO Library Dependencies / pkg-pio-libdeps (push) Canceled after 0s
Run Tests on Native platform / Native PlatformIO Tests (push) Canceled after 0s
Run Tests on Native platform / Native Simulator Tests (push) Canceled after 0s
CI / build-debian-src (push) Canceled after 0s
CI / MacOS (15) (push) Canceled after 0s
CI / MacOS (26) (push) Canceled after 0s
Build MacOS Binary / build-MacOS (push) Canceled after 0s
CI / package-pio-deps-native-tft (push) Canceled after 0s
CI / test-native (push) Canceled after 0s
CI / docker (alpine, native, linux/amd64) (push) Canceled after 0s
CI / docker (alpine, native, linux/arm64) (push) Canceled after 0s
CI / docker (alpine, native-tft, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/arm/v7) (push) Canceled after 0s
CI / docker (debian, native, linux/arm64) (push) Canceled after 0s
CI / docker (debian, native-tft, linux/amd64) (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
2026-06-25 15:13:36 +08:00
kevin fa89db17bb 同时启动wifi和蓝牙
CI / setup (all) (push) Canceled after 0s
CI / setup (check) (push) Canceled after 0s
CI / version (push) Canceled after 0s
CI / check (${{ fromJson(needs.setup.outputs.check) }}) (push) Canceled after 0s
CI / build (${{ fromJson(needs.setup.outputs.all) }}) (push) Canceled after 0s
Run Tests on Native platform / Generate Test Reports (push) Canceled after 0s
Build Debian Source Package / build-debian-src (push) Canceled after 0s
Package PlatformIO Library Dependencies / pkg-pio-libdeps (push) Canceled after 0s
Run Tests on Native platform / Native PlatformIO Tests (push) Canceled after 0s
Run Tests on Native platform / Native Simulator Tests (push) Canceled after 0s
CI / gather-artifacts (esp32) (push) Canceled after 0s
CI / gather-artifacts (esp32c3) (push) Canceled after 0s
CI / gather-artifacts (esp32c6) (push) Canceled after 0s
CI / gather-artifacts (esp32s3) (push) Canceled after 0s
CI / gather-artifacts (nrf52840) (push) Canceled after 0s
CI / gather-artifacts (rp2040) (push) Canceled after 0s
CI / gather-artifacts (rp2350) (push) Canceled after 0s
CI / gather-artifacts (stm32) (push) Canceled after 0s
CI / firmware-size-report (push) Canceled after 0s
CI / release-artifacts (push) Canceled after 0s
CI / release-firmware (esp32) (push) Canceled after 0s
CI / release-firmware (esp32c3) (push) Canceled after 0s
CI / release-firmware (esp32c6) (push) Canceled after 0s
CI / release-firmware (esp32s3) (push) Canceled after 0s
CI / release-firmware (nrf52840) (push) Canceled after 0s
CI / release-firmware (rp2040) (push) Canceled after 0s
CI / release-firmware (rp2350) (push) Canceled after 0s
CI / release-firmware (stm32) (push) Canceled after 0s
CI / publish-firmware (push) Canceled after 0s
CI / build-debian-src (push) Canceled after 0s
CI / MacOS (15) (push) Canceled after 0s
CI / MacOS (26) (push) Canceled after 0s
Build MacOS Binary / build-MacOS (push) Canceled after 0s
CI / package-pio-deps-native-tft (push) Canceled after 0s
CI / test-native (push) Canceled after 0s
CI / docker (alpine, native, linux/amd64) (push) Canceled after 0s
CI / docker (alpine, native, linux/arm64) (push) Canceled after 0s
CI / docker (alpine, native-tft, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/amd64) (push) Canceled after 0s
CI / docker (debian, native, linux/arm/v7) (push) Canceled after 0s
CI / docker (debian, native, linux/arm64) (push) Canceled after 0s
CI / docker (debian, native-tft, linux/amd64) (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
2026-06-25 13:41:58 +08:00
kevin 8e6525c4aa 修改编译方式
Build Docker Multi-Arch Manifest / docker-manifest (push) Canceled after 0s
Package for OpenSUSE Build Service / package-obs (push) Canceled after 0s
Trigger COPR build / build-copr-hook (push) Canceled after 0s
Daily Packaging / docker-multiarch (push) Canceled after 0s
Build Docker Multi-Arch Manifest / docker-debian-amd64 (push) Canceled after 0s
Build Docker Multi-Arch Manifest / docker-debian-arm64 (push) Canceled after 0s
Build Docker Multi-Arch Manifest / docker-debian-armv7 (push) Canceled after 0s
Build Docker Multi-Arch Manifest / docker-debian-riscv64 (push) Canceled after 0s
Build Docker Multi-Arch Manifest / docker-alpine-amd64 (push) Canceled after 0s
Build Docker Multi-Arch Manifest / docker-alpine-arm64 (push) Canceled after 0s
Build Docker Multi-Arch Manifest / docker-alpine-armv7 (push) Canceled after 0s
Build Docker Multi-Arch Manifest / docker-alpine-riscv64 (push) Canceled after 0s
Daily Packaging / package-ppa (jammy) (push) Canceled after 0s
Daily Packaging / package-ppa (noble) (push) Canceled after 0s
Daily Packaging / package-ppa (questing) (push) Canceled after 0s
Daily Packaging / package-ppa (resolute) (push) Canceled after 0s
Package for Launchpad PPA / build-debian-src (push) Canceled after 0s
Package for Launchpad PPA / package-ppa (push) Canceled after 0s
Daily Packaging / package-obs (push) Canceled after 0s
Package for OpenSUSE Build Service / build-debian-src (push) Canceled after 0s
Daily Packaging / hook-copr (push) Canceled after 0s
Build Docker / docker-build (push) Canceled after 0s
Build Debian Source Package / build-debian-src (push) Canceled after 0s
2026-06-25 12:57:12 +08:00
Ben MeadorsandGitHub 7aa004c1eb Merge pull request #10780 from meshtastic/forward-port-t-impulse-plus
Forward-port: Add Lilygo T-Impulse-Plus (#10497) to develop
2026-06-24 20:11:33 -05:00
Ben MeadorsandGitHub 8e72b16693 Merge pull request #10779 from meshtastic/forward-port-master-fixes
Forward-port master-only fixes to develop
2026-06-24 20:10:58 -05:00
AustinandBen Meadors 3c68c2eed9 Debian: Correctly build without signing (for forks) (#10605)
(cherry picked from commit 266c143359)
2026-06-24 18:39:44 -05:00
AustinandBen Meadors a43cd75bd3 Add Lilygo T-Impulse-Plus (#10497)
* Add Lilygo T-Impulse-Plus

* Enable small screen layout 64x32

* trunk'd

* Haptic Feedback (short and long press)

* enable Charging Indicator

* enable nrfutil uploads

* trunk fmt

* Add Lilygo T-Impulse-Plus

* Enable small screen layout 64x32

* trunk'd

* Haptic Feedback (short and long press)

* enable Charging Indicator

* enable nrfutil uploads

* trunk fmt

* enable proper device model

* Dim the haptic duration a bit.

* Fix GPS pins and speed. Module enable is active low, speed is 38400

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* set correct geometry.

* Add custom_meshtastic_* metadata to t-impulse-plus platformio.ini

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
(cherry picked from commit cb867cc6c8)
2026-06-24 16:54:03 -05:00
Ben Meadors 41cbb45cdc Fix build on picomputer
(cherry picked from commit 22d08b4303)
2026-06-24 16:50:44 -05:00
ManuelandBen Meadors 434225eb01 tdeck touch driver fix (#10740)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
(cherry picked from commit 4044f4af6c)
2026-06-24 16:50:44 -05:00
LittleatonandBen Meadors b603ed0bbf Additional *RAK* missing values for 6421 / 13300 13302 modules + Zebra/Nebra Hat Configs (#10644)
* rebased to master

* Update bin/config.d/lora-ZebraHat_2W.yaml

Co-authored-by: Austin <vidplace7@gmail.com>

* Update bin/config.d/lora-ZebraHat_1W.yaml

Co-authored-by: Austin <vidplace7@gmail.com>

* Update bin/config.d/lora-NebraHat_2W.yaml

Co-authored-by: Austin <vidplace7@gmail.com>

* Update bin/config.d/lora-NebraHat_1W.yaml

Co-authored-by: Austin <vidplace7@gmail.com>

* Remove TX_GAIN_LORA configuration line

* Remove TX_GAIN_LORA configuration line

* Comment out TX_GAIN_LORA configuration

* Comment out TX_GAIN_LORA configuration

* Update lora-ok3506-RAK6421-13300-slot1.yaml

* Update lora-ok3506-RAK6421-13300-slot2.yaml

* Update lora-RAK6421-13300-slot1.yaml

* Update lora-RAK6421-13300-slot2.yaml

---------

Co-authored-by: Austin <vidplace7@gmail.com>
(cherry picked from commit a49729eb89)
2026-06-24 16:49:29 -05:00
AustinandBen Meadors 245b45fff3 meshtasticd: Add configs for B&Q Station G3 (#10673)
configs for raspberry pi and luckfox lyra zero

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
(cherry picked from commit bfc206a47b)
2026-06-24 16:49:29 -05:00
Ben Meadors 9e3a9c370d Enhance RTC handling with unit test support for system time fallback (#10642)
* Enhance RTC handling with unit test support for system time fallback

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit c7f17a80b2)
2026-06-24 16:49:29 -05:00
Ben MeadorsandGitHub b93239bb0a fix(phone-api): skip manifest scan for node-info-only requests; bound getFiles (#10778)
Forward-port of #10754 and #10757 from master (2.7) into develop, so the
develop->master 2.8 promotion (#10777) doesn't drop them.

#10754: PhoneAPI no longer walks the filesystem to build the file manifest on
node-info-only config requests (SPECIAL_NONCE_ONLY_NODES), which never consume
it. getFiles() is now bounded (default 64 entries, depth 3) via collectFiles(),
takes an optional wasLimited out-param, and reserves capacity with a bad_alloc/
length_error fallback. The manifest vector is freed via swap (releaseFilesManifest).

#10757: getFiles()/collectFiles() now guard against empty file names returned by
the Adafruit LittleFS nRF52 glue (issue 4395).

Ported by hand rather than cherry-picked: master had reflowed FSCommon.cpp to a
different brace style (every line conflicted), #10754 already subsumes #10757,
and develop carries a MESHTASTIC_EXCLUDE_FILES_MANIFEST path (nRF54L15) that
master lacks. The exclude path is preserved and now also short-circuits + frees
the manifest. Verified: native Docker suite 448/448, clang-format clean.
2026-06-24 12:38:46 -05:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>thebenternBen MeadorsClaude Opus 4.8
fcfe329091 Guard TCP API writes after Wi-Fi reconnects (#10505)
* Guard TCP API writes against dead sockets

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/ebeb38d5-7339-4eac-b310-4b6dd9d40758

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* fix: apply review fixes for server write checks and stream locking

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/215c659d-bc17-4b30-89f4-78e3f9cdb3c3

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* style: format TCP API write check files

* fix(api): keep TCP API open on write backpressure; drop broken writability gate

canWriteFrame() treated a full transmit buffer (availableForWrite() == 0) as a
dead socket and closed the connection, so every healthy client was dropped and
the native simulator integration test timed out waiting for config. A zero
availableForWrite() is normal backpressure, not a disconnect; only a dropped
link should refuse a write. Reduce canWriteFrame() to the reliable
!client.connected() check -- genuine write failures are still caught after the
fact by onFrameWriteFailed().

Remove the now-unused ClientWriteChecks.h writability helper and its unit test
(test_client_write_checks, which also failed to link), and restore
ServerAPI.cpp to the project clang-format style (fixes the Trunk check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:31:17 -05:00
Jonathan BennettandGitHub 04545c7ba2 Delete dead code src/platform/esp32/iram-quirk.c (#10762) 2026-06-22 16:31:49 -05:00
Thomas GöttgensandGitHub c4fdf374e1 fix: right-size warm tier for constrained platforms (#10746, #10705) (#10759)
* fix: right-size warm tier for constrained platforms and feed RP2040 watchdog during NodeDB save

* fix: size warm tier and traffic cache per-MCU RAM, lowering no-PSRAM ESP32 (classic/S2/C3) tiers

* docs: document nRF52/RP2040 #else fall-through in warm tier and TM cache cascades
2026-06-22 06:21:50 -05:00
3501f620a3 fix: restore fixed position into localPosition on boot (#10670)
* NodeDB: restore fixed position into localPosition at boot

Fixed-position nodes without a GPS stop broadcasting their position (and
publishing MQTT map reports) after a reboot. On boot localPosition is empty,
and NodeDB::hasValidPosition() for the local node only inspects localPosition,
not the persisted node position. PositionModule therefore never sends a
position, so the lazy localPosition backfill in getPositionPacket() never
runs and localPosition stays at (0,0) indefinitely.

Restore the configured fixed position into localPosition during NodeDB
construction so position broadcasts and map reports resume automatically
after a reboot.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-21 08:17:09 -05:00
c51c01607d Traffic Management module: dedup, rate limiting, role-aware policing (#10706)
Adds the Traffic Management module (TMM) plus the NodeDB/warm-store and
next-hop foundations it builds on:

- Unified per-node cache (flat array, 8-bit relative ticks) shared by all
  features; role-aware throttles for tracker / lost-and-found.
- Position deduplication: drop unchanged position rebroadcasts within a
  configurable interval; precision driven off the channel ceiling (clamped to
  the public-key max on well-known channels). Enabled by default at 11h.
- Per-node rate limiting and unknown-packet filtering (config-driven; a
  non-zero companion field enables each feature -- no bool toggles).
- NodeInfo direct response from cache with role-based hop clamps.
- Persistent next-hop overflow store: confirmed hops have no TTL, are seeded
  from NodeInfoLite at boot, and survive hot-store eviction.
- Three-tier sender-role resolution (hot NodeInfoLite -> warm store -> TMM
  cache). Role is cached write-time (seeded on first track, refreshed from
  NodeInfo), pins its cache entry like a next-hop hint, and is evicted last.
- Warm store caches device role + protected category across reboot/eviction.
- PositionModule stationary floor for tracker / lost-and-found.
- PSRAM gating for warm/satellite/TMM cache sizes; STM32WL excluded.

Protobufs: TrafficManagementConfig trimmed to the five uint32 fields actually
used; submodule repointed to protobufs develop.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:35:14 -05:00
d8d92b7b71 Fix/zerohop and hopscale mqtt (#10753)
* fix for prehopdrop on zerohop packets

* hopscaling: exclude MQTT-origin packets from the node histogram

The hop-scaling histogram gate only checked transport_mechanism == TRANSPORT_LORA,
which excludes packets received from the broker but NOT MQTT-origin packets that a
gateway rebroadcasts onto LoRa (those arrive as TRANSPORT_LORA with via_mqtt set).
Counting them inflates the local mesh-size/density estimate with nodes that aren't
real RF participants — and since the bridged copy usually carries hop_start==0 they
land in the hop-0 bucket, the one that pulls getLastRequiredHop() lowest, over-
shrinking NodeInfo/position/telemetry hop limits. Add `!mp.via_mqtt` to the gate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:08:29 -05:00
Ben Meadors 161cd26519 Add conditional compilation for warm node count checks 2026-06-19 20:49:48 -05:00
Ben MeadorsandGitHub dd1ec9d462 Lora region preset map (#10736)
* Added lora region and preset maps

* Protos

* Address PR review feedback

- Log (and break/skip) when the region preset map exceeds its array bounds
  instead of silently dropping regions
- Derive test bounds from the generated nanopb array sizes via sizeof()
  instead of hard-coded magic numbers
- Fix want_config sequence comment (missing comma, STATE_SEND_MODULECONFIG)
- Specify a language on the spec's fenced code blocks (markdownlint MD040)

* Fix want_config stall: handle STATE_SEND_REGION_PRESETS in PhoneAPI::available()

available() had a separate per-state switch that wasn't updated for the new
state, so it returned false ('unexpected state 5') and getFromRadio() was
never called - the config handshake stalled after metadata and the client
timed out. Verified via the native simulator integration test.
2026-06-19 19:56:24 -05:00
22072c5f4b Pr1.5 tmm nexthop (#10745)
* TrafficManagement: flat unified cache + persistent next-hop overflow store

Reworks the TrafficManagementModule cache layer (policing behaviour unchanged
from upstream) and adds a routing-hint overflow store:

- Flatten the ring: replace the cuckoo-hashed unified cache and the bucketed
  PSRAM NodeInfo index with plain flat arrays + linear scan (same idiom as
  WarmNodeStore). At LoRa packet rates an O(n) scan of the cache is negligible,
  and it removes a large amount of hashing/displacement complexity. The cache
  entry is 11 B; timestamps use a uniform +1 presence-offset so a 0 byte always
  means "empty" across every sub-store. Adds rebaseEpoch() so cached state
  survives the ~19 h relative-timestamp horizon instead of being flushed.

- Next-hop overflow cache: setNextHop/getNextHopHint store a confirmed last-byte
  relay for a destination, written only from NextHopRouter's ACK-confirmed
  decision (and mirrored from TraceRoute). NextHopRouter::getNextHop falls back
  to this cache when the hot NodeDB has no hint, so DMs/relays to long-tail
  nodes keep routing after the node ages out of NodeInfoLite.

- Persistence: preloadNextHopsFromNodeDB warm-starts the cache from persisted
  NodeInfoLite hints on first maintenance pass; next_hop entries are kept alive
  across the maintenance sweep (no TTL) and never clobbered by a stale preload.

All packet-policing logic (rate limit, position dedup, unknown-packet drop,
NodeInfo direct response, hop exhaustion) is the existing upstream behaviour,
untouched. HAS_TRAFFIC_MANAGEMENT defaults on so the module is compiled in. (see note).

Tests: upstream policing suite now actually runs (adds the MeshTypes.h include
that gates HAS_TRAFFIC_MANAGEMENT) plus 4 next-hop tests. Role-aware throttles,
politeness, precision clamp, port-interval and mesh-radius gating — and the
rate-limit >255 saturation fix — are deferred to the advanced-TMM branch.

Note: default dedup movement grid moves to ~91m, which also means 1.5km required to end up with the same signature position - coarser and therefore further than before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* TrafficManagement: fix cppcheck constVariablePointer warning

`node` in preloadNextHopsFromNodeDB() is never written through — mark
it const to satisfy cppcheck's constVariablePointer check in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add multi-hop NextHop recovery tests and unit tests for routing reliability

- Introduced a new test suite for multi-hop NextHop directed-message delivery and relay recovery in `test_nexthop_multihop_recovery.py`. This includes tests for end-to-end delivery and recovery after relay drop.
- Implemented unit tests in `test_main.cpp` for NextHop routing reliability mitigations, covering:
  - M1: Ambiguity-aware last-byte resolution.
  - M2: NextHopRouter's strict-neighbor gate and hop limit checks.
  - M3: Route-health freshness and failure decay.
- Enhanced mock classes to facilitate controlled testing of node behaviors and routing logic.

* grafting fixed

* Address Copilot review for PR #10735 (NextHop improvements)

- docs/nexthop-routing-reliability.md: update status from "no code
  changes yet" to reflect that mitigations and tests are implemented

RAM pressure and MIGRATION_VERBOSE concerns addressed upstream in
PR2.5 (per-platform TRAFFIC_MANAGEMENT_CACHE_SIZE) and PR2 (verbose
default=0) respectively; (0,0) sentinel fixed in PR2.5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* CI: fix cppcheck constVariablePointer and test include path

- NextHopRouter.cpp: qualify two RouteHealth *h locals as const — only
  read for stale-route checks, never mutated through the pointer
- Router.cpp: qualify meshtastic_NodeInfoLite *node as const in
  shouldDecrementHopLimit — only read for favorite/role predicate
- test_position_module/test_main.cpp: change bare PositionModule.h to
  modules/PositionModule.h — build_flags sets -Isrc, not -Isrc/modules,
  so the bare form fails to resolve in the native PlatformIO test env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* WarmStore: cache device role + protected category in last_heard low bits

Steal the low 6 bits of WarmNodeEntry.last_heard to carry an evicted node's
device role (4 bits) and a protected category (2 bits) for the hop-trim path,
at zero record-size cost (entry stays 40 B; no RAM/flash growth). The high bits
remain a real unix-seconds timestamp, quantised to 64 s — ample for warm LRU
ordering of long-tail nodes.

- absorb() packs role/protectedCat; place()/ring replay store the raw word so
  metadata round-trips through flash. LRU compares masked time (warmTimeOf).
- take() rehydration masks the metadata bits and restores the cached role so a
  re-admitted node isn't stuck at CLIENT until its next NodeInfo.
- NodeDB classifies the category (favorite/ignored/verified -> Flag;
  tracker/sensor/tak_tracker -> Role) at each eviction site.
- WarmNodeStore::lookupMeta() exposes role/category to consumers.
- Bump WARM_RING_MAGIC (WRNG->WRN2): old rings read as erased and rebuild;
  warm data is a non-critical evictee cache, so discard-on-upgrade is safe.

Tests: test_warm_store 11/11 (new meta round-trip + quantisation-aware ordering);
NodeDB compiles (test_nodedb_blocked 4/4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: migrate v1 rings/files by discarding last_heard, not the data

Previously the WRNG->WRN2 magic bump treated old rings as erased, discarding all
warm entries — including the PKI public keys that let evicted nodes keep
decrypting DMs. Instead, read v1 (WRNG / WRM1) records and keep each node's
identity + public key, discarding only last_heard (its low bits would otherwise
be misread as the new role/protected metadata). Records re-rank and re-learn
their role on next contact.

- Ring backend (nRF52840): ringReadHeader accepts both magics and reports v1 via
  an out-param; replay zeroes last_heard for v1 records. If the active head page
  is v1, force a rotation so new v2 records never land in a v1-headered page
  (which would discard their freshly-set role on the next load). Legacy pages
  convert to v2 as the ring rotates.
- File backend (warm.dat): bump WARM_STORE_MAGIC WRM1->WRM2; accept WRM1, verify
  CRC against the stored bytes, then discard last_heard and mark dirty so the
  next save rewrites as v2.

Tests: test_warm_store 12/12 (adds test_ws_v1_migration_discardsLastHeard:
key survives, role/protected reset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* WarmStore: guard role bit-width + test eviction carries role/protected

- static_assert that the device role enum still fits the 4-bit warm metadata
  field (WARM_ROLE_MASK); fails the build loudly if a new role is added past 15
  rather than silently truncating role on eviction. (Max role today = 12.)
- Add test_migration_carriesRoleAndProtectedIntoWarm: a demoted TRACKER lands in
  the warm tier with its key, role=TRACKER and protected category=Role; a demoted
  CLIENT carries role=CLIENT/None. Exercises the NodeDB eviction path +
  warmProtectedCategory classification (the warm-store unit tests only cover
  absorb() directly).

Tests: test_nodedb_blocked 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix copilot comments

* fix(test): restore #if HAS_TRAFFIC_MANAGEMENT guard in TMM test

The rebase onto PR1.5 lost the top-level HAS_TRAFFIC_MANAGEMENT guard
that PR1.5 introduced, leaving the #else/#endif tail orphaned and
causing compile errors on non-TMM builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-19 19:52:58 -05:00
Jonathan BennettandGitHub ca7d82629d Native sensors (#10748)
* Enable Sensirion libraries on native

* Bump platform-native to get bug fix
2026-06-19 15:50:31 -05:00
f2f23f8978 NimBLE params overhaul and try-fix for incompatible bond cleanup (#10741)
* NimBLE params overhaul and try-fix for incompatible bond cleanup

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address PR review: remove dead clearNVS(), defer bond-purge log below init

- Delete unused clearNVS() (no callers; should have been removed in #10264).

- Move purgeIncompatibleBleBonds() after the "Init the NimBLE" log so bond-cleanup output doesn't appear to precede module init; it still runs before BLEDevice::init() reads the store.

* Update MAX_SATELLITE_NODES and WARM_NODE_COUNT definitions for ESP32-S3 PSRAM support

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-19 12:21:05 -05:00
9358b2c549 fix(esp32): skip RTC timer wake on user shutdown; TAP V2 OCV and partition (#10739)
* fix(esp32): skip RTC timer wake on user shutdown

Do not arm esp_sleep_enable_timer_wakeup when msecToWake is portMAX_DELAY (UI shutdown), matching nRF52 system_off semantics.

fix(rak_wismesh_tap_v2): Tag OCV curve and 16MB partition

Add OCV_ARRAY matching WisMesh Tag for accurate SOC. Use 16MB flash partition scheme for TAP V2 hardware.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trunkt

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-18 16:29:54 -05:00
4f0e2dde98 feat: add Ethernet OTA support for RP2350/W5500 boards (#10136)
* feat: add Raspberry Pi Pico 2 + W5500 + E22-900M30S variant

Adds community variant for Raspberry Pi Pico 2 (RP2350, 4 MB flash)
with external WIZnet W5500 Ethernet module and EBYTE E22-900M30S LoRa
module (SX1262, 30 dBm PA, 868/915 MHz).

Key details:
- LoRa on SPI1: GP10/11/12/13 (SCK/MOSI/MISO/CS), RST=GP15,
  DIO1=GP14, BUSY=GP2, RXEN=GP3 (held HIGH via SX126X_ANT_SW)
- W5500 on SPI0: GP16/17/18/19/20 (MISO/CS/SCK/MOSI/RST)
- SX126X_DIO2_AS_RF_SWITCH: DIO2→TXEN bridge on module handles PA
- SX126X_DIO3_TCXO_VOLTAGE 1.8: TCXO support via EBYTE_E22 flags
- DHCP timeout reduced to 10 s to avoid blocking LoRa startup
- GPS on UART1/Serial2: GP8 TX, GP9 RX
- Reuses WIZNET_5500_EVB_PICO2 code paths for Ethernet init

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* pico2_w5500_e22: rename define and address review feedback

Rename WIZNET_5500_EVB_PICO2 to PICO2_W5500_E22 so the variant-specific
define matches the variant directory name and isn't confused with an
on-board EVB SKU.

Review fixes from PR #10135:
- Gate the 10 s Ethernet DHCP timeout behind PICO2_W5500_E22 so other
  Ethernet builds keep the default 60 s behavior; apply the same timeout
  to reconnectETH() for consistency.
- Drop the unused -D EBYTE_E22 flag; EBYTE_E22_900M30S already selects
  TX_GAIN_LORA / SX126X_MAX_POWER in src/configuration.h.
- Rewrite "on-board W5500" comments to describe the external module.
- Correct README TX_GAIN_LORA value (7, not 10) and drop the EBYTE_E22
  row.

* fix(pico2_w5500_e22): drop DEBUG_RP2040_PORT=Serial

The arduino-pico framework hooks _write() when DEBUG_RP2040_PORT=Serial
is set and dumps raw debug bytes onto USB CDC, corrupting any binary
protobuf stream sent through StreamAPI (e.g. `meshtastic --port COMx`).

The variant excludes BT and WiFi, so the primary client transport is
Ethernet TCP via ethServerAPI — unaffected — but users who configure
the node over USB serial would see protobuf decode failures from
debug-byte interleaving. Removing the flag restores clean USB CDC.

Debug output can still be enabled per-build by adding -D DEBUG_RP2040_PORT=Serial1
to redirect to UART0 instead of USB CDC.

* feat: add Ethernet OTA support for RP2350/W5500 boards

Adds over-the-air firmware update capability for RP2350-based boards
with a WIZnet W5500 Ethernet module (e.g. pico2_w5500_e22).

Protocol (MOTA):
- SHA256 challenge-response authentication with a configurable PSK
  (override via USERPREFS_OTA_PSK; default key ships in source)
- 12-byte header: magic "MOTA" + firmware size + CRC32
- Firmware received in 1 KB chunks, verified with CRC32, written via
  Updater (picoOTA), then device reboots to apply
- Constant-time hash comparison prevents timing attacks on auth
- 30s inactivity timeout + 5s cooldown after failed auth
- Response codes 0x00-0x08 map 1:1 to OTAResponse enum

Firmware side:
- ethOTA.cpp / ethOTA.h: OTA TCP server on port 4243
- ethClient.cpp: wire initEthOTA/ethOTALoop into reconnect loop
- main-rp2xx0.cpp: hardware watchdog (8s, paused during debug)
- pico2_w5500_e22/platformio.ini: HAS_ETHERNET_OTA flag,
  filesystem_size bumped to 0.75m for OTA staging

Host side:
- bin/eth-ota-upload.py: Python uploader with progress and full
  result-code mapping (matches OTAResponse 0x00-0x08)

* style(eth): clang-format ethOTA.cpp per repo .clang-format

Reformat to the repo trunk clang-format config (IndentWidth 4,
ColumnLimit 130). Resolves the Trunk Check 'Incorrect formatting'
failure on PR #10136.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 16:28:10 +02:00
Quency-DandGitHub 68af6b277c Add Heltec tower v2 board. (#10693)
* Add heltec_mesh_tower_v2 board.

* Added automatic detection for high and low power versions.

* Fix low power consumption issues
2026-06-18 07:07:50 -05:00
TomGitHubClaude Opus 4.8github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Ben Meadors
79a7dcc46c Pr1 nodedb warmstore (#10705)
* NodeDB: 3-tier node store with persistent warm tier (long-tail identity retention)

Introduces a tiered NodeDB so the device retains identity (public key,
last_heard) for far more nodes than fit in the full-record hot store,
without growing heap or the persisted nodes.proto unboundedly.

- Hot store: full NodeInfoLite, MAX_NUM_NODES (120 on nRF52).
- Satellite maps: position/telemetry/environment/status capped at
  MAX_SATELLITE_NODES (40 freshest); eviction via enforceSatelliteCaps /
  evictSatelliteOverCap.
- Warm tier (WarmNodeStore): 40 B {num,last_heard,public_key} records for
  evicted nodes so DMs to/from long-tail nodes keep encrypting/decrypting.
  Persisted to /prefs/warm.dat, or on nRF52840 a dedicated 12 KB raw-flash
  record-ring below LittleFS (3x4 KB pages; see linker scripts + the
  nrf52_warm_region.py post-link guard).

NodeDB::getOrCreateMeshNode now demotes evicted nodes into the warm tier and
re-admits them (restoring key/last_heard). Router PKI decrypt/encode resolve
the peer key via NodeDB::copyPublicKey (hot store, then warm tier).

NodeInfoLite gains snr_q4 (sint32, Q4-encoded dB); the float snr is zeroed on
disk. NodeInfoLite grows 105 -> 112 B; backup 2432 -> 2468 B.

Note: the snr_q4 .proto change still needs to land in the protobufs submodule
(generated header is updated here; submodule pointer left at upstream).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* NodeDB: robust receive + retention for blocked (ignored) nodes

Hardens how ignored/favourite nodes are received over admin and retained,
closing paths where a block could be lost or accidentally cleared.

- Blocking keeps the node's public key (admin set_ignored_node and
  addFromContact no longer zero it / drop the warm-tier key), so a blocked
  peer stays a verifiable identity.
- set_ignored_node creates the node if absent, so a block by node ID sticks
  even for a node we've never heard from (e.g. pushed by a remote admin) with
  no NodeInfo or key.
- Eviction protection (favourite/ignored/manually-verified) now also applies to
  the load-time hot-store migration and is never undone by cleanupMeshDB, which
  previously purged ignored nodes that lacked user info.
- The hot-store migration leaves our own node (index 0) in place and prefers to
  demote non-protected nodes, like the runtime eviction scan.

Caps the protected set (favourite + ignored + verified) at MAX_NUM_NODES-2 via
NodeDB::setProtectedFlag(), so at least two evictable slots always remain and
getOrCreateMeshNode can always make room — replacing the previous unconditional
append that could run off the end of the node vector when every node was
protected. A locally-set favourite/ignore that hits the cap reports back to the
phone via a ClientNotification.

Adds test_nodedb_blocked covering the migration, favourite/ignored eviction
protection, ignored-survives-cleanup, and the protected-node cap. The
maintenance methods stay private in production; the test reaches them through a
PIO_UNIT_TESTING-guarded friend shim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	src/mesh/NodeDB.h

* fix copilot comments

* once again

* WarmNodeStore: fix cppcheck warnings (uninitvar, constVariablePointer)

Zero-initialise `stranded[]` and `seqs[]/order[]` VLAs so cppcheck can
verify there are no unguarded reads of uninitialised memory (the guards
exist but are not visible to static analysis). Mark two local pointers
`const` where the pointed-to entry is never mutated after assignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* self-care added to assist 2.7 and 2.8 nodedb migration

* Tidy warm-store/self-care: comments, guards, log + flash cleanup

Style/cleanup pass over the branch (no behavior change except the noted
preprocessor simplifications, which are semantically identical):

- Comments: move function descriptions to the headers, cap in-function
  comments at ~3-4 lines, drop leading-number step markers, label stacked
  #endif blocks, de-decorate banner comments.
- dumpToLog: fully gate decl + definition + AdminModule call site behind
  MESHTASTIC_NODEDB_MIGRATION_VERBOSE so it compiles out when disabled
  (~1.2 KB when off).
- mesh-pb-constants: drop the dead nRF52832 WARM_NODE_COUNT branch and trim
  the macro docs.
- WarmNodeStore: simplify the redundant `ARCH_NRF52 && NRF52840_XXAA` guards
  to `NRF52840_XXAA`, add a kNoPage sentinel for the ring page state.
- Shorten the always-on LOG_WARN strings (~120 B flash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* more tidying up, aligning with docs and undoing other-arch regressions

* Update protobufs (#19)

Co-authored-by: NomDeTom <116762865+NomDeTom@users.noreply.github.com>

* made the migration pathway cleareer

* address copilot review

* fixed a copilot review on a downstream PR.

* Address Copilot review comments for PR #10705 (warmstore/nodedb)

- WarmNodeStore.h: default MIGRATION_VERBOSE to 0 (suppress info-level
  chatter on production builds; opt in with =1)
- WarmNodeStore.cpp load(): move memset to top of function so all
  failure paths (header-read fail, invalid header) leave entries clear
- WarmNodeStore.cpp save(): replace manual spiLock lock/unlock around
  mkdir with LockGuard covering the full SafeFile sequence, matching
  the lock discipline in load()
- Router.cpp: memcpy(&p->public_key.bytes, ...) -> memcpy(p->public_key.bytes,
  ...) — pass decayed uint8_t* rather than pointer-to-array
- AdminModule.cpp: check setProtectedFlag return for PKC auto-favorite;
  log cap-refusal warning instead of unconditional "auto-favoriting"
- nrf52_warm_region.py: error message references both v6.ld and v7.ld

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* NodeDB: formatting cleanup (blank lines after preprocessor blocks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Lukewarm store

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-18 08:41:34 +01:00
Jonathan Bennett b311e01ce0 Update OCV_ARRAY values in Thinknode m5 variant.h 2026-06-17 22:16:57 -05:00
Jonathan Bennett ae3493a00c Set the time from GPS every 30 minutes (#10737) 2026-06-17 18:50:38 -05:00
1cf6d7648f Leave src/platform out of the build filter by default (#10726)
* Leave src/platform out of the build filter by default, and only add back what each build needs.

* typo fix

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-17 18:14:01 -05:00
5ffd30c4a8 Fix PKC on portduino sim by working around region blocks keygen and packet length (#10730)
* Fix PKC on portduino sim by working around region blocks keygen and packet length

* Reserve Compressed framing overhead in sim loopback capacity check

The sim loopback re-encodes the Compressed wrapper back into
decoded.payload.bytes (the same 233-byte field its data is copied from),
so the carried bytes must leave room for protobuf framing. Checking
against sizeof(c.data.bytes) (233) let near-max payloads overflow
pb_encode_to_bytes(), which returns 0 and silently sends an empty
loopback payload. Cap at sizeof(decoded.payload.bytes) minus the
worst-case Compressed framing (meshtastic_Compressed_size - data size)
for both the ciphertext and plaintext paths.

Addresses Copilot review feedback on #10730.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Run trunk fmt

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-17 13:34:29 -05:00
HarukiToredaandGitHub 358e4e2fcd InkHUD: Wipe all messages option (#10721)
* Wipe messages

* trunk
2026-06-17 13:34:05 -05:00
Jonathan Bennett 7424631a27 Beta fixes (#10728)
* Wipe message Store on factory reset

* Check for destination 0 in a new message, and convert to broadcast

* Make sure CHARGE_LED_state gets turned off, to avoid stuck LEDs

* Take the spiLock in MessageStore when clearing messages

* Trunk

* Add thinknode M5 voltage curve

* Fix the oops
2026-06-17 13:29:20 -05:00
Ben Meadors 2291b672c4 Protos 2026-06-17 06:14:40 -05:00
Ben Meadors a944e1c908 Update security documentation with detailed cryptographic mechanisms and known limitations 2026-06-17 05:43:24 -05:00
e4f4d1f9e7 Ci test report filter (#10722)
* ci(test report): drop no-status testsuites from the PlatformIO report

PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_*
dir x every hardware variant it can't run on native (~4900 rows). They carry
no pass/fail/skip status and bury the suites that actually ran in the dorny
Test Report. Strip them (in generate-reports, before the reporter) so the
report shows only real results. The uploaded artifact keeps the full XML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add bin/run-tests.sh: standardised local test verdict

Self-contained RED/AMBER/GREEN runner: matches all pass/fail spellings and
cross-checks the suite count against test/test_*/ so a missing suite shows AMBER.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* run-tests.sh: detect sanitizer faults + live build/test progress

Two usability improvements to the local verdict script:

1. Sanitizer-fault detection. A sanitizer-instrumented (coverage) build aborts
   non-zero at EXIT on an ASan/LSan/UBSan/TSan fault — most often a LeakSanitizer
   leak — AFTER every test has printed [PASSED], so pio reports it as
   [ERRORED]/SIGHUP with no :FAIL: anywhere and it masquerades as a phantom
   failure. verdict_red now recognises the documented fault signatures (ERROR:/
   WARNING: <San>:, SUMMARY: <San>:, Direct/Indirect leak of, heap-use-after-free,
   runtime error:, etc. — not the benign "failed to intercept" startup noise) and
   reports e.g. "RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: ...",
   plus the "run the binary bare (gdb hides it via ptrace)" recipe. Also flags the
   "all tests passed but aborted at exit" shape when the report was swallowed.

2. Progress trail. Long rebuilds were silent for minutes. A background heartbeat
   now appends a status line every few seconds to .pio/build/<env>/.runtests-progress
   (always — tail -f it to check a backgrounded/piped run) and live-updates the tty
   for interactive --quiet runs: build = objects recompiled / cached total + ETA;
   test = suites done / expected. The object-count baseline is cached per env in the
   gitignored build dir. Writes never touch the parsed verdict log; tty writes are
   guarded so a no-tty run emits no redirect-open error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:53:17 -05:00
43d485dd76 Add IIS2MDCTR and ISM330DHCX to ScanI2C (#10723)
* add ScanI2C for IIS2MDCTR and ISM330DHCX

* Trunk format

* Minor cleanup

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 13:39:57 -05:00
Ben MeadorsGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Copilot Autofix powered by AI
d878c81ce8 Clamp position precision on public / known-keys (#10665)
* Clamp position precision on public / known-keys (Compliance)

* Fix review comments: bounds check, all-channel precision clamp, disabled channel guard

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Refactor position precision comments for clarity and update channel configuration handling in AdminModule

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-14 18:53:18 -05:00
oscgonferandGitHub 8a0c7592cc Remove duplicate code from AQ telemetry, probably from merge conflict (#10708) 2026-06-14 06:38:49 -05:00
Benjamin FaershteinandGitHub 882ca0a216 Improve GPS stale probe recovery (#10714)
* Improve GPS stale probe recovery

* Address GPS review feedback
2026-06-14 06:30:20 -05:00
Benjamin FaershteinandGitHub 5d1c4f15b7 Make nRF52 lockdown support opt-in (#10712)
* Make nRF52 lockdown support opt-in

* Scope lockdown opt-in normalization to nRF52
2026-06-13 21:14:47 -05:00
745b53698a Mesh node t1 fixes (#10602)
* Fixes

* Remove BATTERY_LPCOMP_THRESHOLD

BATTERY_LPCOMP_THRESHOLD is dead code — in main-nrf52.cpp it's inside #ifdef BATTERY_LPCOMP_INPUT, which this board intentionally doesn't define. The threshold value is never reached.

* Trunk fix

* Update MotionSensor.cpp

* fix

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-06-13 19:57:28 -05:00
b938b63e8a fix a long-running CI bug that overran a lot (#10707)
* fix the fix

* Address Copilot review: add EXIT trap and clarify PKC comment

Add `trap` to kill meshtasticd on any early exit (python harness
failure, socket timeout) so CI never leaks a background process.

Reword the ARCH_PORTDUINO comment to make explicit that pki_encrypted=true
causes the from==0 plain-admin branch to be skipped, routing into the
PKC key-check — the underlying logic was correct all along.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update PORTDUINO comment to reflect from==0 auth fix

The from==0 branch no longer requires !pki_encrypted (fixed upstream
in this branch), so update the simulator comment to reflect the actual
remaining reason for the early intercept: is_managed could still block
exit_simulator even for local packets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:46:29 -05:00
TomandGitHub b76e5e6ba4 removes NRF52832 from codebase - it is vestigal at best (#10709) 2026-06-13 18:46:08 -05:00
bbcc35e209 Stm32 general (#10700)
* Attempt to generalize ARCH_STM32

* Trunk

* One More ARCH_STM32

* Whoops, one snuck in there

* Fix comment to reflect define change

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-13 12:21:56 -05:00
Jonathan BennettandBen Meadors bede05356d Lora led rx (#10674)
* add optional LED_LORA to indicate LoRa TX

* Briefly flash LED_LORA on packet RX

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-13 12:18:22 -05:00
Jonathan BennettandBen Meadors 031e73bbe6 Use standard GPS enable pin, for smarter power control on M3 (#10671)
* Use standard GPS enable pin, for smarter power control on M3

* Enable GPS pin in variant.cpp initialization

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-13 12:05:24 -05:00
Ben Meadors 9ea1f0065a Update protos 2026-06-13 07:30:56 -05:00
Jonathan BennettGitHubBen MeadorsCopilotWesselcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>thebentern
8267bb22bd Packet Signing via XEdDSA (#10478)
* Test commit for XEdDSA support

* Update to Crypto lib in Meshtatic org

* Generate a new node identity on key generation (#7628)

* Generate a new node identity on key generation

* Fixes

* Fixes

* Fixes

* Messed up

* Fixes

* Update src/modules/AdminModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/mesh/NodeDB.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Figured it out!

* Cleanup

* Update src/mesh/NodeDB.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/mesh/NodeDB.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/AdminModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update crypto commit hash

* Some fixes for xeddsa pr (#9610)

* fix: add null check for getMeshNode() in NodeInfoModule

getMeshNode() can return nullptr for unknown nodes. Dereferencing
without a check crashes the firmware when receiving NodeInfo from
a node not yet in the database.

* fix: enforce XEdDSA signature verification and prevent stripping

Previously, failed signature verification still allowed the packet
through, making signatures purely cosmetic. Now:

- Failed verification drops the packet (DECODE_FAILURE)
- Successfully verified nodes get HAS_XEDDSA_SIGNED bitfield set
- Unsigned packets from previously-signing nodes are rejected
- Log levels reduced from WARN/ERROR to DEBUG/WARN as appropriate

* fix: include packet metadata in XEdDSA signature

The signature now covers [fromNode | packetId | portnum | payload]
instead of just the payload bytes. This prevents:
- Replay attacks (different packetId fails verification)
- Reattribution (different fromNode fails verification)
- Portnum redirection (different portnum fails verification)

Also adds a key initialization check to xeddsa_sign (returns false
if XEdDSA keys are all zeros) and checks the return value in the
encode path.

* fix: handle existing key pair in AdminModule security config

When a user provides both a valid private key and public key via
admin config, the crypto engine's DH private key and owner public
key were never loaded. DMs and XEdDSA signing would silently break.

Add an else branch to load both keys into the crypto engine.

* perf: cache Ed25519 public key conversion in xeddsa_verify

curve_to_ed_pub() performs field element parsing, inversion, and
multiplication on every call. Since packets from the same node
tend to arrive in bursts, a single-entry cache avoids repeating
this expensive conversion for consecutive packets from one sender.

* fix: skip identity cleanup when node number is unchanged

createNewIdentity() was called on every generateCryptoKeyPair(),
including normal boots where the same key is regenerated. This
caused unnecessary NodeDB writes and old-node cleanup logic to
run when the node number hadn't actually changed.

Also fixes only zeroing byte[0] of the old node's public key
instead of clearing the entire array.

* fix: replace hardcoded 120 with derived XEDDSA_SIGNATURE_SIZE constant

The payload size check for XEdDSA signing used a magic number (120).
Replace with a derivation from DATA_PAYLOAD_LEN and XEDDSA_SIGNATURE_SIZE
so the limit adjusts automatically if constants change. This also
increases the max signable payload from 120 to 169 bytes, which is
still safe since the actual encoded size is checked after pb_encode.

* fix: add const qualifiers to XEdDSA verify and curve_to_ed_pub inputs

pubKey, payload, and signature parameters in xeddsa_verify are
input-only and should not be modified. Same for curve_pubkey in
curve_to_ed_pub.

* chore: remove commented-out old Crypto dependency in portduino.ini

* Leave out the admin module change for now

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>

* trunk

* protobuf re-update

* Protobufs

* Merge resolution fix

* Put XEDDSA on the right bit

* NodeDB update to new nodeInfoLite accessors, etc

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Refine unsigned packet rejection logic in Router (#10534)

* use hardware random to fill the first 32 signature bytes with entropy prior to signing.

* Add XEdDSA packet-signing policy tests and update dependencies for macos

* Minor fixes

* integrate XEdDSA support and update dependencies across multiple modules

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Wessel <github@weebl.me>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-06-13 06:45:56 -05:00
07a87a8254 security: runtime-toggleable MESHTASTIC_LOCKDOWN hardening for nRF52 (#10349)
* security: add MESHTASTIC_LOCKDOWN hardened build option

Meshtastic nodes ship with secrets on flash (channel PSKs, the device
private key, admin keys, wifi PSK) and over-the-wire access to admin
APIs that can re-key the mesh. Lose the device, at a border crossing,
in a raid, off a backpack, and an attacker reads everything in 30s
with a USB cable. There's no at-rest encryption, no client auth, the
screen leaks contents, and SWD is wide open. This adds an opt-in
hardened build for users who care.

-DMESHTASTIC_LOCKDOWN=1 on nRF52 (CC310) turns on:

  DEBUG_MUTE                         silence USB/serial logs
  MESHTASTIC_ENCRYPTED_STORAGE       AES-128-CTR + HMAC-SHA256 on
                                     LocalConfig / channels / NodeDB.
                                     Passphrase-gated DEK, TTL/boot
                                     unlock token, failed-attempt
                                     backoff (within-boot, wall-clock,
                                     persisted bootsSinceFail).
  MESHTASTIC_PHONEAPI_ACCESS_CONTROL per-connection auth gate. Secrets
                                     emitted as empty proto structs
                                     to unauthenticated clients.
  MESHTASTIC_ENABLE_APPROTECT        one-way UICR APPROTECT, reset
                                     applied same boot. Recoverable
                                     only via \`nrfjprog --recover\`,
                                     which also wipes the DEK.
  LockdownDisplay                    screen shows "LOCKED" when locked
                                     or idle 30s. OLED only; InkHUD /
                                     niche / device-ui not yet wired.

Wire format is the LockdownAuth / LockdownStatus pair from
meshtastic/protobufs#911 (AdminMessage tag 104, FromRadio tag 18).

Access-control state is a file-scope 6-slot table in PhoneAPI.cpp
keyed by \`this\`, not class members. Adding *any* per-instance field
to PhoneAPI breaks USB-CDC enumeration on the current nRF52 Adafruit
framework, one volatile bool was enough. Out-of-line side-steps it.

lockdown_auth is handled synchronously in PhoneAPI::handleToRadioPacket
rather than routed through the mesh Router into AdminModule. Two
reasons: the passphrase never travels through a routed MeshPacket
queue, and per-connection authorization runs while \`this\` is still on
the call stack. The previous async-via-router design lost connection
identity (g_currentContext was null by the time AdminModule processed
the auth), so per-connection unlock never actually took effect on the
originating client.

Non-nRF52: #warning, only DEBUG_MUTE activates. tools/lockdown_provision.py
drives provision / unlock / lock-now / watch over USB.

Display privacy is a screen-lock latch separate from storage-lock
state: shouldRedactDisplay() is true when storage is locked OR the
latch is set. Screen::setOn(false) sets the latch when the stock idle
timeout powers the display off (reusing config.display.screen_on_secs,
no second timer); it is cleared only when a client authenticates with
the passphrase. A device idling on the mesh keeps routing but hides
its screen until re-auth; button input wakes the backlight to the
LOCKED frame, not content. The earlier lockdown-specific 30s idle
timer is removed — it duplicated PowerFSM idle detection and showed a
misleading LOCKED screen on a merely-idle device.

Unlock-token TTL fix: a token carrying both a boot-count and a
wall-clock TTL is no longer destroyed when the RTC is invalid at cold
boot. The boot count is independently verifiable without a clock, so
the token falls back to boot-count enforcement instead of being
deleted. A token is only hard-rejected when its wall-clock TTL can be
evaluated and is found expired.

NodeDB::reloadFromDisk() after unlock is deferred to the main loop via
lockdownReloadPending rather than run inline on the transport callback
stack — the reload is too heavy for the BLE/serial task stack and was
resetting the device immediately after a successful unlock.

The screen-lock latch also swallows local input events in
InputBroker::handleInputEvent while it (or storage-locked) is set.
Without that, a blind operator could drive on-device menus, fire
canned messages, or change settings through the joystick/buttons even
though the screen content was hidden. PowerFSM is still triggered
first so the backlight wakes to the LOCKED frame; the event is dropped
before reaching the UI observers.

The screen-lock latch is initialised to true at boot, so even a
token-auto-unlocked cold boot comes up redacted. Otherwise an attacker
holding a screen-locked device could power-cycle it (the RAM latch
resets) and recover a content screen. After any boot, the operator
must authenticate from a client to reveal screen content.

MyNodeInfo.device_id is also redacted for unauthenticated clients —
it is a stable hardware identifier useful to an attacker for
fingerprinting / correlating the device across observations. The
public mesh fields (my_node_num, owner short/long name, public key,
hw model) are left as-is because they are already broadcast on-mesh.

ModuleConfig.mqtt is also redacted for unauthenticated clients —
MQTTConfig carries broker username, password, server address, and
root_topic. The empty MQTTConfig is emitted via the same zero-init
pattern as the other gated sections.

Uptime-based session limit (MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS)
caps how long a single auto-unlocked session can hold storage open,
measured in firmware millis() since unlock. 0 = unlimited (existing
token-only behavior, suitable for tower/infra nodes); non-zero arms a
timer on every passphrase unlock and on every token-auto-unlock that
inherits the value, since the cap is persisted in the token (token
format bumped to v2: adds sessionMaxSeconds, body 56→60 bytes).

On expiry the device revokes per-connection auth, re-engages the
screen-lock latch, and reboots WITHOUT deleting the token. Next boot
auto-unlocks via the boot count (decrementing it) and arms a fresh
session window. Hard exposure ceiling: bootsRemaining * sessionMaxSeconds.
Explicit user Lock Now still deletes the token (passphrase required to
recover); only session expiry preserves it.

Why uptime, not wall-clock: getValidTime() is fed by GPS/RTC/client
time pushes — all manipulable by an attacker with the device (GPS
spoof to roll the clock back, pull the RTC backup cell, Faraday-cage
the whole thing). millis() comes off the Cortex-M's internal cycle
counter, sealed inside the chip; the only way to reset it is a reboot,
which costs a boot from the on-flash token counter. APPROTECT remains
the load-bearing defense against forging higher boot counts via SWD.

A future LockdownAuth.max_session_seconds proto field will let the
client set this per-token; until that lands the build-time
MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS macro is the only source.

Session expiry now decrements the on-flash boot count in place and
re-arms the uptime timer WITHOUT rebooting, while budget remains.
Mesh routing keeps running across session boundaries; the device only
reboots when bootsRemaining reaches zero (rollback budget exhausted),
at which point it hard-locks and forces passphrase re-entry.

Each session boundary still: revokes per-connection admin auth so
clients must re-authenticate to see content, re-engages the screen
lock latch, and emits LockdownStatus{LOCKED, needs_auth, boots=N}
so connected clients see the decremented count and know to re-auth.
Storage stays unlocked (DEK in RAM) for continuity.

The boot count's role as the rollback ledger is unchanged — it
decrements monotonically once per session boundary, whether the
session ends in a reboot or an in-place roll. Attacker who power-
cycles to dodge the session timer still pays a boot via the existing
readAndConsumeToken decrement-at-load path. APPROTECT remains the
only defense against forging higher counts.

Net effect for an unattended/tower node with bootsRemaining=50,
sessionSeconds=3600: 50 hours of continuous mesh service, one
reboot at the end, vs. the previous design's 50 reboots over the
same period. Same exposure ceiling, far better uptime.

LockdownAuth.max_session_seconds (proto tag 5) is now consumed: when
non-zero the client value wins; 0 falls back to the firmware-side
MESHTASTIC_LOCKDOWN_SESSION_DEFAULT_SECONDS, matching the boots_remaining
sentinel convention. Protobufs submodule pin bumped to develop tip
which contains meshtastic/protobufs#916 (merged).

* security: drop dead is_managed allowlist for set_config(security).private_key

The 'isLockdownSecurityCmd' allowlist in handleReceivedProtobuf dates
from the pre-LockdownAuth design when the passphrase was smuggled
through SecurityConfig.private_key. With lockdown_auth handled
synchronously in PhoneAPI::handleToRadioPacket before any admin message
reaches the Router, this allowlist now serves no legitimate purpose
and lets an unauthenticated local client mutate security settings on
a managed device by setting private_key.size>=1 — including
potentially disabling is_managed itself.

Remove the allowlist. Managed-mode local admin now requires a
PhoneAPI connection that has already authenticated via lockdown_auth
(or, on the pki_encrypted branch below, a valid PKC admin key).

Resolves Copilot review feedback on src/modules/AdminModule.cpp:105.

* security: protect lockdown-status drain slot from concurrent writers

g_pendingLockdownStatus / g_hasPendingLockdownStatus are written from
multiple call sites (PhoneAPI::handleLockdownAuthInline on the BLE/USB
transport callback, AdminModule on the Router thread, main loop session
expiry) and read in getFromRadio() on whichever transport is draining
FromRadio. The struct read/write was unprotected, so a writer could
corrupt the slot mid-encode. Same pattern as nodeInfoMutex — wrap
both the queue path and the drain in a small lock. Drain re-checks
the bool under the lock to handle the case where another reader
grabbed the slot first.

Resolves Copilot review feedback on src/mesh/PhoneAPI.cpp:1560.

* security: derive readAndDecrypt size cap from caller buffer, not a hardcoded 64 KB

The MAX_PROTO_FILE_SIZE = 65536 + OVERHEAD ceiling was an absolute
constant chosen against a since-outdated assumption that 'meshtastic
proto files are well under 64 KB'. On variants where MAX_NUM_NODES
pushes the serialised NodeDatabase past 64 KB the legitimate file gets
rejected at load and the device treats its own real config as corrupt.

The caller already knows the maximum plaintext it expects (outBufSize).
Cap the ciphertext at outBufSize + OVERHEAD instead — this is the tightest
sound bound (anything larger could not possibly decode into the caller's
buffer), still defends against OOM / integer overflow, and scales with
the platform's actual NodeDB size rather than an arbitrary constant.

Resolves Copilot review feedback on src/security/EncryptedStorage.cpp:1327.

* docs: fix stale 'passphrase delivery via AdminModule' references in configuration.h

The lockdown overview comment block was written when passphrase delivery
ran through AdminModule's handleReceivedProtobuf. With the synchronous
refactor that path now lives in PhoneAPI::handleLockdownAuthInline,
called before the admin message reaches the Router. Update both the
nRF52 feature list and the non-nRF52 degraded-mode rationale to point
at the current code path.

Resolves Copilot review feedback on src/configuration.h:578 (and :604).

* docs: refresh unlock-token format doc to match v2 layout

The header comment for the UTOK file still described v1 (version 0x01,
no session_max_seconds, 71 bytes) even after the in-flight bump to
TOKEN_VERSION=0x02 and TOKEN_TOTAL_SIZE=75. The inline body-size
breakdown comment was also wrong (claimed 39 bytes and mismatched the
real NONCE_SIZE/AES_KEY_SIZE constants). Rewrite both to match the
actual on-flash layout and note how v1 tokens are handled on upgrade
(rejected via the version byte; passphrase re-entry mints a v2).

Resolves Copilot review feedback on src/security/EncryptedStorage.h:50.

* docs: correct session-limit comment re: token-auto-unlock behavior

The s_sessionMaxMs comment block claimed 'token-auto-unlocked
sessions have no session timer (the session feature is a
passphrase-unlock-only knob)'. Stale: readAndConsumeToken() now
persists sessionMaxSeconds in the token file and re-calls
setSession() from the token-load path, so token-auto-unlocked
sessions DO inherit the same cap (and consumeSessionBoot() re-arms
in place between sessions on a single boot). Update the comment to
match.

Resolves Copilot review feedback on src/security/EncryptedStorage.cpp:72.

* docs: clarify input-swallow gate re: screen-lock latch vs storage state

The previous comment said input is swallowed 'until a client authenticates
and unlockScreen() clears the latch (or storage is unlocked)'. The
parenthetical was misleading: storage being unlocked is not in itself
enough to clear the latch — the latch persists across the
storage-unlocked-but-screen-locked steady state, and only an explicit
unlockScreen() (called from a successful passphrase auth path) clears
it. Reword so the only-passphrase-clears-the-latch invariant is
explicit and local input is named as something that does NOT clear it.

Resolves Copilot review feedback on src/input/InputBroker.cpp:134.

* docs: fix reloadFromDisk() trigger comment in NodeDB.h

The header still claimed reloadFromDisk() is called by AdminModule
after a successful passphrase op. With the synchronous PhoneAPI
refactor the actual trigger is PhoneAPI::handleLockdownAuthInline
setting lockdownReloadPending, with main.cpp's loop() dispatching
the heavy reload on the main thread (the transport callback stack
isn't large enough). Update the comment to point at the real path
and explain why the deferral exists.

Resolves Copilot review feedback on src/mesh/NodeDB.h:393.

* style: clang-format lockdown sources

Apply trunk clang-format (16.0.3) to satisfy the format check.

* style: black-format lockdown_provision.py

Satisfy the trunk black formatter check.

* security: drop unused v1 EncryptedStorage formats and migration

This storage layer has never shipped, so there are no v1 DEK files,
v1 unlock tokens, or v1 backoff records anywhere to stay compatible
with. Remove the dead compatibility machinery:

- legacy init() (FICR-only KEK, no passphrase) — had no callers
- deriveKEKv1() / loadDEKv1() and the v1->v2 DEK migration paths in
  provisionPassphrase() and unlockWithPassphrase()
- the 5-byte v1 backoff file format

Also drop the now-pointless version byte from the on-disk MENC, MDEK,
and UTOK formats. Each is identified by its 4-byte magic (and, for the
keyed formats, its HMAC); with only one version that will ever exist,
the version field added nothing. Sizes shrink by one byte each
(overhead 54->53, DEK 66->65, token 75->74).

Rename the surviving helpers to drop the _v2 suffix (deriveKEK,
loadDEK, saveDEK, KEK_DOMAIN). No behavioral change for provisioning,
unlock, token consumption, or session handling.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): harden auth-table and lockdown_auth handler (audit)

Audit findings addressed:

C3 — `~PhoneAPI()` now clears its auth slot unconditionally. The previous
slot-clear in `close()` was gated on `state != STATE_SEND_NOTHING`, so a
PhoneAPI that never reached config (or that already closed) left
`slot.who` pointing at freed memory; a future PhoneAPI heap-allocated at
the same address would inherit the prior session's authorization through
`findOrAllocSlot`.

C4 — All access to `g_authSlots`, `g_authEpoch`, and `g_currentContext` is
now serialised through `g_authSlotsMutex`. Previously these were touched
without locking from BLE/USB/TCP/Router tasks, so two parallel slot scans
could hand out the same slot and mid-update reads could observe
authorized=true alongside a stale epoch. Granularity is fine — every
critical section is a short linear scan over six entries, and getFromRadio
(which calls `getAdminAuthorized()` per redaction check) tolerates the
brief blocking.

A4 / H1 — `lock_now` now requires the originating connection to be
already authorized. Previously any unauthenticated client (BLE/USB/TCP)
could submit `lockdown_auth { lock_now=true }` and force a reboot,
which was a trivial local-presence DoS — an attacker near the radio
could brick-loop it indefinitely. The original "panic button without
auth" property is dropped; panic now requires the operator to have
passphrase-unlocked the connection.

H2 — Empty-passphrase `lockdown_auth` (with `lock_now=false`) used to
silently return success. The client received no feedback distinguishing
that case from a real success, and an attacker could probe lockdown
state for free. Now emits UNLOCK_FAILED with no backoff increment
(empty-passphrase is more likely a client bug than an attack, but the
honest signal still lets the client correct itself).

H14 — `la.boots_remaining > 255` previously truncated silently
(256 → 0 → mapped to TOKEN_DEFAULT_BOOTS=50; 257 → 1). Honest clients
could not detect the misbehavior. Now rejected explicitly with
UNLOCK_FAILED.

L1 — The `to == nodeDB->getNodeNum()` allowance in the unauth ToRadio
gate now also requires `getNodeNum() != 0`. During the locked-default
boot path `getNodeNum()` returns 0, so a packet with `to=0` could
otherwise satisfy the equality and bypass the gate.

L2 — Comment added on `g_authEpoch` wrap. Practically unreachable
(2^32 lockNow events on one boot), but worth recording the behavior.

M17 — `findOrAllocSlot_LH` now evicts the first unauthorized stale slot
when the table is full of non-nullptr entries, rather than failing
closed. Authorized slots are never evicted — they represent live
operator sessions. Fail-closed (with LOG_WARN) only when every slot
holds a different live authorized PhoneAPI, which would require seven
simultaneous authed connections.

M18 — `s_screenLocked` is now `std::atomic<bool>` with relaxed ordering.
Plain bool happened to work on single-core Cortex-M4 today but breaks
silently if lockdown ports to ESP32 / RP2040, or under LTO whole-
program elision.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): gate every admin op on per-connection auth + storage unlock

Audit findings addressed:

H6 — Unauthenticated local clients could previously set_config / set_module_config /
set_channel etc. on a lockdown device whenever is_managed was unset.
The previous gate inside AdminModule's is_managed branch consulted
PhoneAPI::isLocalAdminAuthorized(), which reads a global g_currentContext
set during synchronous PhoneAPI dispatch — but AdminModule runs on the
Router task, by which time the dispatch task has exited and the global is
unrelated to the originating connection. The check was both broken (always
false on Router, so even authed clients were rejected) and unsafe (when it
did fire, the wrong connection could be authorized).

The fix relocates the gate to PhoneAPI::handleToRadioPacket, where dispatch
is synchronous and getAdminAuthorized() can be trusted. The admin payload
is already decoded there to extract lockdown_auth; extend the same branch
so that any non-lockdown_auth admin variant from an unauthorized connection
is dropped before ever reaching the Router queue.

H7 — Same root cause: get_config_request / get_module_config_request /
get_channel_request handlers returned full security/network/mqtt content
to unauthorized local clients. With the H6 gate in PhoneAPI, these
requests never reach AdminModule, so handleGetConfig / handleGetModuleConfig
/ handleGetChannel are only callable from authorized connections.

H9 — Remote admin (PKC-authorized peers, mesh-relayed admin) bypassed
lockdown entirely. If admin_keys were baked in via USERPREFS or set on a
prior unlocked boot, a remote attacker could drive factory_reset /
set_config against a locked device before the operator ever unlocked it.
Added an EncryptedStorage::isUnlocked() early-return at the top of
AdminModule::handleReceivedProtobuf. The local lockdown_auth path is
unaffected because PhoneAPI handles it synchronously before AdminModule
runs.

H10 — Removed g_currentContext, the ContextGuard, authorizeLocalAdmin(),
and isLocalAdminAuthorized() entirely. The audit's race (Router-thread
reads a pointer set by an unrelated parallel dispatch and authorizes the
wrong PhoneAPI) and the always-false-on-Router behavior both disappear
with the code that produced them. The PKC-admin auto-authorize path is
gone — PKC admin and the per-connection lockdown auth are now
independent: clients using PKC admin from a local app must also send
lockdown_auth to unlock the redacted FromRadio stream.

Cleaned up AdminModule's is_managed branch: under lockdown the
PhoneAPI-layer gate has already done its job, so no additional check
is needed; without lockdown the legacy is_managed-blocks-plain-admin
semantics are preserved.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): hold radio silent until storage is unlocked

Audit finding H8: while locked, the device beaconed nodeinfo and
telemetry on the public LongFast default PSK and routed incoming default-
channel packets through the locked router. The locked-default boot path
in NodeDB::loadFromDisk installs config via installDefaultConfig, which
honours USERPREFS_CONFIG_LORA_REGION (the common shape for managed
deployments) and synthesises the default LongFast channel. So a locked
device on managed firmware came up TX-enabled on a well-known PSK
before any operator interaction.

Force config.lora.region = UNSET in the locked-boot block.
RadioLibInterface gates both TX (startSend) and RX (readData) on
region != UNSET — locked devices no longer initialise the SX12xx for
either direction. Also set tx_enabled = false for any code path that
checks the flag directly without consulting region.

reloadFromDisk() restores the persisted lora config once the operator
unlocks. Note: until the audit's M8 (radio re-init after reload, the
upcoming commit 5 in this remediation series) lands, an unlocked
device may need to reboot before its radio fully comes up under the
real config; this is no worse than the pre-fix state, where the radio
was already running on the wrong (default) config and any real config
change required an explicit reconfigure or reboot anyway.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): per-connection status queue, redaction expansion, log/banner mute (audit)

M14 — Replaces the single file-scope LockdownStatus slot with a per-
PhoneAPI table keyed by PhoneAPI*, parallel to the auth-slot table and
sharing g_authSlotsMutex. Previously a status produced for connection
A (UNLOCKED with the active TTL, or UNLOCK_FAILED with a backoff)
could be drained by connection B before A read it, leaking A's auth
state to B. queueLockdownStatus is now a per-instance method writing
to this->slot. A new static broadcastLockdownStatus exists for the
main-loop session-expiry callers that have no PhoneAPI* in hand —
those want every connected client to learn about the session roll,
which is the only legitimate broadcast use case. hasPendingLockdownStatus
is a const helper for the FromRadio available()/drain check.

M13 — buildStatus_LH (the single point where lock_reason crosses into
the on-wire LockdownStatus) collapses any token_* reason to a generic
"locked" before emission. The specific reasons (token_hmac_fail,
token_wrong_size, token_bad_magic, token_boots_zero, token_expired,
token_dek_fail, token_missing) still go to local logs, but no longer
tell an unauthenticated client that the firmware noticed their
tampering / rollback / corrupt-file attempt.

M15 — Extended the STATE_SEND_MY_INFO redaction (previously device_id
only) to also wipe pio_env and min_app_version for unauth clients —
both are pure build-fingerprint vectors that tell an attacker which
known issues to probe. Kept my_node_num (broadcast on the mesh anyway)
and nodedb_count (clients need it post-unlock to decide whether to
pull the node DB). Added equivalent redaction for STATE_SEND_METADATA:
the whole DeviceMetadata struct is wiped for unauth clients
(firmware_version, device_state_version, hw_model, hw_model_string,
has_bluetooth/has_wifi/has_ethernet, role, position_flags,
excluded_modules). Clients re-fetch after authenticating.

M16 — LoRa config is now whitelisted for unauth clients to the set
that is intrinsically observable on the air anyway: region,
modem_preset, use_preset, channel_num, hop_limit. Operator-private
knobs (ignore_incoming, override_duty_cycle, override_frequency,
sx126x_rx_boosted_gain, tx_power, ignore_mqtt, fem_lna_mode,
config_ok_to_mqtt) are zeroed. The whitelist is built as a fresh
LoRaConfig stack copy rather than masked in place to avoid touching
the persisted struct.

M12 — Skip the DEBUG_MUTE "we are muted, FYI" banner under
MESHTASTIC_LOCKDOWN. The banner spilled APP_VERSION / APP_ENV /
APP_REPO over USB CDC even with all other logging suppressed, which
defeats the muting in lockdown builds and gives a USB-attached
attacker a free firmware-fingerprint primitive.

L9 — Removed the numeric backoff value from the LOG_WARN unlock-
failed message. The client receives backoff_seconds via the
UNLOCK_FAILED status; printing it again to USB serial under
non-DEBUG_MUTE builds (i.e. MESHTASTIC_LOCKDOWN_DEBUG dev builds)
was the only place it appeared in logs.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): atomic post-unlock reload with corruption surface (audit)

Closes M6, M7, M8, M9 from the lockdown security audit.

M6 — handleLockdownAuthInline no longer flips the connection to
authorized or emits UNLOCKED on the cold-unlock path (the first
successful passphrase verify after a locked boot). The client keeps
seeing LOCKED until reloadFromDisk has actually populated config /
channelFile / nodeDatabase with the operator's real values. Without
this, the window between the auth call and the main-loop reload
exposed two race-friendly bugs: (a) the client could read the
locked-default placeholders as if they were the real config, and (b)
a set_config in the window would silently overwrite a corrupted
baseline once the reload swapped values in.

A new per-status-slot bool pendingUnlockAfterReload records that the
connection is mid-unlock. The re-verify path (storage already
unlocked) is unchanged and authorizes immediately — there is nothing
to reload.

M7 — reloadFromDisk now holds a new file-scope mutex
(g_reloadFromDiskMutex) against itself, parks the radio in sleep
mode before swapping config / channelFile, and reconfigures the
radio with the now-real settings after. Other readers of config.lora
/ channelFile / nodeDatabase do not take this lock today; closing
those races is a wider locking-discipline change outside the audit's
M7 scope. The radio standby+reconfigure prevents the SX12xx from
sitting in a half-old/half-new register set across the swap, which
otherwise required a reboot to recover from.

M8 — RadioInterface::reconfigure() is now called at the end of a
successful reload, so the SX12xx register set actually reflects
the unlocked operator settings (region, modem preset, channels)
rather than staying on the locked-default placeholder. Routed through
a new Router::getRadioIface() accessor — the radio interface is
owned by Router as a unique_ptr and was not exposed.

M9 — NodeDB::loadProto now sets a NodeDB::storageCorruptThisLoad
flag whenever an encrypted file fails to decrypt or proto-decode.
reloadFromDisk consumes the flag and returns false on any failure
instead of silently falling back to defaults. main.cpp's reload
service then calls EncryptedStorage::lockNow() and
PhoneAPI::revokeAllAuth(), and the new
PhoneAPI::completePendingUnlocks(false) emits LOCKED(storage_corrupt)
to every pending connection — they stay unauthorized so any
set_config they send is dropped at the existing unauth gates.
The lock_reason string passes through buildStatus_LH's M13
redaction unchanged because it does not start with token_.

The success path goes through PhoneAPI::completePendingUnlocks(true)
which authorizes each pending connection, emits UNLOCKED with the
current TTL, and clears the screen-lock latch once. Snapshots the
target PhoneAPI* list outside the auth-table lock to avoid re-entry
when setAdminAuthorized takes the same lock.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): UI/pairing fixes for first-pair + content-flash + e-ink (audit)

Closes H13, M19, M20, L4 from the lockdown audit. (L3 dropped per
explicit decision — battery level is not a meaningful security side
channel.)

H13 — BLE pairing PIN was suppressed by the lockdown lock screen on
locked devices. Screen.cpp updateUiFrame's lockdown short-circuit
intercepts before ui->update() runs, so the pairing-PIN overlay
banner that NRF52Bluetooth::onPairingPasskey queued never painted.
Net effect: a freshly-locked device on first BLE pair could not be
unlocked over BLE because the operator could never see the PIN —
chicken and egg.

Adds a new notificationTypeEnum::pairing_pin value and special-cases
it in the short-circuit: paint the LOCKED frame first (so the
underlying background remains the redacted view, never dashboard
content) then let ui->update() composite the PIN banner overlay on
top. The PIN itself is an ephemeral pair-handshake artifact
(regenerated per attempt, dies on banner timeout) and is not
operator content, so this does not regress the redaction guarantee.

NRF52Bluetooth::onPairingPasskey switches from showSimpleBanner to
showOverlayBanner with notificationType = pairing_pin so the
short-circuit's lookup matches.

M19 — Brief content-visible window on Screen::handleSetOn(true)
wake. OLED GDDRAM physically retains the last-rendered frame while
the panel is powered off; the next ui->update() after displayOn() is
async, so an observer (or shoulder-surfer) could see the previous
frame's content for 16-50 ms on every wake. Under MESHTASTIC_LOCKDOWN
we now paint the LOCKED frame into GDDRAM in handleSetOn(false)
before calling displayOff(). On wake the only thing the panel can
flash is the redacted view. Gated on lockdown only — non-lockdown
builds keep the previous frame as a UX cue.

M20 — E-ink panels physically retain the last-rendered image
without power. A power-cycled lockdown handheld kept showing
operator-identifying content (position, messages, nodeinfo) until
the firmware's first natural refresh — which on e-ink can be
seconds into boot. Now, under MESHTASTIC_LOCKDOWN && USE_EINK, the
panel init path in Screen::setup() paints the LOCKED frame and
forces a full refresh (forceDisplay) immediately after ui->init()
and before any other rendering. Persistent pixels are wiped to the
redacted view before an observer can see them. Build-tested on
seeed_wio_tracker_L1_eink; hardware-verified visual confirmation
is pending a T-Echo session.

L4 — Screen::blink() bypasses the normal ui->update() path that
the lockdown short-circuit gates. It draws arbitrary geometry, not
node data, so it does not actually leak today; but any future
change that puts content into blink would silently leak past
redaction. Added an early-return on shouldRedactDisplay() to make
the function honor the redaction contract.

Verified with nRF52 lockdown builds on both rak4631 (OLED) and
seeed_wio_tracker_L1_eink (e-ink).

* fix(lockdown): refuse APPROTECT on vulnerable silicon, gate on provision (audit)

Closes M22 and M23 from the lockdown audit.

M22 — APPROTECT lockout on nRF52840 is publicly known to be bypassable
on every silicon revision shipping in current Meshtastic hardware
(AAB0..AAF0) via SWD glitching, per LimitedResults' published research
on the nRF52 series. Engaging APPROTECT on these revisions has two
bad properties: (1) the lockout is irreversible without a destructive
nrfjprog --recover, and (2) it gives the operator a false sense of
security because the lockout itself can be defeated by anyone with
ten minutes and a glitcher.

enableAPProtect() now reads FICR.INFO.VARIANT (encoded as a 4-byte
ASCII word) and refuses to engage on any known-vulnerable revision,
logging the variant so the operator knows their device's specific
build code. To override (e.g. for end-to-end testing of the engage
path on hardware that's known affected), rebuild with
-DMESHTASTIC_APPROTECT_OVERRIDE_VULNERABLE_SILICON=1.

The vulnerable list is explicit and easy to update: any future
revision shown to be fixed can be removed from the list and APPROTECT
will engage on it as before.

M23 — APPROTECT engagement moved from very early in setup() to
after fsInit() + EncryptedStorage::initLocked(), and gated on
EncryptedStorage::isProvisioned(). A misconfigured CI build of a
lockdown variant flashed to a dev board would otherwise burn SWD on
first boot before the operator had set any passphrase, taking the
board out of the development/recovery workflow with zero real
security benefit (there is no DEK to protect on an unprovisioned
device). Engagement now follows operator intent: SWD locks only
once they've committed to lockdown via passphrase provisioning.

The SWD-attachable window between boot and APPROTECT engagement
widens slightly from this reorder (now ~hundreds of ms while fsInit
runs) but APPROTECT remains effective on the only payload it could
protect (the in-RAM DEK loaded by initLocked which now runs *after*
APPROTECT for already-provisioned devices).

Verified with an nRF52 lockdown build (rak4631).

* tools: harden lockdown_provision.py (audit)

Closes M26-M30 and addresses L7.

M26 — passphrase input. --passphrase on argv now requires
--insecure-passphrase-on-cmdline as an explicit acknowledgement;
without it the tool refuses and points at --passphrase-file or the
interactive prompt. --passphrase-file refuses to read anything that
isn't mode 0600 (so a passphrase another user can read off the
filesystem doesn't silently succeed). With neither, the tool reads
the passphrase via getpass.getpass — and on 'provision' double-prompts
with a confirm.

M27 — provision now requires an explicit 'yes' confirmation unless
--yes is passed, after printing the warning that the passphrase
cannot be recovered. The double-passphrase prompt is built into
gather_passphrase(confirm=True). Reduces the chance of a typo
binding a device to an unrecoverable passphrase.

M28 — 'lock' subcommand gains a 'lock-now' alias, matching how the
audit and wire docs refer to it everywhere. Both forms now require
'yes' confirmation unless --yes is set, so an accidental command
doesn't immediately reboot the device into a locked state.

M29 — the 4-second sleep is gone. Replaced with a StatusFuture
single-shot that the FromRadio interceptor signals when the next
LockdownStatus arrives. provision/unlock/lock wait up to --wait
seconds (default 8) for the actual reply and exit non-zero with the
device's reason on UNLOCK_FAILED, surfacing backoff_seconds in the
error line. Exit codes are now meaningful:
  0 = UNLOCKED
  1 = no status / unexpected
  2 = NEEDS_PROVISION (or a precondition fault: missing pkg, bad args)
  3 = LOCKED (ambiguous: device reported locked rather than the
              expected unlocked result)
  4 = UNLOCK_FAILED
This lets ops scripts decide what to do without parsing stdout.

M30 — top-of-file docstring gained an explicit SECURITY MODEL block
that names the threat model (USB-only, passphrase cleartext on the
cable) and forbids extension to TCP/BLE/UDP without a redesign. A
runtime banner reprints the headline on every invocation. --port
values starting with tcp:/tcp://, ble:/ble://, udp:/udp://, ws:/wss:
are rejected at argument parse before any connection attempt; a
copy-paste of an example into a context with a different --port
cannot silently leak credentials to the wire.

L7 — private meshtastic APIs (_handleFromRadio, _sendToRadio,
_generatePacketId) are still in use because the lib does not yet
dispatch LockdownStatus on a public pubsub topic and there is no
public seam for raw ToRadio. Their use is now wrapped in
getattr-with-clear-error so a future lib version that removes them
produces an actionable error instead of an obscure traceback. The
top-of-file note explains why we're on the private surface.

Verified end-to-end on hardware (R1-Neo + Seeed Wio Tracker L1)
during the audit-remediation hardware test pass:
  - provision (interactive, with confirm and double-prompt)
  - unlock (success returns UNLOCKED + boots TTL)
  - watch (passive listener emits LockdownStatus events)
  - lock-now (with --yes)

* fix(lockdown): H13 — render pairing PIN steady over LOCKED frame

Two bugs in the H13 fix from commit 614b7f001:

1. NotificationRenderer::drawBannercallback's switch had no case for
   the new notificationTypeEnum::pairing_pin. The function fell through
   to no-op so the banner never rendered. Added pairing_pin alongside
   text_banner so it dispatches to drawAlertBannerOverlay (same
   rendering, distinct type so the lockdown short-circuit in Screen.cpp
   can recognise it).

2. updateUiFrame's lockdown short-circuit called ui->update() to
   composite the banner. That redraws the current carousel frame
   (the dashboard) into the host framebuffer BEFORE the overlay
   paints, so the panel flashed dashboard content under the banner
   on every cycle. Replaced with a direct call to drawBannercallback
   so only the banner box is painted on top of the LOCKED pixels.

Also: drawLockdownLockScreen used to commit to the panel
(display->display()) at its end. With the banner overlay then
painting and committing a second time, the panel visibly flickered
between 'just LOCKED' and 'LOCKED + banner' on every render cycle.
Split into drawLockdownLockScreenIntoBuffer (no commit) for the
lockdown short-circuit, and a thin drawLockdownLockScreen wrapper
that calls Buffer + display() for the other call sites that don't
composite anything on top. The short-circuit now commits exactly
once per frame after both LOCKED + any overlay are in the buffer.

Verified end-to-end on hardware (Seeed Wio Tracker L1, OLED):
fresh BLE pair against a locked device now shows the pairing PIN
steadily on top of the LOCKED frame, no flicker, no dashboard
leak, and pair completes normally.

* fix(lockdown): backoff MAC + atomic writes + fault wipe + size cap (audit)

Closes H3, H4, H12, M10, M11, M25 from the lockdown audit. Non-format-
breaking: existing devices keep their .dek and .unlock_token but their
old plaintext .backoff file (6 bytes, no MAC) is silently rejected as
tampered on first read and reseeded with the MAC'd 38-byte format on
the next failed-attempt OR successful unlock.

H3 — Pre-increment the failed-attempt counter BEFORE running the HMAC
verify in unlockWithPassphrase. The previous order wrote the counter
only after a failed verify, so an attacker glitching the chip between
verify and write could skip the increment and bypass backoff. The
slot is now reserved atomically up front; the success path writes
attempts=0 to clear the reservation. Worst case for a legitimate user
who power-cycles mid-success is one phantom attempt — backoff
recovers next try.

H4 — .backoff file is now MAC'd with HMAC-SHA256(ephemeralKEK,
"backoff-auth" || body) (32-byte tag), and written atomically via
SafeFile (tmp + readback verify + rename). readBackoff treats
missing / wrong-size / MAC-fail uniformly as max-attempts (255) so an
attacker who deletes or rewrites the file can only INCREASE the wait,
never decrease it. clearBackoff() now writes an attempts=0 sentinel
instead of removing the file, so 'missing == tamper' is unambiguous
post-provision. bumpBootsSinceFailOnBoot() skips on un-provisioned
devices to avoid false 'tamper' detection during the legitimate fresh
window between fsInit and provisionPassphrase.

H12 — saveDEK and writeUnlockToken now write via SafeFile in
fullAtomic mode (tmp file + readback verify + atomic rename) instead
of remove-then-open-then-write. Power loss during a DEK or token
write previously left the device unable to unlock — the encrypted
prefs files are unreadable without a valid DEK. The atomic path
rolls back to the previous file on partial write.

M10 — readAndConsumeToken's 74-byte stack buffer (entire wrapped
DEK + HMAC, explicitly called out by the audit as never wiped before
return) is now a meshtastic_security::ZeroizingBuffer that the
destructor scrubs on every return path. Same treatment for the
computedHmac stack array next to it, and for the new backoff state
buffers in readBackoff / writeBackoff / computeBackoffHmac. Removes
the manual secure_zero calls those buffers had on success paths and
fixes the missing wipes on the failure-return paths.

M11 — Added EncryptedStorage::secureWipeKeys() public API that
zeros dek/kek/ephemeralKek in BSS without touching flash, no
logging, no locks (safe from interrupt context). HardFault_Impl now
calls it as the very first thing on entry, before the diagnostic
print / coredump path runs, so a hard-fault crash dump won't capture
the DEK / KEK material that the rest of the module leaves in RAM.

M25 — migrateFile now refuses to allocate a buffer for any file
larger than 64 KiB. The legitimate ceiling is well under that on
every supported variant; anything larger is either corrupt or a
DFU-injected OOM attempt.

Verified with an nRF52 lockdown build (rak4631).

* fix(lockdown): MENC header MAC + token rollback counter (audit)

Closes M2 and M4 from the lockdown audit. **FORMAT-BREAKING** — devices
provisioned with prior lockdown firmware must factory-erase /prefs and
reprovision; the previous tokens and encrypted prefs files will not
decrypt under the new HMAC/body layouts.

M2 — The HMAC on MENC encrypted proto files now covers the full on-disk
header (4-byte magic + 13-byte nonce + 4-byte plaintext_len + ciphertext)
instead of just (nonce + ciphertext). Without this, magic and
plaintext_len were integrity-protected only by the equality check
`plaintextLen == ciphertextLen` — which holds today (no padding /
compression / AAD) but would silently produce length-oracle and
downgrade vulnerabilities the instant any of those got added. Putting
the header inside the MAC closes that pre-condition cleanly. The
verify side in readAndDecrypt and the compose side in encryptAndWrite
update in lockstep.

M4 — UTOK gains a 4-byte monotonic counter field inside its MAC'd
body. The highest counter ever issued is persisted to a new
/prefs/.tokmono file MAC'd with HMAC-SHA256(ephemeralKEK,
"tokmono-auth" || counter). On every readAndConsumeToken, any
token whose counter is less than the persisted value is rejected as
a rollback attempt and deleted. Defeats the audit's threat: an
attacker who once captured a token (e.g. bootsRemaining=255 from
before the operator lowered the policy) tries to write it back to
disk later. Counter is incremented monotonically across the device's
lifetime so any captured snapshot loses to the persisted max-seen.

Self-heal: a token whose counter exceeds the persisted value (e.g.
the .tokmono write itself failed after the token committed, or the
.tokmono got wiped via factory-erase) is accepted AND the counter
file is promoted to match. This avoids spuriously rejecting valid
tokens after partial-update recovery.

Threat model caveat (consistent with C2 acceptance): an attacker who
has both flash extraction AND FICR can recompute the .tokmono MAC
and restore a matching pair (.unlock_token + .tokmono) from an
earlier capture. M4 raises the bar to that combined capability;
the flash-write-only attacker is now blocked.

Verified with an nRF52 lockdown build (rak4631).

MIGRATION: devices already provisioned with the prior lockdown
firmware will fail to auto-unlock at boot (token format mismatch),
fall back to LOCKED(needs_auth), and every passphrase attempt will
fail because the encrypted /prefs files are HMAC'd against the old
input. Recovery is: factory-erase via the bootloader UF2 then
re-provision via lockdown_provision.py or the Android app.

* feat(lockdown): make lockdown a runtime client-toggleable setting

Converts MESHTASTIC_LOCKDOWN from a per-variant compile-time flag that
forced lockdown ON into an internal capability that is ALWAYS compiled
in for nRF52 and gated purely at runtime by whether a passphrase has
been provisioned. A device that has never been provisioned (or that the
operator disabled) behaves exactly like stock firmware.

Build/config:
- configuration.h auto-defines MESHTASTIC_LOCKDOWN (+ ACCESS_CONTROL,
  ENCRYPTED_STORAGE, APPROTECT-capable) for ARCH_NRF52 unconditionally.
  No variant sets -DMESHTASTIC_LOCKDOWN anymore. Flash-constrained
  variants can opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1. DEBUG_MUTE
  is no longer coupled to lockdown (a capable-but-off device must log
  normally). rak4631 lands at 96.2% flash with lockdown always-in.

Runtime predicate:
- EncryptedStorage::isLockdownActive() == isProvisioned() (.dek exists)
  is the single source of truth for active/inactive.
- PhoneAPI::getAdminAuthorized() returns true when lockdown is inactive,
  so every existing redaction gate no-ops on a capable-but-off device
  with no per-site changes. The locked-boot defaults path (NodeDB), the
  AdminModule storage-locked gate, the screen-redaction predicate, and
  the plaintext->encrypted migrate block are all additionally gated on
  isLockdownActive() so an un-provisioned device loads/serves plaintext
  normally.
- sendConfigComplete emits LockdownStatus{DISABLED} when capable-but-off
  so the client renders its toggle OFF.

Enable (off->on): client provisions a passphrase. provisionPassphrase
generates the DEK; the existing reload path encrypts the plaintext
config in place (migration runs live with the DEK in RAM) and authorizes
the connection -> UNLOCKED. No reboot.

Disable (on->off): LockdownAuth{passphrase, disable=true}. PhoneAPI
verifies the passphrase (loads DEK), sets lockdownDisablePending; the
main loop runs NodeDB::disableLockdownToPlaintext() which decrypts every
pref via EncryptedStorage::migrateFileToPlaintext() then
removeLockdownArtifacts() deletes the DEK/token/counter/backoff (the
.dek delete is the atomic commit), then reboots into normal mode.
Power-loss safe and re-runnable without a persistent marker — and the
crypto runs live with the operator's passphrase in RAM rather than via
a boot-time marker an attacker could plant to trigger an unprompted
decrypt. APPROTECT is NOT reversed (sticky; permanent on silicon where
it engaged).

Generated bindings (admin.pb.h / mesh.pb.h) regenerated against
protobufs#927 (LockdownAuth.disable, LockdownStatus.State.DISABLED).
Submodule pointer stays at the pinned develop commit; the bindings are
ahead until #927 merges and the submodule is bumped, same flow as the
max_session_seconds work.

Builds clean: rak4631 with no flags now auto-includes lockdown.

NOTE: this changes the LockdownStatus the firmware emits and adds the
disable path; pairs with protobufs#927 and the upcoming Android client
toggle work.

* fix(lockdown): re-lock per-connection auth on BLE reconnect

A provisioned device reused a single BLE PhoneAPI instance, and the
per-connection auth slot (keyed by that instance) was only cleared on the
!isConnected() disconnect transition. A fast disconnect/reconnect could
begin a new config burst while state was still STATE_SEND_PACKETS, so the
reconnected client inherited the prior session's authorization: it received
SecurityConfig in the clear and no LockdownStatus, and never re-authenticated.

Reset the auth slot in NRF52Bluetooth onConnect(), which fires once per
physical link, so every new connection starts locked regardless of whether
the previous link's close() raced the new handshake. handleStartConfig keeps
its !isConnected() reset (do NOT reset on a same-connection want_config: the
post-unlock re-fetch is the client pulling now-unredacted config and must keep
the auth it just earned, otherwise config comes back redacted and set_config
writes get dropped).

* fix(lockdown): persist config on a lockdown-capable but disabled device

saveProto always called encryptAndWrite when encrypted storage was compiled,
and saveToDiskNoRetry skipped every save when !isUnlocked(). On a disabled
(never provisioned) device there is no DEK and isUnlocked() is always false,
so both paths fired and NO config ever persisted: a LoRa region set before
enabling lockdown lived only in RAM, then provisioning migrated the UNSET
default from disk and the region was lost.

Gate both on isLockdownActive(): when lockdown is inactive the device writes
plaintext exactly like stock firmware; the reloadFromDisk migrate pass then
re-saves those plaintext files encrypted once the device is provisioned.
Verified on hardware: region set while disabled now survives enable, reboot,
and unlock.

* fix(lockdown): suppress LoRa region picker under the lock screen

A locked-boot lockdown device installs region=UNSET as a deliberate RAM
placeholder (the real region is in encrypted storage, restored on unlock).
Screen.cpp popped the region picker / onboard message whenever region==UNSET,
so it rendered over the lock screen and trapped input with no way out. Skip it
while the display is being redacted for lockdown.

* fix(lockdown): silence cppcheck void* false positive + ruff docstring lints

The nRF52 `check` (cppcheck --fail-on-defect=low) flagged
arithOperationsOnVoidPointer on EncryptedStorage.cpp buffers. These are
false positives: make_zeroizing_array() returns unique_ptr<uint8_t[], ...>
so .get() is uint8_t*, not void* — cppcheck just can't resolve the
custom-deleter alias. File-scoped suppression, matching the existing
crypto-code convention in suppressions.txt.

Trunk flagged 5 ruff docstring issues in lockdown_provision.py: D301
(backslashes need a raw docstring) and D405/D407/D411/D413 (the EXAMPLES
heading was being parsed as a numpydoc section). Made the docstring raw
and renamed the heading to USAGE to dodge section detection while keeping
the ASCII-box formatting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lockdown): resolve cppcheck const/null-deref defects

The nRF52 `check` job (pio check --fail-on-defect=low) flagged seven
real cppcheck defects in the lockdown code:

  - EncryptedStorage.cpp: nonce/encDek are read-only views into the
    token buffer -> const uint8_t *.
  - NodeDB.cpp: segments[] lookup table is never mutated -> const.
  - PhoneAPI.cpp: clearStatusSlot_LH's p is only compared; the auth-check
    slot and the hasPendingLockdownStatus loop var are read-only -> const.
  - Screen.cpp: the MESHTASTIC_LOCKDOWN drawLockdownLockScreen() guard
    introduced a redundant null check (nullPointerRedundantCheck) since
    dispdev->displayOff() right below derefs it unguarded, as does the
    rest of the file. Dropped the guard.

Verified with cppcheck 2.21 locally against the project suppressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lockdown): const-qualify clearAuthSlot_LH param (cppcheck cascade)

Making clearStatusSlot_LH take const PhoneAPI* let cppcheck propagate the
same to clearAuthSlot_LH, whose p is only compared and forwarded. The
remaining PhoneAPI* params (findOrAlloc*Slot_LH) store p into the slot
table, so they correctly stay non-const.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lockdown): wire runtime-toggle disable flow into provision tool

Addresses Copilot review on tools/lockdown_provision.py — the reference
tool advertised the runtime-toggle disable lifecycle but couldn't exercise
it:

  - _STATE_NAMES: map LockdownStatus.DISABLED so a capable-but-off boot
    prints DISABLED instead of an opaque state=<num>.
  - build_lockdown_auth(): add a disable param that actually sets
    la.disable, failing loudly on pre-runtime-toggle bindings instead of
    silently sending a plain unlock.
  - cmd_disable() + 'disable' subcommand: send LockdownAuth{disable=true,
    passphrase=...} and wait for the resulting LockdownStatus. Mirrors the
    firmware: non-empty passphrase required, DISABLED broadcast precedes
    the reboot, TTL/session fields ignored.
  - _exit_code_for_status(): treat DISABLED as a success (exit 0) like
    UNLOCKED.

All DISABLED/disable references are hasattr-guarded so the tool still
imports and runs the lock/unlock/provision paths against the currently
released meshtastic package (verified: it has LockdownAuth but not yet
disable/DISABLED). Verified with ruff 0.15.13 and black.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:39:49 -05:00
Ben Meadors eb719f6fca Refine IPv6 address logging for CH390 driver in WiFiAPClient 2026-06-11 15:37:22 -05:00
Ben Meadors 02081dc85d Fix Ethernet handling and dependencies for CH390 driver 2026-06-11 15:36:13 -05:00
Ben Meadors ed52e3019d Change handleSetOwner parameter to const reference and improve long name handling 2026-06-11 14:24:12 -05:00
Ben Meadors c2bcec93d0 Fix long name clamping and adjust related structures for compatibility 2026-06-11 12:18:26 -05:00
8bb5364d8c tunk (#10684)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-11 07:57:06 -05:00
Ben Meadors 83c7e4ede3 Add board_level configuration for Heltec V4, RAK WisMesh Tag, and Seeed Wio Tracker L1 2026-06-11 07:21:25 -05:00
Ben Meadors a14f7afe87 fix(workflows): expand trusted author criteria for flasher comments 2026-06-10 20:04:40 -05:00
Ben Meadors 1490daa7ca Update runner configuration to use GitHub-hosted runners for checks 2026-06-10 19:12:32 -05:00
Ben Meadors a4001d71d5 Improve PR resolution logic for web flasher link comments 2026-06-10 17:54:24 -05:00
Ben Meadors 6da9f5f20e Add placeholder comment for web flasher during PR builds 2026-06-10 17:28:30 -05:00
TomandGitHub ab882c5619 EU regions merge (#10675)
* stronger together

* validate 2.4ghz regions

* less noise

* you're right, and that shapens the analysis significantly

* sassy rejoinder
2026-06-10 18:37:14 +01:00
Ben Meadors 2541db2bef fix(workflows): update artifact selection to exclude expired firmware size artifacts 2026-06-10 10:01:12 -05:00
Ben Meadors f875518b28 Flasher link fix 2026-06-10 08:00:05 -05:00
Ben Meadors 334ad9b313 Restrict web flasher link comments to organization members only 2026-06-10 06:33:33 -05:00
Ben Meadors 0953706e9e Add GitHub Action to post web flasher link comments on successful PR workflows 2026-06-10 05:48:39 -05:00
Ben Meadors 309d51a3e8 fix(NodeInfoModule): update user handling in allocReply to prevent global state clobbering 2026-06-09 21:00:40 -05:00
Ben Meadors 93f87c57b9 MacOS fixes 2026-06-09 21:00:05 -05:00
Ben MeadorsandGitHub 94ef2ae451 Revert "Automated version bumps (#10667)" (#10672)
This reverts commit abef0d85a2.
2026-06-09 20:04:26 -05:00
abef0d85a2 Automated version bumps (#10667)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-06-09 19:32:27 -05:00
6b3f975ba5 fix(ble): reliably expose and update BLE battery level (BAS) (#10622)
* fix(ble): reliably expose and update BLE battery level (BAS)

The Battery Service (0x180F / 0x2A19) is now wired up per the Bluetooth
BAS spec: the Battery Level characteristic always holds a valid 0-100
value and is pushed on change.

- NimBLE: seed an initial level at setup and cache the value on every
  update so a READ returns the current level even while disconnected;
  only notify when a client is connected.
- Power: mirror the battery level to the Battery Service from
  readPowerStatus() on change, so it updates independent of GPS/position
  events (previously the only push path was MeshService).

Also fixes two regressions the above would otherwise introduce:

- NimBLE use-after-free: BLEDevice::deinit(true) frees the GATT objects
  but left the global BatteryCharacteristic dangling. Several AdminModule
  paths (e.g. serial-config entry) deinit BLE while config.bluetooth.enabled
  stays true, so the periodic push would deref freed memory. Null the
  pointer in deinit().
- NRF52: guard blebas.write() on the nrf52Bluetooth instance so the new
  periodic push can't call it before the Battery Service is begun in setup().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ble): clamp BAS battery level to 0-100 and skip redundant updates

Address review feedback on the Battery Level characteristic (0x2A19):

- Clamp the value to the BAS-mandated 0-100 range at the platform write
  boundary (NimBLE seed + update, NRF52 update), so a misbehaving battery
  backend can't put an out-of-range value on the characteristic.
- Skip the write/notify when the level is unchanged, so repeated callers
  (e.g. MeshService refresh paths) don't emit redundant notifications.
- Simplify Power.cpp to a direct guarded call now that clamping and
  de-duplication live at the boundary, which also removes the implicit
  int->uint8_t narrowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-09 18:58:26 -05:00
Jason PandGitHub e028663658 BaseUI: First attempt at Ham Mode implementation (#10663)
* First attempt at Ham Mode implementation

* Simplify licensedOnly check

* Move related code closer together

* TX Disabled if N0CALL, enabled if properly set

* Only disable if callsign is N0CALL, don't enable at this stage.

* Allow users back to Normal mode if they don't pick an ITU region
2026-06-09 19:52:29 -04:00
bf68b9e597 NRF52 LTO flags (#10655)
* Add LTO support for nrf52840 while preserving interrupt handlers

* nrf52840: enable whole-image LTO on all targets via nrf52_base

Moves -flto + the nrf52_lto.py exclusion middleware from the rak4631 env
(771018ca8) up to [nrf52_base], so every nrf52840 target inherits it.

nrf52_lto.py keeps the interrupt handlers out of LTO (framework core +
TinyUSB USBD_IRQHandler) -- they're referenced only from the asm vector
table, so whole-program LTO would otherwise drop them and the chip hangs.

HW-validated: RAK4631 (SX1262, -60KB) and muzi-base (LR1121) both boot and
init their radios. Build-verified on canaryone (fresh board, base-inherited).

Caveat: the RAK "1-Watt" variant's radio does not tolerate global-LTO
(SX126x init fails); it shares the rak4631 binary, so keep that hardware on
src-only or split it into a separate target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nrf52840: move -fmerge-all-constants to nrf52_base (all targets)

It had been trialed on rak4631 only; it's a general image-wide flag (the
same one stm32 uses globally, ~0.7KB), so move it up to [nrf52_base] next
to -flto so every nrf52840 target gets it instead of just rak4631.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nrf52_lto.py: normalize path separators for Windows (Copilot review)

get_abspath() returns backslash-separated paths on Windows, so the
"/FrameworkArduino/" / "/cores/nRF5/" substring matches would miss and the
ISR-owning objects would still be LTO'd -> hang on first IRQ. Replace
backslashes with forward slashes before matching. No-op on macOS/Linux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix  directory handling for LTO

* Add post-link guard to check for dropped ISR handlers in nrf52 LTO

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jason P <applewiz@mac.com>
2026-06-09 16:09:25 -05:00
a9b98f47e9 GPS: cache model and baudrate and skip full sweep every startup (#10544)
* GPS cache

* trunk and CRLF fix

* Fix GPS.cpp formatting for trunk

* Format GPS.cpp for trunk clang-format

* Show gps model instead of model number

* Potential fix for pull request finding

Useful fix

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Update GPS.cpp

* Update GPS.cpp

* Trunk fix

* Update GPS.cpp

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 10:24:59 -05:00
Jason PandGitHub 90a3ac5938 Update SharedUIDisplay.cpp (#10659) 2026-06-09 09:17:43 -05:00
Jonathan Bennett 38f15db1d0 Bump protos to latest develop and regen 2026-06-08 16:28:40 -05:00
AustinandJonathan Bennett da821ec663 Actions: Update protobufs using the triggering branch (#10612) 2026-06-08 16:21:52 -05:00
Jonathan Bennett 124bffad84 Update Thinknode m7 pins (#10635) 2026-06-08 16:10:51 -05:00
Jason PandJonathan Bennett f98abe00f3 Update clock to be 70% max versus 80% to avoid unintended overlaps (#10516) 2026-06-08 16:01:02 -05:00
Thomas GöttgensandJonathan Bennett 56a33a07f7 remove private flag 2026-06-08 15:54:38 -05:00
Thomas GöttgensandJonathan Bennett ce80433e43 activate HWID 2026-06-08 15:54:24 -05:00
3d98622b96 Add hex picker (#10650)
* Add hex picker

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-08 14:50:55 -05:00
Jonathan BennettandGitHub d3691258d3 Update nanopb download URL in workflow 2026-06-08 14:23:22 -05:00
Jason PandGitHub 360c54f1f9 Random 2.8 Warning cleanups (#10649)
* Clean up Compass warning

* Update ICM42607PSensor.cpp
2026-06-08 08:12:57 -05:00
Thomas GöttgensandGitHub 8c4900a52f Prevent ghost nodes during onboarding (#10647)
* Prevent ghost nodes during onboarding
* Coplilot is exceptionally nit-picky today
2026-06-07 22:54:25 +02:00
bfb833982e Flip C6 to supported. (#10646)
* Flip C6 to supported.

* Re-add board_level pr

and remove redundant lib_deps

---------

Co-authored-by: Austin <vidplace7@gmail.com>
2026-06-07 15:12:59 -05:00
TomandGitHub 1410f170f9 makes clod format as it goes (#10645) 2026-06-07 08:09:19 -05:00
AustinandGitHub 14e998e6c3 ESP32: Update pioarduino to v3.3.9 (#10637)
Arduino Release v3.3.9 based on ESP-IDF v5.5.4

May help with recent compile troubles?
2026-06-06 17:03:41 -04:00
AustinandGitHub 57f678240d Add 1.25 Meter '125cm' amateur radio region support (#10638) 2026-06-06 07:43:49 -05:00
AustinandGitHub 99df0a6fe9 Update protobufs (#10636) 2026-06-05 14:46:29 -05:00
ce7444c1a1 feat: add WIZnet W5500-EVB-Pico2 + E22P-868M30S variant (#10552)
* feat: add WIZnet W5500-EVB-Pico2 + E22P-868M30S variant

Adds a community variant for the WIZnet W5500-EVB-Pico2 development
board (https://docs.wiznet.io/Product/iEthernet/W5500/w5500-evb-pico2)
paired with an external EBYTE E22P-868M30S LoRa module.

This is the hardware twin of variants/rp2350/diy/pico2_w5500_e22 — same
LoRa pinout, same W5500 pin mapping — and reuses its variant.h verbatim
via `-I variants/rp2350/diy/pico2_w5500_e22`. No duplicated pinout file.

Two differences vs pico2_w5500_e22 justify the separate env:

1. Board target: `wiznet_5500_evb_pico2` (2 MB flash) instead of
   `rpipico2` (4 MB flash). The WIZnet PCB ships with a smaller Q-SPI
   chip than a stock Pi Pico 2. Selecting the correct board makes the
   linker fail fast on overflow instead of producing a UF2 that gets
   silently truncated when flashed to the device.
2. W5500 PHY is integrated on the PCB (hard-wired SPI0 GP16-20) rather
   than supplied as an external module the user wires manually.

The E22P-868M30S uses a single combined RFEN pin (LNA + PA enable)
instead of the older E22-900M30S's split RXEN/TXEN pins. RFEN is held
HIGH at all times via `SX126X_ANT_SW 3`; TX switching still uses the
on-module DIO2 → TXEN bridge through `SX126X_DIO2_AS_RF_SWITCH`.

Build verified: wiznet_5500_evb_pico2_e22p SUCCESS
(RAM 19.2%, Flash 65.6% of 1.5 MB partition / 2 MB chip).

* style(wiznet-evb-pico2-e22p): prettier-format README tables

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-05 09:08:43 -05:00
LittleatonandGitHub e3ae0a6ab5 missing module config (#10496)
13302 and 13300 had missing module config for slot 2, also the rf switch and voltage info was missing.

Without that the auto setting in the config.yaml will try to use the default  lora-hat-rak-6421-pi-hat.yaml and things do not work correctly.
2026-06-05 08:34:28 -05:00
pdxlocationsandGitHub 733430ed45 feat: Add Module configuration for RAK13300 and RAK13302 in Slot 2 (#10632) 2026-06-05 06:10:25 -05:00
cd2466692f fix: FEM Sleep/Wake Fix - Enable PA after sleep (#10617)
* fix: release Heltec v4 FEM sleep holds

* fix: increase FEM power settle delay

* Fix heltec_V4 documentation

Fixing the heltec_v4 documentation for enabling the PA after sleep.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Add comment for the next guy about why 5ms was chosen.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-06-04 20:40:05 -05:00
AustinandGitHub 4b424f0234 Add support for T-Beam Supreme 144mhz variant (#10624)
Adds support for T-Beam-Supreme 144mhz version
https://github.com/Xinyuan-LilyGO/LilyGo-LoRa-Series/blob/master/docs/en/t_beam_supreme/t_beam_supreme_144_hw.md

Tested / working.
2026-06-04 16:03:39 -05:00
a212d2e84f Save Frame Visibility Actions (#10576)
Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
2026-06-04 10:05:51 -05:00
HarukiToredaandGitHub 5154e81d0b Unify InkHUD message storage with BaseUI's MessageStore (#10596)
* Unified Messagestore.

* DM fix

* Copilot fixes
2026-06-04 10:05:31 -05:00
TomGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Austin
5c1b6b2a23 Size change reporting (#10488)
* feat: add firmware size reporting and comparison scripts from #9860

* feat: add silence output feature to size_report and implement tests for size reporting scripts

* rm shame

* Fix baseline artifact paths in size report workflow

* feat: add firmware size reporting and comparison scripts from #9860

* feat: add silence output feature to size_report and implement tests for size reporting scripts

* rm shame

* Fix baseline artifact paths in size report workflow

* fix write permissions

* tunk

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Austin <vidplace7@gmail.com>
2026-06-04 06:31:27 -05:00
de345939af Automatic variable hop limits based on mesh activity and size estimation (#10176)
* asdf

* Implement SphereOfInfluenceModule for traffic management and eviction tracking

* Implement Sphere of Influence module for dynamic hop limit adjustment and role-based floor

* Update SAMPLING_DENOMINATOR to improve mesh size estimation accuracy

* Add debug logging for scale factor estimation and per-hop node counts in SphereOfInfluenceModule

* Enable variable hop limits and role-based hop floors in Sphere of Influence module

* Respond to copilot review

* Disable variable hop limits and role-based hop floors in Sphere of Influence module

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Implement adaptive sampling for unique node ID tracking in Sphere of Influence module

* Add state persistence for Sphere of Influence module

* Enhance Sphere of Influence module with state management and adaptive sampling adjustments

* Refactor hop scaling functionality: remove SphereOfInfluenceModule and introduce HopScalingModule

- Deleted SphereOfInfluenceModule.h, consolidating its responsibilities into the new HopScalingModule.
- Added HopScalingModule.h and HopScalingModule.cpp to manage hop scaling logic, including eviction tracking and sampling-based mesh size estimation.
- Implemented methods for recording evictions and packet senders, estimating scale factors, and computing required hops based on node activity.
- Introduced state persistence for hop scaling parameters to maintain continuity across reboots.
- Enhanced thread safety and modularity by utilizing concurrency features.

* Guard out STM32. Sowwy.

* Refactor HopScalingModule: enhance sampling logic and improve state management

* Add unit tests for HopScalingModule: implement mock database and various test scenarios

* Refactor test output in HopScalingModule tests: replace printf with TEST_MESSAGE for better integration with Unity

* Refactor HopScalingModule logging: replace lastStatusMode with descriptive mode names for improved readability

* Refactor test_main.cpp: change NodeNum variable to static and improve comments for clarity

* Remove unnecessary delay in setup function for improved test performance

* Add missing include for MeshTypes in test_main.cpp

* Refactor HopScalingModule tests: enhance mesh topology scenarios and improve test clarity

* Update HopScalingModule tests: flesh out node scenarios and improve clarity for dense and sparse mesh cases

* Fix politeness factor calculations in HopScalingModule and update related test scenarios for clarity. Remove outdated design doc.

* Enhance HopScalingModule: add sampled estimate for scaling decisions and refactor initial run state management

* Add sample traffic injection for HopScaling tests to enhance sampledEst visibility

* Enhance HopScalingModule: adjust windowFraction calculation for early triggers and improve test output formatting

* Enhance HopScalingModule: add jitter functionality to sampling denominator and update tests for consistent behavior

* Enhance HopScalingModule: implement adaptive sampling denominator adjustment and add reset functionality for tests

* Enhance HopScalingTestShim: add test-only clock and window helpers, update injectSampleTraffic for adaptive sampling, and improve scenario summary output

* Enhance HopScalingModule: add detailed documentation for functions, improve clarity of jitter and sampling logic, and reset functionality in tests

* Enhance HopScalingModule: add evictionEstimate parameter to estimateScaleFactor and update related logging for improved mesh size estimation

* Enhance HopScalingModule: adjust effective rolls calculation for improved accuracy, add eviction estimate logic, responding to all copilot review points

* Implement CompactHistogram for parallel hop scaling sampling

- Added CompactHistogram class to track node hop distances with bitwise sampling.
- Integrated CompactHistogram into HopScalingModule for independent packet sampling.
- Updated NodeDB to feed both the hop scaling module and the new histogram sampler.
- Enhanced HopScalingModule with methods to sample packets for the histogram and retrieve hop distribution statistics.
- Implemented tests for CompactHistogram functionality, including sampling, window rolling, and adaptive denominator scaling.
- Updated existing tests to validate the integration of the new histogram sampling mechanism.

* Enhance CompactHistogram and HopScalingModule: add per-hop distribution functionality, improve time handling for unit tests, and refine test setup for deterministic behavior

* CompactHistogram: add mesh size estimation, improve entry replacement logic, and update logging for per-hop distribution

* Refactor HopScalingModule and CompactHistogram integration

- Removed the suggestedHopFromCompactHistogram function to streamline hop suggestion logic.
- Updated HopScalingModule to directly utilize CompactHistogram's internal methods for hop suggestions and sampling.
- Enhanced logging in HopScalingModule to provide detailed histogram statistics.
- Modified test cases to ensure comprehensive coverage of new histogram behaviors and sampling logic.
- Improved node ID distribution in tests to better exercise sampling mechanisms.
- Ensured that filtering denominators are held for 12 hours before dropping, enhancing stability in sampling.

* Refactor CompactHistogram to support 13-hour activity tracking and introduce politeness regimes

- Updated the bitfield structure to accommodate 13-hour seen tracking.
- Changed the logic in rollHour() to analyze activity over the last 0-2 hours vs. 1-3 hours for politeness factor calculation.
- Introduced three politeness levels: GENEROUS, DEFAULT, and STRICT based on recent activity ratios.
- Adjusted filtering and sampling logic to reflect the new 13-hour tracking period.
- Updated unit tests to validate new behavior and ensure proper functionality of politeness regimes.

* Enhance CompactHistogram and HopScalingModule for improved sampling and decision-making

- Introduced a session-specific hash seed in CompactHistogram to reduce bias in node ID sampling.
- Updated sampling logic to use hashed node IDs instead of raw IDs for filtering and entry management.
- Added histogram rollover tracking in HopScalingModule to ensure proper decision-making after initial data collection.
- Adjusted logging to reflect the active state of the histogram and its comparison with NodeDB advisory hops.
- Enhanced unit tests to validate new sampling logic and memory layout changes.

* Expose hashNodeId for testing in CompactHistogram

* Add mesh trend statistics to CompactHistogram for enhanced activity tracking

* Implement histogram state persistence in CompactHistogram with save and load functions

* Refactor CompactHistogram to improve entry management and enhance rollHour logging

* feat: add HopScalingModule for adaptive hop limit recommendations

Introduces HopScalingModule, a sampled hop-distance histogram that
recommends the minimum hop limit needed to reach ~40 nodes, and
automatically reducing the hops as the mesh grows.

Key design:
- 512-byte packed histogram (128 × 4-byte Record entries) embedded
   in a new HopScalingModule.
- Each Record: 16-bit node hash, 3-bit hop distance, 13-bit seen bitmap
- Sampling filter: only nodes where (hash & (denom-1)) == 0 are kept;
  denominator doubles on overflow and halves when utilisation is low
- Hourly rollHour(): tallies per-hop counts, walks scaled buckets to
  find the minimum hop satisfying TARGET_AFFECTED_NODES (40), applies a
  politeness extension based on recent/older activity ratio, shifts all
  seen bitmaps, and persists state to /prefs/hopScalingState.bin
- Hop recommendation gated by bootstrap (requires >=1 rollHour before
  overriding HOP_MAX)
- NodeDB calls samplePacketForHistogram() on every non-MQTT rx packet
- Module also estimates total mesh size and logs useful information about
  mesh characteristics.

Changes:
- src/modules/HopScalingModule.h/.cpp: new module
- src/mesh/NodeDB.cpp: wire up samplePacketForHistogram
- src/mesh/Router.cpp: consume getLastRequiredHop()
- test/test_hop_scaling/: 12-test suite covering all mesh topologies and
  anticipated operational requirements

* test: increase run iterations in sparse to dense transition test

* feat: refactor HopScalingModule to use RUNS_PER_HOUR constant and improve logging

* feat: enhance HopScalingModule with filtering denominator management and add tests for state transitions

* refactor: remove CompactHistogram module and related files

* address copilot review comments

* Tweak: packet sampling only lora

* ove role-based hop floor logic and related definitions into the module - keep it in one place.

* Refactor MockNodeDB to use nodeInfoLiteSetBit for MQTT flag setting

* Refactor hop scaling parameters and logic to integer maths and put default values in defaults.h. Small flash size reduction, no functional impact.

* Update unit test preprocessor directives to PIO_UNIT_TESTING for consistency

* refactor: improve test output organization and clarity in hop scaling tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-04 05:59:49 -05:00
AustinandGitHub ef51c7ec11 Add 2 Meter (~144mhz) Amateur Radio Regions (#10623)
Default slots:
ITU1_2M: Slot 26 (144.510 MHz)
ITU2_2M: Slot 51 (145.010 MHz)
ITU3_2M: Slot 33 (144.650 MHz)
2026-06-03 18:05:03 -04:00
AustinandGitHub 3e873c51b7 Add TinyFast and TinySlow presets to modem configuration and menu actions (#10597) 2026-06-03 16:42:39 +01:00
Thomas GöttgensandGitHub f86cb7781e Remove fragile JSON libraries from the firmware while retaining Meshtasticd JSON support (#10152) 2026-06-03 16:47:30 +02:00
TomandGitHub fe23dcfa3a Merge pull request #10619 from NomDeTom/test_fixes
Some fixes and tidies for testing both online and in unit_tests
2026-06-03 13:43:54 +01:00
nomdetom 2d6f2ba1a4 docs: enhance README with verbose test output instructions and usage examples 2026-06-03 11:51:29 +01:00
nomdetom 5d55353939 Some fixes and tidies for testing both online and in unit_tests 2026-06-03 02:47:23 +01:00
AustinandGitHub c3a46d4d85 Update protobufs (#10614) 2026-06-02 19:25:35 -04:00
oscgonferandGitHub 0e0e17928b Remove reocurring pio deps for SHT sensors (#10601) 2026-06-02 06:23:23 -04:00
a522973fe7 fix(t5s3-epaper): ED047TC1 display margins, remove calibration diagnostic, fix touch threshold (#10304)
* fix(t5s3-epaper): increase ED047TC1 margins to 16px on all sides

Tested on device — 16px uniform margins look noticeably cleaner than
the previous asymmetric 8–9px values. Canvas shrinks from 944×523 to
928×508 (H_OFFSET_BYTES=2, V_OFFSET_TOP/BOTTOM=16).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(t5s3-epaper): remove EINK_EDGE_LINES calibration diagnostic from ED047TC1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(t5s3-epaper): align TOUCH_THRESHOLD_X with TOUCH_THRESHOLD_Y (40px)

X axis threshold was 60 while Y was 40, causing asymmetric swipe detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
2026-06-01 21:29:51 -04:00
AustinGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
cbfa8d7ffd Add low bandwidth conversions to MeshRadio (#10595)
* Add low bandwidth conversions to MeshRadio

* Add test cases for new bandwidth conversion values (8/10/16/21/42) and round-trip tests

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-01 18:24:41 -04:00
AustinandGitHub 3feb155a5f Fix null pointer dereference in updateBatteryLevel function (#10588) 2026-05-31 14:46:28 -04:00
Chloe BethelandGitHub 64e35908c9 Add Xiao RP2040 and RP2350 variants (#10109) 2026-05-31 12:51:15 -04:00
Ben Meadors 8a1d6d9285 Add stable uid generation for PLI entities in allocAtakPli function 2026-05-31 12:26:00 -04:00
TomandGitHub ee441dd7b2 Merge pull request #10560 from meshtastic/migrate-overrideslot
Move overrideSlot from RegionProfile to RegionInfo (override per-region)
2026-05-30 22:04:37 +01:00
vidplace7andCopilot 178ae0a7f1 Move overrideSlot from RegionProfile to RegionInfo (override per-region)
Move default frequency (slot) override from RegionProfile to RegionInfo (set per-region).

This is usually set to `0`, but will be especially useful for Ham modes where each region default must fit within a band plan.

Co-authored-by: Copilot <copilot@github.com>
2026-05-30 16:20:17 -04:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
a08872299e Fix thinknode_m7 esp32s3 CI build failure from missing esp_eth_driver.h (#10585)
* Initial plan

* Add ESP32 ethernet header compatibility shim

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-30 11:26:02 -05:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
cc9d433db0 Fix mini-epaper-s3 build: resolve SensorLib isBitSet macro conflict with SparkFun MMC5983MA (#10584)
* Initial plan

* Fix SensorLib isBitSet macro conflict with SparkFun MMC5983MA library

SensorLib 0.3.4 defines isBitSet as a C preprocessor macro in SensorLib.h,
which conflicts with SparkFun_MMC5983MA_IO.h's class method of the same name.
When both libraries are included in the same translation unit (e.g., via
configuration.h → SensorRtcHelper.hpp → SensorLib chain, alongside the
SparkFun MMC5983MA library in lib_deps), the macro expansion causes compile
errors like 'expected unqualified-id before const'.

Fix: undefine the isBitSet macro right after including SensorRtcHelper.hpp
in configuration.h, so it doesn't interfere with SparkFun's class method.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-30 08:59:26 -05:00
60303968bb Add Heltec mesh node t1 (#10416)
* add heltec-mesh-node-t1

* fixed low power

* Update the sensor enumeration values.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Fix memory leak in ICM42607PSensor

* fix  ST7735_MISO  error

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-29 12:47:09 -05:00
Catalin PatuleaandGitHub d3c7f05baa Simplify tracking of BLE connection handle & improve thread safety. (#10390)
- Redefine isConnected in terms of nimbleBluetoothConnHandle. isConnected was not safe because BLEServer::getConnectedCount is not thread-safe (https://github.com/espressif/arduino-esp32/issues/12538) while isConnected is called from various threads. Now we can avoid checking bleServer every time before calling isConnected.
- getRssi: don't try to "populate nimbleBluetoothConnHandle", that requires calling BLEServer::getPeerDevices which is not thread-safe (same issue as above).
2026-05-29 06:48:43 -05:00
Ben Meadors 1971e5ab13 Remove now unused payload variant settings in allocAtakPli function 2026-05-29 06:15:11 -05:00
Ben Meadors e2fda6598c Update protobufs 2026-05-28 21:01:30 -05:00
982440d21d Noise floor (#9347)
* add noise floor

* Sliding window noise floor

* Add getCurrentRSSI() to SimRadio for noise floor support

* Remove sendLocalStatsToPhone call from runOnce

* Change noise floor to int32_t type

* Use int32_t for RSSI sample storage in noise floor

* Remove float cast from noise floor assignment

* Fix Copilot review issues: fix noise floor logic, types, and null pointer

- Use robust busyTx/busyRx checks instead of simple isReceiving check
- Initialize noiseFloorSamples to NOISE_FLOOR_MIN instead of 0
- Move noise_floor assignment inside null check to prevent potential crash
- Change getNoiseFloor() and getAverageNoiseFloor() to return int32_t
- Fix RSSI validation to check for positive values (rssi > 0)
- Fix format specifier from %.1f to %d for int32_t
- Update comments to accurately reflect the sampling logic

* Fix RSSI condition to include zero value

* Change noise floor initialization to zero

* Disable noise floor for LR11x0 chips: getRSSI(bool) unsupported

* Remove updateNoiseFloor call from onNotify to avoid radio queue overflow

Per PR review feedback, calling updateNoiseFloor() in onNotify() for every
ISR event (ISR_TX, ISR_RX, TRANSMIT_DELAY_COMPLETED) can cause the LoRa
radio queue to get full. The noise floor sampling still happens in
startReceive() and after transmitting.

* fix lr11x0 current rssi

* Address noise floor review comments

* Address Copilot SimRadio noise floor comments

* Fix RadioLibInterface formatting

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-05-28 19:39:41 -05:00
Ben Meadors f9fea562aa Add TAKTALK message and room data structures to support voice/text chat 2026-05-27 09:05:38 -05:00
Ben Meadors c366296ab4 Update LoRaConfig region codes and add new amateur radio bands 2026-05-26 18:56:16 -05:00
Ben MeadorsGitHubCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
a076d97eb6 Implement ATAK Plugin V2 and drop unishox2 compression support (#10105)
* Implement ATAK Plugin V2 and drop unishox2 compression support

* Fix course calculation and improve error handling in allocAtakPli

* Update src/modules/AtakPluginModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix buffer overflow in allocAtakPli by ensuring null termination for callsigns

* Add missing include for concurrency::OSThread in AtakPluginModule.h

* Update src/modules/AtakPluginModule.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor allocAtakPli to use configurable team and role; improve position source mapping

* Update src/modules/PositionModule.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove OSThread inheritance from AtakPluginModule - pure passthrough needs no periodic scheduling

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/bdc82eb6-77c4-4711-839c-04bcbb1aa9cd

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Apply clang-format (trunk fmt)

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-26 15:16:47 -05:00
Clive BlackledgeandGitHub 32dcd90abf Preserve forwarded position payload precision (#10554) 2026-05-26 06:33:11 -05:00
5b7a5b2c22 feat: add Raspberry Pi Pico 2 + W5500 + E22-900M30S variant (#10135)
* feat: add Raspberry Pi Pico 2 + W5500 + E22-900M30S variant

Adds community variant for Raspberry Pi Pico 2 (RP2350, 4 MB flash)
with external WIZnet W5500 Ethernet module and EBYTE E22-900M30S LoRa
module (SX1262, 30 dBm PA, 868/915 MHz).

Key details:
- LoRa on SPI1: GP10/11/12/13 (SCK/MOSI/MISO/CS), RST=GP15,
  DIO1=GP14, BUSY=GP2, RXEN=GP3 (held HIGH via SX126X_ANT_SW)
- W5500 on SPI0: GP16/17/18/19/20 (MISO/CS/SCK/MOSI/RST)
- SX126X_DIO2_AS_RF_SWITCH: DIO2→TXEN bridge on module handles PA
- SX126X_DIO3_TCXO_VOLTAGE 1.8: TCXO support via EBYTE_E22 flags
- DHCP timeout reduced to 10 s to avoid blocking LoRa startup
- GPS on UART1/Serial2: GP8 TX, GP9 RX
- Reuses WIZNET_5500_EVB_PICO2 code paths for Ethernet init

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* pico2_w5500_e22: rename define and address review feedback

Rename WIZNET_5500_EVB_PICO2 to PICO2_W5500_E22 so the variant-specific
define matches the variant directory name and isn't confused with an
on-board EVB SKU.

Review fixes from PR #10135:
- Gate the 10 s Ethernet DHCP timeout behind PICO2_W5500_E22 so other
  Ethernet builds keep the default 60 s behavior; apply the same timeout
  to reconnectETH() for consistency.
- Drop the unused -D EBYTE_E22 flag; EBYTE_E22_900M30S already selects
  TX_GAIN_LORA / SX126X_MAX_POWER in src/configuration.h.
- Rewrite "on-board W5500" comments to describe the external module.
- Correct README TX_GAIN_LORA value (7, not 10) and drop the EBYTE_E22
  row.

* fix(pico2_w5500_e22): drop DEBUG_RP2040_PORT=Serial

The arduino-pico framework hooks _write() when DEBUG_RP2040_PORT=Serial
is set and dumps raw debug bytes onto USB CDC, corrupting any binary
protobuf stream sent through StreamAPI (e.g. `meshtastic --port COMx`).

The variant excludes BT and WiFi, so the primary client transport is
Ethernet TCP via ethServerAPI — unaffected — but users who configure
the node over USB serial would see protobuf decode failures from
debug-byte interleaving. Removing the flag restores clean USB CDC.

Debug output can still be enabled per-build by adding -D DEBUG_RP2040_PORT=Serial1
to redirect to UART0 instead of USB CDC.

* style(pico2_w5500_e22): apply trunk fmt — fixes Trunk Check

- variant.h: clang-format 16.0.3 (drop manual #define alignment)
- README.md: prettier + add `text` language to fenced code blocks (markdownlint MD040)
- wiring.svg: svgo optimization

Resolves the Trunk Check Runner failure on this PR (3 unformatted
files + 8 markdownlint issues). No functional changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(pico2_w5500_e22): address review — move to rp2350/diy, generic guards

Maintainer feedback from NomDeTom on PR #10135:

- Move the variant from variants/rp2350/ to variants/rp2350/diy/ to
  distinguish DIY from prebuilt boards (matches the variants/*/diy/
  pattern; still discovered via the existing variants/*/diy/*/platformio.ini
  glob in the root platformio.ini).
- Replace the board-name macro PICO2_W5500_E22 in shared code with a
  generic capability macro USE_ARDUINO_ETHERNET, defined from variant.h.
  DebugConfiguration.h / ethServerAPI.h / ethClient.cpp no longer
  reference a board name.
- Drop the architecture.h hook entirely: variant.h now defines PRIVATE_HW,
  which the existing `#elif defined(PRIVATE_HW)` branch already handles.

No functional change. Build verified: pico2_w5500_e22 SUCCESS
(RAM 19.2%, Flash 28.1%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(pico2_w5500_e22): drop unoptimized wiring.svg to fix trunk fmt check

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-25 14:05:46 -05:00
Alexander BalyaandGitHub 5bce26d9b7 Fix SHT2x detection for INA219 addresses (#10482) 2026-05-24 21:03:24 -05:00
ManuelandGitHub ef734b73c7 fix: mbed TLS crash in Arduino 3.x (pioarduino) (#10535)
* fix mbed TLS crash

* adapt SSL_MAX_CONTENT_LEN size to framework-libs

* found two more
2026-05-22 20:14:42 -05:00
Ben Meadors c7748a1602 Fix update neighbor_info before checking update_interval in handleSetModuleConfig 2026-05-22 14:53:52 -05:00
Jaime RoldanandGitHub 91f930d5c0 fix(telemetry): stop emitting -0.001V sentinel when battery unavailable (#7958) (#10217)
* fix(telemetry): stop emitting -0.001V sentinel when battery unavailable (#7958)

* address review: use int32_t for batteryMv to avoid uint16_t signed wrap
2026-05-22 06:58:21 -05:00
κρμγandGitHub f2c5cb0a05 fix: first set pinMode, then write to pin (#10520) 2026-05-21 13:30:27 -05:00
e10e13226d nrf54l15: fix SHT4x libdep -- arduino-sht, not Adafruit_SHT4X (#10515)
SHTXXSensor (the SHT4x driver) includes <SHTSensor.h>, gated by
__has_include(<SHTSensor.h>). That header ships in Sensirion/arduino-sht.
Adafruit_SHT4X ships Adafruit_SHT4X.h and has no consumer anywhere in
src/, so the SHT40 driver was silently excluded from the build -- the
nRF54L15 variant could not read an SHT4x sensor as committed in #10193.

Replace the dead Adafruit_SHT4X libdep with arduino-sht v1.2.6.
Validated on nRF54L15-DK: SHT40-AD1B @0x44, 24.3h soak, 0 reboots,
temperature/humidity telemetry stable end-to-end.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-21 11:00:10 -05:00
5e69bc6c3f Enable Narrow and Lite regions for EU (#10120)
* Enable Lite and Narrow regions and introduce getEffectiveDutyCycle for Lite profiles

* Add TrafficType enum and extend getConfiguredOrDefaultMsScaled to manage based on regionProfile settings

* Refactor telemetry modules to include TrafficType in getConfiguredOrDefaultMsScaled calls

* Update submodule protobufs to latest commit

* Add support for new region presets and modem presets in menu options

* Add new LoRa region codes and modem presets for EU bands

* boof

* Add modem presets for LITE and NARROW configurations

* Update subproject commit reference in protobufs

* Update protobufs

* Refactor modem preset definitions to use macro for consistency and clarity

* Refactor modem preset cases to use PRESET macro for consistency

* fix: update LoRa region code for EU 868 narrowband configuration

Co-authored-by: Copilot <copilot@github.com>

* Fix test suite failure

Co-authored-by: Copilot <copilot@github.com>

* Add override slot override - for when one override isn't enough.

Co-authored-by: Copilot <copilot@github.com>

* address copilot comments

---------

Co-authored-by: Copilot <copilot@github.com>
2026-05-21 10:20:09 -05:00
f3cb2bff78 Refactor keyboard cell height logic for consistency (#10501)
Adjust keyboard cell height calculation for better layout consistency across different screen sizes.

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-21 06:12:34 -05:00
AustinandGitHub 894c5556cf Actions: Fix tagging upon release. (#10521)
Current release tags are actually based upon the latest state of `develop` currently...
Specify target_commitish to always use the commit that triggered the build
2026-05-21 06:09:57 -05:00
Ben MeadorsandGitHub 2f92eb8499 Refactor position precision handling to honor explicit channel settings and prevent location leaks (#10513) 2026-05-20 10:18:46 -05:00
Ben Meadors 8d08077412 Add Ethernet configuration to platformio.ini for ThinkNode variants 2026-05-20 07:12:43 -05:00
Ben Meadors 00ec69201d Develop protos should be on develop 2026-05-19 06:57:05 -05:00
Ben Meadors 4304480ca3 Merge remote-tracking branch 'origin/master' into develop 2026-05-19 05:37:01 -05:00
Thomas GöttgensandGitHub 1747e2d8e5 T-Echo-Card support (#10267) 2026-05-19 09:31:04 +02:00
622aa046f1 Enabled SX_LNA_EN by default (#10469)
* Enabled SX_LNA_EN by default
* Update I2C configuration for IO direction and pull settings

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-05-19 08:31:42 +02:00
Thomas GöttgensandGitHub 98e0604edf Fix antenna switch initialization logic once more 2026-05-19 07:58:24 +02:00
23ead5f2f1 Update protobufs (#10500)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-05-19 07:47:06 +02:00
Thomas GöttgensandGitHub 3261c04afb Fix Antenna Switch on Cardputer (#10491) 2026-05-18 11:18:40 +02:00
AustinGitHubmverch67ManuelCatalin Patuleacopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>vidplace7CopilotthebenternBen Meadors
a541957480 ESP32: Migrate to Arduino 3.x (pioarduino) (#9122)
* Migrate esp32 families to pioarduino platform

* ESP32c6 align text.handler_execute same as C3

* Use pioarduino `develop`

The latest fixes and the latest bugs!

* preliminary esp32p4.ini

* pioarduino: Update LovyanGFX

Includes Manuel's recent commit

* pioarduino 3.3.6

* pioarduino 3.3.6 *release*

chasing the release

* pioarduino: Fix OG ESP32 duplicate libs

* pioarduino: T-Beam 1W CDC mode

* pioarduino: disable network provisioning (wifiprov)

* pioarduino: use legacy esptoolpy naming (forward-compatible)

* Update lovyangfx from `develop` commit to 1.2.19

* fix esp32p4.ini

* check for esp32 w/ wifi

* esp32-p4 specific adaptations

* Switch to meshtastic/esp32_https_server fork (idf5 branch)

* don't ignore esp_lcd

* config for MUI

* fix/workaround SDMMC

* revert a6f6175, update to 3.3.8

* enable esp_hosted for esp32-p4 (experimental)

* Pioarduino 55.03.38-1

* NimBLE-Arduino -> Arduino "BLE" (3.3.x) migration (#10164)

* NimBLE-Arduino -> Arduino "BLE" (3.3.x) migration

* More NimBLE

* Fix Device Name in ATT Read Request (0x2A00).

Device Name is exposed in two places:

- Advertisement data: this is set properly in startAdvertising.
- GATT attribute Device Name (0x2A00). This one is handled internally in NimBLE
  and comes from ble_svc_gap_device_name_set. This is set initially, but then
  BLEDevice::createServer calls ble_svc_gap_init which resets the device name.
  This causes the device to apparently "change name after pairing":

< ACL Data TX:... flags 0x00 dlen 7  #113 [hci0] 14.241149
      ATT: Read Request (0x0a) len 2
        Handle: 0x0003 Type: Device Name (0x2a00)
> ACL Data RX: Handle 2048 flags 0x02 dlen 11             #115 [hci0] 14.269050
      ATT: Read Response (0x0b) len 6
        Value[6]: 6e696d626c65   # "nimble"

Workaround this by setting the device name once again after
BLEDevice::createServer.

* Temporarily lower CORE_DEBUG_LEVEL to INFO to avoid triggering an apparent ESP-IDF Bluetooth bug when re-connecting to Pixel 8 Android devices.

Initial pairing works, but after ESP32 is rebooted, phone fails to reconnect. Meshtastic app shows it as disconnecting immediately. LightBlue shows a more detailed error "Peripheral Connection - Warning: onConnectionStatusChange: status 61" (0x3D - MIC Failure).

Bug report to Espresssif: https://github.com/espressif/esp-idf/issues/18126#issuecomment-4286197744

* Temporarily disable ble_gap_set_data_len, causes crash with Pixel 8 Android reconnect.

Crash looks like this:
  [ 11966][E][BLEAdvertising.cpp:341] setScanResponseData(): ble_gap_adv_rsp_set_data: 22
  [ 11975][E][BLEAdvertising.cpp:1554] start(): Host reset, wait for sync.
  ERROR | ??:??:?? 11 BLE failed to start advertising
  Guru Meditation Error: Core  0 panic'ed (LoadProhibited). Exception was unhandled.

  Core  0 register dump:
  PC      : 0x420e6190  PS      : 0x00060730  A0      : 0x820e158b  A1      : 0x3fce50c0
  A2      : 0x00000000  A3      : 0x3fcb8600  A4      : 0x3fcb85cc  A5      : 0x00000000
  A6      : 0x00000000  A7      : 0x00000c03  A8      : 0x00000000  A9      : 0x3fce50b0
  A10     : 0x0000000e  A11     : 0x00000000  A12     : 0x00000010  A13     : 0x3fce50e0
  A14     : 0x00000c03  A15     : 0x00000001  SAR     : 0x0000001e  EXCCAUSE: 0x0000001c
  EXCVADDR: 0x00000000  LBEG    : 0x400570e8  LEND    : 0x400570f3  LCOUNT  : 0x00000000

  Backtrace: 0x420e618d:0x3fce50c0 0x420e1588:0x3fce5110 0x420dfe87:0x3fce5200 0x420dfefb:0x3fce5220 0x420dff3f:0x3fce5240 0x4219602b:0x3fce5260 0x4037b0e5:0x3fce5280 0x4201edf3:0x3fce52a0

Connection seems fast enough even without this. We'll investigate the
reason for the crash and re-enable once it's safe.

---------

Co-authored-by: Catalin Patulea <cronos586@gmail.com>

* Add extension from pioarduino nag
"Jason2866.esp-decoder"

* Cleanup after merge

* ESP32: Disable classic bluetooth

* Cleanup: Fix ADC channels on new variants

* InkHUD: Fix type casting for message size in saveToFlash method

inkhud compiles again!

* update p4 esp_hosted for BT

* I thought I fixed this

* fix linker error using response file (p4 only)

* fix infinite loop

* Fix Power.cpp check warning

Local variable 'config' shadows outer variable [shadowVariable]

* Build ESP32 original with NimBLE ('custom_sdkconfig' approach). (#10235)

* Re-enable littlefs json manifest

This works locally again :)
Not sure what changed

* Re-add tool-mklittlefs

* sensecap indicator fixes after upgrade arduino-esp & lovyanGFX libs

* hackaday fix

* robot tbeam cache error fix

Co-authored-by: Copilot <copilot@github.com>

* trunk fmt

* ignore trunk

* BLEDevice::deinit() added

Co-authored-by: Copilot <copilot@github.com>

* platformio-custom: Modify mtjson target dependency to prevent fake-success. (#10291)

Co-authored-by: Copilot <copilot@github.com>

* Fix ESP32-C6 linker errors.

Align .text.handler_execute section to 4 bytes and update watchdog timer core mask configuration

Co-authored-by: Copilot <copilot@github.com>

* tlora-c6: Disable Screen

MESHTASTIC_EXCLUDE_SCREEN=1 on tlora-c6.

It doesn't have a screen, and this gets it compiling again (saving flash).

* Use mverch's iram_memset hack for all OG-ESP32

* Refactor watchdog timer initialization and handling

* use adc_channel_t in variant.h

* Fix variant headers

* More idiomatic default ethernet that doesn't break the build

* Elecrows: Delete problematic variant.cpp

Not needed after USE_ETHERNET_DEFAULT

---------

Co-authored-by: mverch67 <manuel.verch@gmx.de>
Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
Co-authored-by: Catalin Patulea <cronos586@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-17 18:53:32 -05:00
Jonathan BennettandGitHub 767a748188 add optional LED_LORA to indicate LoRa TX (#10465) 2026-05-16 22:11:49 -05:00
4a1ff18f57 feat: add Nordic nRF54L15-DK variant (Zephyr + BLE + LoRa) (#10193)
* feat: add Nordic nRF54L15-DK variant (Zephyr + BLE + LoRa)

Adds a community hardware variant for the Nordic nRF54L15-DK (PCA10156)
with an external EBYTE E22-900M30S (SX1262) LoRa module. First Meshtastic
port running on the Zephyr RTOS; all other Nordic targets use the nRF5
SoftDevice stack.

Scope
-----
- New Zephyr-based platform layer under src/platform/nrf54l15/ providing
  Arduino-compatible shims (Arduino.h, SPI, Wire, Print, Stream) over the
  Zephyr APIs plus a LittleFS-backed InternalFileSystem on SPIM20.
- Bluetooth LE peripheral (NRF54L15Bluetooth.*) built on the Zephyr BT
  host stack, exposing the Meshtastic GATT service with legacy
  connectable advertising, just-works pairing, dynamic MTU exchange
  (up to 247 bytes), and iOS connection-parameter tweaks.
- Variant directory variants/nrf54l15/nrf54l15dk/ with pin map for the
  E22 module on connector J1, PlatformIO env (nrf54l15dk), Zephyr
  DT overlay and a wiring README.
- Zephyr project config (zephyr/prj.conf + board overlay) tuned for
  BT + LoRa: 16 KB main stack, 4 KB BT RX thread, RTT logging in
  immediate mode, newlib-nano heap sized to leave room for the GATT
  pools while still allowing ATT MTU=247.
- extra_scripts/nrf54l15_linker.py works around a PlatformIO + old Ninja
  issue where Zephyr's two-pass linker script generation does not run
  automatically; the post-script parses build.ninja and invokes the
  gcc -E step directly before the final link.
- boards/nrf54l15dk.json board definition (PlatformIO needs it for the
  DK; the Seeed platform only ships the XIAO variants).
- variants/rp2350/rp2350.ini excludes platform/nrf54l15/ from RP2350
  build_src_filter so the shared platform tree does not leak between
  targets.
- .gitignore: add nRF J-Link / RTT debug artifacts (flash.jlink,
  rtt_*.txt).

Shared source changes
---------------------
- src/main.{cpp,h}, src/RedirectablePrint.cpp, src/FSCommon.{cpp,h},
  src/mesh/{Channels,NodeDB,RadioLibInterface,MeshService,PhoneAPI}.cpp,
  src/mesh/RadioLibInterface.h, src/modules/AdminModule.cpp: add small
  guards / helpers so the Zephyr build compiles alongside the Arduino
  targets. Behavior on existing boards is unchanged.

Hardware model
--------------
HW_VENDOR maps to meshtastic_HardwareModel_PRIVATE_HW until a dedicated
protobuf enum value is assigned upstream. The variant declares
custom_meshtastic_hw_model = 132 so the maintainers can wire the new
enum value through the protobufs repo after merge.

Hardware note
-------------
The E22-900M30S does not connect its DIO2 pin to TXEN internally — a
wire/solder bridge between DIO2 and TXEN on the module is required for
TX to work. Details and full pin map are in the variant README.

Validation
----------
Built clean against develop. On real hardware (April 2026) the port
passes end-to-end: iOS companion app pairs and connects, configuration
round-trip works, LoRa TX/RX reaches a canonical tbeam on the same mesh
channel, NodeDB updates propagate both ways, and traceroute completes.

* fix(nrf54l15): use atomic fs_rename instead of copy fallback

Zephyr LittleFS on nrf54l15 supports fs_rename natively, so route it
through the same atomic path as ESP32. The previous copyFile+remove
fallback truncated the destination before copying, leaving 0-byte files
if interrupted mid-write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nrf54l15): expand storage_partition from 36KB to 700KB

LittleFS on the default 9-block (36KB) storage_partition ran out of
space during copy-on-write of config.proto, causing fs_write to return
ENOSPC and pb_encode to surface "io error" when saving configuration
via the mobile app.

Reclaim slot1_partition (the MCUboot secondary slot — unused since we
flash directly via J-Link) and grow storage_partition to span
0xb6000..0x165000 (~175 blocks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nrf54l15): drop USERPREFS_LORACONFIG_* so LoRa config stays mutable

NodeDB rewrites LoRa config from USERPREFS_LORACONFIG_* on every boot,
which prevented reconfiguration via the BLE/serial app. Drop the
variant-level defaults; users configure region and modem preset through
the app like every other variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nrf54l15): enforce MITM passkey pairing on GATT service

- Add MESH_PERM_READ/MESH_PERM_WRITE macros (READ_AUTHEN/WRITE_AUTHEN)
  on all mesh service characteristics so clients must complete passkey
  exchange before accessing fromNum/fromRadio/toRadio/logRadio.
- Wire FIXED_PIN mode to bt_passkey_set() so the device advertises a
  known PIN (config.bluetooth.fixed_pin); RANDOM_PIN keeps default
  per-pairing random passkey.
- Reduce BleDeferredThread HARD_WATCHDOG_MS from 3min to 1min.
- prj.conf: CONFIG_BT_SMP_ENFORCE_MITM=y, CONFIG_BT_FIXED_PASSKEY=y,
  CONFIG_BT_SMP_SC_PAIR_ONLY=n (legacy fallback for clients that abort
  SC pairing with reason 0x01 within 150ms).

* fix(nrf54l15): resolve develop-merge conflict + cppcheck warnings

The `Merge branch 'develop'` left two ~RadioLibInterface() declarations
in src/mesh/RadioLibInterface.h: the inline version added upstream by
PR #10254 (which independently applied the same UAF guard this PR was
carrying) and the out-of-line version this PR introduced. GCC rejects
the duplicate, breaking every platform build. Drop the out-of-line
declaration + definition; keep upstream's inline form.

Also silence the 13 cppcheck low warnings introduced by the new
nrf54l15 Arduino shim — Arduino's `String`/`SPISettings` API contract
relies on implicit single-arg constructors used pervasively by
existing Meshtastic code, so suppress `noExplicitConstructor` inline
with a comment instead of breaking the API. The few mechanical wins
(`const tmp[2]`, `const uint32_t *sp`) are applied directly.

* fmt: fix Trunk Check lint issues on nrf54l15-port

- extra_scripts/nrf54l15_linker.py: move regular imports above
  Import("env") to silence E402, add trunk-ignore-all(F821) for the
  PIO/SCons SConstruct injection (matches esp32_pre.py / nrf52_extra.py
  convention)
- src/platform/nrf54l15/NRF54L15Bluetooth.cpp: clang-format 16.0.3
- boards/nrf54l15dk.json + variants/nrf54l15/nrf54l15dk/README.md:
  prettier 3.8.3 (also resolves markdownlint MD060 on README tables)

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nrf54l15): address Copilot review comments + correct clang-format style

Six review threads from the 2026-04-30 Copilot review:

- src/platform/nrf54l15/nrf54l15_main.cpp: validate PSP against the nRF54L15
  SRAM range (0x20000000..0x20040000) and 4-byte alignment before walking the
  faulting thread's stack, and clamp the walk so it never reads past the end
  of RAM. Prevents a second fault inside the fatal handler when PSP is
  corrupted (common in real faults).

- src/platform/nrf54l15/nrf54l15_arduino.cpp: gate the bring-up printk traces
  in digitalWrite/digitalRead (CS/NRESET toggle log, BUSY-before-NRESET
  snapshot, BUSY periodic timeline) behind a new -DNRF54L15_GPIO_DEBUG flag
  that is off by default. The "dev NOT READY" message stays unconditional —
  it indicates a genuine hardware/DTS misconfig.

- src/modules/AdminModule.cpp: don't mutate config.device.output_gpio_enabled
  from handleGetConfig(). Reflect the live pin state in the response payload
  only — a getter must not write back to disk-persisted state.

- src/platform/nrf54l15/InternalFileSystem.h: derive totalBytes() from
  FIXED_PARTITION_SIZE(storage_partition) at compile time so it tracks the
  DK overlay's ~700 KB partition instead of the stale 36 KB hard-coded value.
  Updated the file header comment accordingly.

- extra_scripts/nrf54l15_linker.py: make _extract_gcc_command() handle the
  POSIX Ninja COMMAND format (no `cmd.exe /C "..."` wrapper) in addition to
  the Windows form, so the script doesn't hard-fail on Linux/macOS hosts.

- src/platform/nrf54l15/NRF54L15Bluetooth.cpp: clamp NO_PIN to RANDOM_PIN
  with a one-shot LOG_WARN. The mesh GATT permissions are declared with
  BT_GATT_PERM_*_AUTHEN and prj.conf sets CONFIG_BT_SMP_ENFORCE_MITM=y, so
  NO_PIN with no auth callbacks would leave every characteristic returning
  BT_ATT_ERR_AUTHENTICATION. Falling back to RANDOM_PIN keeps the link
  usable instead of silently broken. Also re-formatted this file with the
  project's .trunk/configs/.clang-format (Linux braces, 4-space indent,
  130-col) — the previous lint-fix commit a2aca3234 accidentally used the
  default LLVM style here, which CI's clang-format would have rejected.

Build verified: pio run -e nrf54l15dk passes, RAM 47.4%, Flash 28.6%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nrf54l15): address remaining Copilot review threads

Round 2/3 review fixes — bugs first, then docs/portability:

BLE concurrency (NRF54L15Bluetooth.cpp):
- onNowHasData / sendLog / BleDeferredThread / shutdown: acquire
  active_conn under ble_mutex via new acquire_active_conn() helper so
  disconnected_cb can't free the conn between the null check and
  bt_conn_ref/bt_gatt_notify (use-after-unref).
- write_toradio: reject writes that exceed MAX_TO_FROM_RADIO_SIZE with
  ATT_ERR_INVALID_ATTRIBUTE_LEN instead of returning success and silently
  dropping the payload (would hide failed config writes from the phone).
- start_advertising: truncate the device name to fit the 31-byte legacy
  scan-response limit and switch to BT_DATA_NAME_SHORTENED so
  bt_le_adv_start() doesn't reject the payload when the name approaches
  CONFIG_BT_DEVICE_NAME_MAX=32.

Linker / portability:
- main.h: drop the rp2040Loop() forward declaration that had no
  definition and no callers — would surface as a link error if any RP2040
  build added a call to the symbol.
- nrf54l15_arduino.cpp: transfer16() now uses static __aligned(4) DMA
  buffers (matching transfer()), removing the EasyDMA-reachability hazard
  of caller-stack buffers on this part.

Filesystem (InternalFileSystem.h):
- usedBytes(): return real usage from fs_statvfs() instead of 0 so OTA
  / range-test free-space guards work.
- rewindDirectory(): close the dir before reopening — Zephyr fs_dir_t has
  no rewind, and re-fs_opendir on an open handle leaks LittleFS state.

Crash handler (nrf54l15_main.cpp):
- After the stack walk, busy-wait 50 ms to flush RTT/printk and call
  sys_reboot(SYS_REBOOT_COLD) directly so the saved_crash record is
  actually reported on the next boot. Default Zephyr config has
  RESET_ON_FATAL_ERROR=n, so the previous k_fatal_halt() spun forever.

Generalization / config:
- PhoneAPI.cpp: replace the NRF54L15_DK ifdef with a
  MESHTASTIC_EXCLUDE_FILES_MANIFEST capability flag (defined in the
  nrf54l15dk env) so future variants can opt in/out without touching
  shared code.
- variants/nrf54l15/nrf54l15.ini: parameterize libdeps include paths via
  ${PIOENV} so additional nRF54L15 envs sharing nrf54l15_base don't break.
- prj.conf: drop the stale "36 KB storage_partition" comments — the DK
  overlay reclaims slot1 to ~700 KB and runtime size comes from
  FIXED_PARTITION_SIZE.
- nrf54l15dk overlay: remove the zephyr,console / zephyr,shell-uart
  chosen entries that conflicted with CONFIG_UART_CONSOLE=n + RTT
  console; keep uart30 enabled so swapping the console is one Kconfig
  flip away.

Build: nrf54l15dk SUCCESS (flash 28.6%, RAM 47.4%); wiznet_5500_evb_pico2
SUCCESS (verifies the shared main.h change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nrf54l15dk): use PRIVATE_HW (255) for custom_meshtastic_hw_model

Per @thebentern's review: the nRF54L15-DK is a development kit, not a
canonical Meshtastic SKU, so it falls under HardwareModel::PRIVATE_HW
(255) — the same enum value already used at runtime via HW_VENDOR. The
placeholder 132 is removed; no dedicated enum number will be assigned
for DK boards. Slug stays NRF54L15_DK as a human-readable identifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nrf54l15): unstarve bt_long_wq so SC pairing completes

bt_pub_key_gen() runs the ECC P256 key generation on bt_long_wq.  At default
prio=10 (preemptible) and stack=1400 it gets starved by Meshtastic app
threads at boot — sc_public_key stays NULL for minutes, smp_public_key()
defers with SMP_FLAG_PKEY_SEND, and every SC pairing attempt stalls right
after the public-key exchange.  iOS shows "Connecting…" forever with no
PIN prompt; bleak/CLI fails the first CCC notify write with
"Protocol Error 0x05: Insufficient Authentication".

Set CONFIG_BT_LONG_WQ_PRIO=0 (highest preemptible, ties with main) and
CONFIG_BT_LONG_WQ_STACK_SIZE=4096 (margin for the P256M driver frames).

Validated E2E with iOS Meshtastic app: bt_smp_pkey_ready fires within ~40 s
of boot, 20 SC Passkey Entry rounds complete with matching pcnf/cfm,
encrypt 0x01 / sec_level 0x04 (Authenticated MITM), bonded=1.

* feat(nrf54l15): hardware I2C bus via TWIM30 + sensor telemetry

Adds the Arduino TwoWire layer for the nRF54L15-DK so Meshtastic's
sensor drivers can talk to external I2C devices over the hardware
TWIM30 peripheral.

Bus binding:
- &uart30 disabled in the board overlay (peripheral instance 30 is
  shared between UARTE30 / TWIM30 / SPIM30 — pick one). Console stays
  on RTT via CONFIG_RTT_CONSOLE.
- New i2c30_default / i2c30_sleep pinctrl with SDA=P0.03 / SCL=P0.04.
  External 4.7 kOhm pull-ups required on both lines.
- &i2c30 enabled at I2C_BITRATE_FAST (400 kHz).
- button_3 (SW3, P0.04) deleted from DTS so the pad can be claimed by
  i2c30 pinctrl; SW3 is still wired to the pad on the DK, do not press
  it during I2C use or it will short SCL to GND.

Arduino layer:
- src/platform/nrf54l15/Wire.cpp resolves the DT node at compile time
  via DEVICE_DT_GET(DT_NODELABEL(i2c30)) and dispatches Arduino's
  beginTransmission / write / endTransmission / requestFrom to
  i2c_write / i2c_write_read / i2c_read. Buffer is sized to 256 bytes
  for forward compatibility with the SE050 secure element on the
  custom PCB.
- Wire.h drops the prior compile-only stubs and exposes the real
  TwoWire surface.
- Arduino.h: BitOrder becomes an enum (not #define) so Adafruit_BusIO's
  `typedef BitOrder BusIOBitOrder;` compiles.

Variant + build flags:
- nrf54l15.ini flips HAS_WIRE / HAS_SENSOR / HAS_TELEMETRY from 0 to 1
  and cherry-picks the sensor libs Meshtastic needs (BusIO, Sensor,
  BMP280, BME280, INA219/226/260/3221, SHT4X). The full
  environmental_base group is avoided because it pulls
  Adafruit_SSD1306 / Adafruit_GFX which rely on Arduino pin macros the
  Zephyr shim does not implement.
- nrf54l15dk variant.h defines PIN_WIRE_SDA / PIN_WIRE_SCL for parity
  with the Arduino convention used by other variants. The actual bus
  wiring is fixed by the overlay pinctrl above.

Validated 2026-05-14/15 on the DK with BMP280 @ 0x76 (temperature +
barometric pressure) and INA3221 @ 0x42 (rail voltage / current);
EnvironmentTelemetry / PowerTelemetry packets transmit successfully
over LoRa.

Footprint cost on nrf54l15dk: +45 KB flash, +1.7 KB RAM.

* feat(nodedb): honor USERPREFS for environment telemetry on first boot

installDefaultConfig() now respects two new compile-time prefs:

  USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL
  USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED

The mobile apps enforce a 30 min floor on environment_update_interval
in the settings UI, which makes short-interval bring-up testing of new
sensor hardware painful — you have to wait half an hour for the first
LoRa packet to confirm wiring + driver. With these prefs baked into
the variant, the firmware can ship a freshly-flashed device that
broadcasts on a shorter cadence (e.g. 900 s) the moment storage_partition
is empty.

Both prefs are gated on #ifdef so the behavior is unchanged for any
variant that does not opt in. Documented in userPrefs.jsonc with the
existing telemetry-interval pref block.

* fix(nrf54l15): allow multiple bonded BLE peers

CONFIG_BT_MAX_PAIRED defaults to 1, so once the first peer (e.g. an
iOS phone) has paired and bonded, every subsequent pairing attempt
from a different MAC fails inside bt_keys_get_addr() with no free
key slot — the host returns BT_SECURITY_ERR_KEY_DOES_NOT_EXIST and
the second peer never gets past SMP.

Raise the slot count to 4 so the device can simultaneously hold an
iOS phone, a Windows host, a Linux host, and one spare bond. Add
BT_KEYS_OVERWRITE_OLDEST so that once the table fills, the LRU peer
is evicted on the next pairing rather than rejecting the new peer.
This matches the behavior other Meshtastic ports already provide
(nRF52 uses CONFIG_BT_PERIPHERAL_PRIO_CONN with similar semantics).

Discovered while bringing up the Python CLI on Windows alongside
the existing iOS bond.

* fix(nrf54l15): zero-initialize TwoWire buffers + clang-format Wire

cppcheck on every CI target (esp32s3, rp2040, rp2350, nrf52840, ...) was
failing the build with two `uninitMemberVar` warnings on TwoWire's
constructor: `txBuf` and `rxBuf` (256-byte arrays) were not initialized.
Even though the buffers are only read after txLen/rxLen is set, leaving
them uninitialized is a footgun if any future caller bypasses the
len-set step. Use C++11 value-initialization in the member initializer
list — costs ~512 B of memset at boot, gains a clean cppcheck pass and
defensive-against-future-changes semantics.

Also reformat Wire.{cpp,h} with the project's `.trunk/configs/.clang-format`
config so the Trunk Check Runner passes — clang-format moved the
`<errno.h>` include before the Zephyr-namespaced ones in Wire.cpp and
collapsed two inline overloads to single lines in Wire.h.

* fix(AdminModule): remove dead OUTPUT_GPIO_PIN/GpioOutputModule references

OUTPUT_GPIO_PIN is never defined and modules/GpioOutputModule.h doesn't
exist in the codebase; all #ifdef OUTPUT_GPIO_PIN branches were dead code
introduced by the nRF54L15-DK variant commit. Strips the include, the
output_gpio_enabled OFF→ON/ON→OFF transition logic in handleSetConfig(),
and the digitalRead() reflection in handleGetConfig().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-16 06:16:11 -05:00
Ben Meadors 2a91d186eb Add max_session_seconds to LockdownAuth for session management 2026-05-15 15:14:29 -05:00
4827498188 Clamp direct position packets to channel precision (fixes #8640) (#10383)
* Fix position precision for direct sends

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Clarify zero position precision logging

* Use const channel reference for position precision

* Use C linkage for position precision test entrypoints

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-14 20:51:44 -05:00
Ben MeadorsandGitHub 3a0c08b695 Merge pull request #10474 from meshtastic/2.8
Develop is 2.8 WIP now
2026-05-13 19:36:15 -05:00
Ben Meadors 7bdff8ff70 Bump protos 2026-05-13 14:33:15 -05:00
Ben Meadors 35b0590408 Develop is 2.8 WIP now 2026-05-13 13:50:17 -05:00
Ben MeadorsandGitHub cbddf07bc8 Merge pull request #10472 from meshtastic/remove-arial 2026-05-13 12:01:02 -05:00
Jonathan BennettGitHubBen MeadorsHarukiToredaJason Pcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>thebentern
871194517d Ble banner (#8902)
* Drop unneeded Sizeof() instances

* Use SimpleBanner for BLE pin

* Support for different font sizes on notification banner

* Fix NRF52 BLE cppcheck shadow warning

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/de12b52c-49d5-452a-b3fb-344724649270

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
Co-authored-by: Jason P <applewiz@mac.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-05-13 11:02:19 -05:00
Ben Meadors 5a1d2b9ef4 Refine nRF52 flash optimization comment for FONT_LARGE_LOCAL definition 2026-05-13 10:52:05 -05:00
Ben Meadors 9cd3a86938 Cleanup 2026-05-13 10:43:16 -05:00
Ben Meadors 748668b8e9 Remove ARIAL24 on NRF52 2026-05-13 10:13:53 -05:00
Ben Meadors 778d1ad90f Merge remote-tracking branch 'origin/master' into develop 2026-05-13 09:40:16 -05:00
Ben Meadors 75b7a7df4f Missed one 2026-05-13 08:50:15 -05:00
Ben Meadors 8110887be2 Turns out it's already excluded 2026-05-13 07:57:54 -05:00
Ben Meadors 5f734dabf9 Trunk 2026-05-13 06:44:51 -05:00
Andros FenollosaandGitHub 039ad42758 Fix WiFi TCP/HTTP services not starting without USB serial connected (#10460)
Move WiFi.onEvent(WiFiEvent) registration before createSSLCert() to
prevent a race where the ESP32 auto-reconnects during cert generation
and fires GOT_IP before the handler is attached, causing
onNetworkConnected() to never run and the TCP/HTTP API services to
never initialize when booting without USB serial.

Also call onNetworkConnected() from reconnectWiFi() on all platforms
(not just RP2040) as a safety net; it is already guarded by
APStartupComplete so it only runs once.
2026-05-13 06:43:36 -05:00
Ben Meadors 593909c26b Radiolib excludes 2026-05-13 06:42:49 -05:00
Ben Meadors 29a61dc75c Fix type declaration for ambientLightingThread and correct uint32_t usage in PacketHistory 2026-05-12 21:45:16 -05:00
Ben MeadorsandGitHub eead467ce6 Added NodeDB fixtures and refactored to use std maps for better memory efficiency (#10464)
* Added NodeDB fixtures and refactored to use std maps for better efficiency

* Defer NodeDB save during xmodem transfer to prevent mid-transfer fsFormat
2026-05-12 17:23:29 -05:00
Ben Meadors f3ae02c425 Cleanup comments 2026-05-12 16:32:00 -05:00
Ben Meadors d9cb74e4dd XModemAdapter: ensure file truncation before receiving and add isBusy() method to prevent concurrent writes 2026-05-12 15:38:56 -05:00
7ff6641f97 Fix missing potential null termination in xmodem filename handling (#10308)
* Fix missing potential null termination in xmodem filename handling

The packet size max is 128 bytes, and the filename is 128 bytes, so
potentially there is no NUL at the end. use strlcpy() as that takes
care of null termination even if buffer size is exceeded.

* Protect against theoretical buffer overflows in BLE logging

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-12 06:26:13 -05:00
Ben MeadorsGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
f7548e7c25 Remove gradient sync nonce and simplify replay handling (#10459)
* Remove gradient sync nonce and simplify replay handling

* Fix ONLY_CONFIG replay gating and stale gradient-sync comments

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/cfa93978-e2e0-4dc2-ba5f-b82b5b43cef8

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Add transport mechanism to replay packets for client filtering

* Comments

* Update protobuf definitions to include precision_bits in PositionLite

* Propagate position precision_bits and remove verbose NodeInfo sync log

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/41572cbc-408e-499d-b59e-00f330b5789f

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-11 21:42:07 -05:00
b960121464 BaseUI: remove legacy single-message runtime path and keep multimessage flow (#10450)
* cleanup

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 20:53:41 -05:00
Ben Meadors b074645586 Change node pointer to const in JsonSerialize function 2026-05-11 17:16:32 -05:00
Ben Meadors 811dd427dd Update protobufs 2026-05-11 16:36:35 -05:00
Jonathan Bennett 1c06b702dc Merge remote-tracking branch 'origin/master' into develop 2026-05-11 15:07:22 -05:00
59b4993861 Update protobufs (#10456)
Co-authored-by: jp-bennett <5630967+jp-bennett@users.noreply.github.com>
2026-05-11 15:06:37 -05:00
Jonathan BennettandGitHub da61a0db7d Refactor mutex handling in PhoneAPI.cpp
Replace LockGuard with explicit lock and unlock calls to avoid deadlock
2026-05-11 11:45:14 -05:00
Thomas GöttgensGitHubBen Meadorscopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>caveman99Jonathan Bennett
8e99ffbe7e ThinkNode M7 (#8077)
* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* ThinkNode G3, ETH support WIP

* rename variant and add guard macros

* older G3 operational. M7 next.

* Split out G3 and M7 to different variants. Completely new PCB design. The G3 stays on 'PRIVATE_HW'

* Define button behaviour and use all of the device flash

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-05-11 11:33:13 -05:00
Ben Meadors a23f923e64 Update subproject commit reference in protobufs 2026-05-11 08:09:39 -05:00
Ben Meadors 0522039830 Merge branch 'master' into develop 2026-05-11 08:09:10 -05:00
Ben MeadorsGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Copilot Autofix powered by AI
94bb21ecc7 2.8: NodeDB shrink, decoupling, and restructuring (#10413)
* 2.8: NodeDB refactor to decouple satellite entries and decrease size

* Regen

* Refactor node mute handling to use dedicated functions for clarity and consistency

* Develop ref

* Fix NodeDB review follow-ups

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/6b1d6cf6-ed6b-43b6-95cb-8e141757664e

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Address review validation nits

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/6b1d6cf6-ed6b-43b6-95cb-8e141757664e

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Trunk

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Extract legacy NodeDatabase migration

* Fix remaining NodeDB review issues

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/c76b9a5a-7244-4fbc-9ef0-98091d8caaea

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Fixes

* Trunk

* Fix latest review compile follow-ups

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/5198da01-ec4c-4c16-8a09-68b8e6d5d410

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Fix cppcheck style warnings

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e60287ba-4ece-46e0-83d8-a6d89664c0bb

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Change pointer type for mesh node in set_favorite function

* Change pointer types for mesh node references to const in multiple applets

* Add NodeDB layout v25 documentation and migration guidelines

* Remove tests for uninitialized PacketHistory state due to undefined behavior

* Fix code block formatting in copilot instructions

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-09 15:12:10 -05:00
1bcabb893b mesh: bound the user-facing notification sprintf calls (#10437)
Two sites built ClientNotification messages with sprintf into a
fixed-size proto buffer with no length cap. The current format strings
fit comfortably, but a future caller editing either format string
without rechecking the buffer size would get a silent stack/heap
overrun. Switch to snprintf with sizeof so the bound is enforced at
the call site.

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-09 13:32:42 -05:00
BJKandGitHub fefd424901 Fix screen geometry update for SH1107 display (#10444)
Added conditional block to update screen geometry for SH1107 128x128.
2026-05-09 12:53:07 -05:00
f63716c322 Automated version bumps (#10419)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-05-08 19:05:11 -05:00
5e2ca8aed4 LR2021 radio on NRF_Promicro (#10401)
* LR2021 radio on NRF_Promicro

Co-authored-by: Copilot <copilot@github.com>

* Refactor LR2021 interface includes and conditional compilation for improved clarity

Co-authored-by: Copilot <copilot@github.com>

* Refactor LR20x0 interface: remove unused includes and update comments for clarity

* Fix LR2021 max power definitions and add radio type detection tests

* remove potato radio type detection tests

* Include placeholder for DCDC - currently requires godmode

* Added godmode features - not enabled by default

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-07 20:24:39 -05:00
4553d1e0b1 Skip MQTT allocation when disabled (#10411)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-07 11:51:55 -05:00
Ben Meadors 784e3748e2 Merge remote-tracking branch 'origin/master' into develop 2026-05-07 11:45:04 -05:00
TomandGitHub b11d29ff31 fix(mesh): update reconfigure methods to return true instead of RADIOLIB_ERR_NONE (#10407) 2026-05-07 05:57:34 -05:00
cdc47a2aea Fix GPS initialization logic for Portduino configuration (#10395)
Co-authored-by: jessm33 <root@example.com>
2026-05-05 14:17:04 -05:00
fcef46f4b0 dependency swap - INA3221Sensor (#10379)
* dependency swap - INA3221Sensor
update INA3221 initialization and measurement methods for compatibility with Rob Tillaart's library

Co-authored-by: Copilot <copilot@github.com>

* Addresses copilot review

Co-authored-by: Copilot <copilot@github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Refine comments on USB detection and INA3221

Updated comments regarding USB detection and INA3221 usage.

* Fix static_assert conditions for INA3221 channel definitions

Co-authored-by: Copilot <copilot@github.com>

* moved macro defines earlier to allow better use.

Co-authored-by: Copilot <copilot@github.com>

* Add raw register read methods for bus voltage and shunt current in INA3221Sensor

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 06:52:07 -05:00
Jonathan Bennett bb86cf4e81 Merge remote-tracking branch 'origin/master' into develop 2026-05-04 18:10:59 -05:00
Jonathan BennettandGitHub 6ea0d5ebba Add TFT_BACKLIGHT_ON for cardputer to fix builds (#10387) 2026-05-04 16:19:48 -05:00
JordandGitHub b2bda3b07a Enable MESHTASTIC_PREHOP_DROP by default (#9924) 2026-05-02 16:38:06 -05:00
Jonathan BennettandGitHub b7a9555905 Update RadioLib dependency to a specific commit (#10370)
Exploratory PR to test new radiolib change
2026-05-01 12:50:07 -05:00
1eb860a3fc fix(stm32wl,nrf52,fs): flash hardening, FS platform unification, write-behind LFS cache (FORMAT BREAK) (#10171)
* stm32wl: check HAL_FLASH_Unlock() return in _internal_flash_erase

_internal_flash_prog already checks HAL_FLASH_Unlock() and returns
LFS_ERR_IO on failure. _internal_flash_erase discarded the return
value, proceeding to erase even if the flash was not unlocked.

Apply the same check for consistency and safety.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl: fix _internal_flash_prog to abort on first write error

Previously the programming loop continued to the next doubleword after
HAL_FLASH_Program() failed, potentially writing to invalid addresses
and returning a misleading error code only at the end (last iteration).
HAL_FLASH_Lock() was also skipped on the mid-loop early return path.

- Move bounds check before the loop (validate full range at once)
- Break on first HAL error so subsequent doublewords are not written
- Move HAL_FLASH_Lock() after the loop so it always runs

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl: clear stale flash SR error flags before erase and program

Stale error flags in FLASH->SR from a previous failed operation can
cause HAL_FLASH_Program() or HAL_FLASHEx_Erase() to return HAL_ERROR
immediately without attempting the operation.

Add __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS) after each
HAL_FLASH_Unlock() in both _internal_flash_prog and
_internal_flash_erase to ensure a clean state before each operation.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl: reject flash prog writes not aligned to 8-byte doubleword

The STM32WL HAL minimum write unit is one 64-bit doubleword (8 bytes).
_internal_flash_prog silently truncated any trailing bytes when size % 8
!= 0 because dw_count = size / 8 drops the remainder. Return LFS_ERR_INVAL
early so LittleFS sees the error rather than a silent short write.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(nrf52,fs): use atomic SafeFile rename instead of direct write

NRF52 was bypassing the .tmp/readback/rename path entirely — openFile()
deleted the target file and wrote directly to it, and close() returned
true without verifying the write or renaming anything.

Adafruit_LittleFS::rename() calls lfs_rename() directly (confirmed at
Adafruit_LittleFS.cpp:205). Remove both ARCH_NRF52 guards so NRF52
follows the same write-to-.tmp → readback-hash → rename path used by
all other platforms.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(admin): skip uiconfig.proto save on devices without a screen

handleStoreDeviceUIConfig() was writing /prefs/uiconfig.proto
unconditionally. MenuHandler.cpp is already gated behind #if HAS_SCREEN,
so there is no path that populates UI config on screen-less platforms.
Guard the save with #if HAS_SCREEN to avoid wasting a flash block on
devices that will never use it.

The read path (handleGetDeviceUIConfig) does not touch the filesystem
and needs no change.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fs: enable format-on-retry for all platforms in saveToDisk

The FSCom.format() call on save failure was guarded to ARCH_NRF52 with
a comment that other platforms were not ready (bug #4184). STM32WL was
added to the guard in a prior commit. All platforms now expose format
semantics and the retry logic is identical — remove the guard.

To keep NodeDB.cpp platform-agnostic and fix a CI failure on native-tft
(portduino's fs::FS has no format() method), introduce fsFormat() in
FSCommon as the single call-site for all callers:

  - Embedded (ESP32, NRF52, STM32WL, RP2040): delegates to FSCom.format()
  - Portduino: rmDir("/prefs") + FSBegin() (a no-op on portduino).
    rmDir("/prefs") is already called unconditionally by factoryReset()
    (NodeDB.cpp:504), so both primitives are proven on portduino.

Replace both direct FSCom.format() calls in NodeDB.cpp with fsFormat().

Note: we do not run portduino locally — portduino/native build testers
please verify the format-on-retry path.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* DO NOT MERGE: nrf52(fs): add File() default constructor bound to InternalFS

Adds File() to the Adafruit LittleFS File class (in the Meshtastic
Adafruit_nRF52_Arduino fork), delegating to File(InternalFS). This
matches the default-constructible File API on all other platforms.

The constructor is implemented in Adafruit_LittleFS_File.cpp rather
than inline in the header to avoid a circular include between
Adafruit_LittleFS_File.h and InternalFileSystem.h.

FOLLOW-UP REQUIRED: nrf52.ini points to a commit SHA on the
mesh-malaysia/Adafruit_nRF52_Arduino fork instead of the upstream
meshtastic framework. Once meshtastic/Adafruit_nRF52_Arduino#5 is
merged, revert nrf52.ini to point back to the upstream meshtastic
framework URL.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl(fs): add File() default constructor and document LFS tunables

Adds File() to STM32_LittleFS_Namespace::File, delegating to
File(InternalFS). Implemented in the .cpp to avoid a circular include
between STM32_LittleFS_File.h (which cannot include LittleFS.h) and
the InternalFS extern declaration.

This matches the File API on ESP32/RP2040/Portduino and is a
prerequisite for removing the ARCH_STM32WL guard in xmodem.h.

No behavior change — the constructor leaves the file in the same
closed/unattached state as File(InternalFS) would.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fs: remove arch-specific ifdefs from FSCommon, SafeFile, xmodem

Now that NRF52 and STM32WL have File() default constructors and NRF52
has working atomic SafeFile rename, the capability gaps are closed.
Remove all per-arch guards across the shared FS layer:

FSCommon.cpp — renameFile():
  Use FSCom.rename() on all platforms. Adafruit_LittleFS::rename()
  calls lfs_rename() directly (Adafruit_LittleFS.cpp:205). The
  copy+delete fallback on NRF52/RP2040 was never necessary.

FSCommon.cpp — getFiles():
  Replace four ARCH_ESP32 guards with a single filepath pointer at
  the top of the loop (file.path() on ESP32, file.name() elsewhere).
  Fix strcpy(fileInfo.file_name, filepath): bounded to
  sizeof(fileInfo.file_name)-1 with explicit NUL termination to prevent
  overflow of the 228-byte meshtastic_FileInfo::file_name array.

FSCommon.cpp — listDir():
  Same filepath pointer approach. NRF52/STM32WL were in an else-branch
  that only logged but never deleted — now all platforms follow the
  unified del path. 12 guards → 2.
  Fix three strncpy(buffer, ..., sizeof(buffer)) calls that did not
  NUL-terminate when source length >= sizeof(buffer) (255 bytes).
  Add explicit buffer[sizeof(buffer)-1] = '\0' after each.

FSCommon.cpp — rmDir():
  Use listDir(del=true) everywhere. The ARCH_NRF52 rmdir_r() path and
  the ARCH_ESP32|RP2040|PORTDUINO listDir() path collapse to one line.

SafeFile.cpp:
  ARCH_NRF52 bypass removed (handled in preceding commit).

xmodem.h:
  File file; now works on all platforms via default constructors
  added in the two preceding commits.

Remaining #ifdef ARCH_ESP32 in FSCommon.cpp: exactly 4, all for the
file.path() vs file.name() API difference (ESP32 Arduino LittleFS
returns the full path; all others return only the name). That
difference lives in the framework and cannot be closed here.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl(fs): add write-behind page cache, reduce virtual block size and FS reservation (FORMAT BREAK)

Adds a write-behind (RMW) page cache to the STM32WL LittleFS driver,
modelled after the NRF52 Adafruit approach (flash_cache.c). This allows
LFS to use 256-byte virtual blocks backed by 2048-byte physical pages:
the erase/prog callbacks accumulate changes in a 2 KB RAM buffer; the
sync callback (and page eviction on page-change) flushes with a single
HAL physical-erase + doubleword-program pass.

LFS tunables changed (FORMAT BREAK — superblock parameters):
  block_size:  2048 B → 256 B  (8 virtual blocks per physical page)
  read_size:   2048 B → 256 B  (= block_size)
  prog_size:   2048 B → 256 B  (= block_size; hardware min is 8 B)
  block_count: 112   → 80     (14 phys pages → 10 phys pages = 20 KiB)

Benefits:
  - Internal fragmentation: max 2047 B/file → max 255 B/file
  - Heap per open LFS file: ~4 KB → 512 B (prog + read buffers)
  - Code flash headroom: 6.7 KB → ~14.1 KB (+7.4 KB)
  - Block budget: 80 virtual blocks, worst-case peak ~20, ~60 free

Updates board_upload.maximum_size in wio-e5/platformio.ini from 233472
(256 KB − 28 KB) to 241664 (256 KB − 20 KB) to match the reduced FS
reservation.

Justification for the format break: the prior STM32WL firmware had
several flash write bugs fixed earlier in this series (missing error
flag clearing, no abort on first write failure, unaligned write
acceptance). These bugs very likely caused silent config corruption on
deployed devices. The format break should be treated as an enhancement:
it provides a clean, reliably-written starting point. Users will need
to reconfigure their device once after this update.

Correctness fixes applied to the cache implementation:
  - alignas(8) on _page_cache: the buffer was uint8_t[] (alignment 1)
    but _flash_cache_flush casts it to const uint64_t* — undefined
    behaviour per C++ standard, potential Cortex-M hardfault. alignas(8)
    guarantees the required alignment for the doubleword cast.
  - HAL_FLASH_Lock() return value: was discarded. Now assigned to
    lock_rc and propagated into rc if prior writes succeeded, so LFS
    sees the error rather than a false success.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* stm32wl(fs): reduce FS reservation from 10 pages to 7 pages (FORMAT BREAK)

Reduces LFS_FLASH_TOTAL_SIZE from 10 × 2 KiB pages (20 KiB) to
7 × 2 KiB pages (14 KiB), freeing 6 KiB for firmware.

board_upload.maximum_size updated accordingly across all STM32WL variants:
  241664 (256 KiB - 20 KiB) → 247808 (256 KiB - 14 KiB)

This is a FORMAT BREAK: existing filesystems must be erased before use.

Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* fix(fs): return false in renameFile() when FSCom is not defined

Avoids undefined behavior and -Wreturn-type warnings in configurations
that compile FSCommon.cpp without a filesystem backend.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-01 08:25:19 -05:00
Jonathan Bennett 989b8620ba Merge remote-tracking branch 'origin/master' into develop 2026-04-30 10:49:26 -05:00
a0951f23c3 fix: MQTT connection on Portduino/Linux native nodes (#10330)
isConnectedToNetwork() always returned false on ARCH_PORTDUINO
because none of HAS_WIFI, HAS_ETHERNET, or USE_WS5500 are defined
for Linux native builds. This caused wantsLink() to always return
false, preventing the MQTT thread from ever connecting at boot.

Fix: return true for ARCH_PORTDUINO since Linux always has network
access available.

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-30 06:00:50 -05:00
HarukiToredaandGitHub e19f531059 Update Screen.cpp (#10344) 2026-04-29 21:05:16 -05:00
6c7ffa1054 macOS: enable CH341 LoRa-hardware path (fix serial truncation, document setup) (#10320)
* macOS: enable CH341 LoRa-hardware path — fix serial truncation, document setup

Verified on Apple Silicon with a CH341A USB-SPI bridge (VID 0x1A86,
PID 0x5512) wired to an SX1262 (Meshstick variant) that the existing
`pine64/libch341-spi-userspace` lib_dep works on macOS as-is — Apple's
bundled CH34x driver only matches the CH340 *UART* variant
(PID 0x7523), so the CH341A's interface 0 is left unclaimed and
libusb opens / configures / claims it directly via IOUSBHostInterface.
End-to-end test: meshtasticd boots, libusb claim succeeds, SX1262 init
returns 0, TCP API serves the meshtastic CLI's --info / --sendtext flow.

Two changes:

1. **`PortduinoGlue.cpp:497`**: pass `sizeof(serial)` (= 9) instead of
   the literal `8` to `Ch341Hal::getSerialString()`. The function in
   `USBHal.h:61-68` treats `len` as buffer size and reserves one slot
   for the null terminator (`bytesCopied = (len - 1) < 8 ? (len - 1) : 8`),
   so passing 8 produced a 7-char serial — which then broke the
   `strlen(serial) == 8` check at line 502, skipping the auto-MAC
   derivation from serial + product string. On Linux this was masked
   by the BlueZ HCI MAC fallback in `getMacAddr()` at lines 139-157,
   but on macOS that fallback is `__linux__`-guarded so the serial path
   is mandatory and the truncation left `mac_address` empty, causing
   the daemon to exit with `*** Blank MAC Address not allowed!`.

2. **`variants/native/portduino/platformio.ini`**: expand the
   `[env:native-macos]` comment block with a "Real LoRa hardware on
   macOS" section. Documents:
   - Why no upstream library change is needed (Apple kext targets
     CH340/UART, not CH341A/SPI; libusb's `#ifdef __linux__` skip is
     correct for macOS in this case).
   - How to point `meshtasticd` at an existing platform-agnostic
     `bin/config.d/lora-*.yaml` for CH341 hardware.
   - The auto-MAC-derivation contract (now working with this fix).
   - `ioreg` and `LIBUSB_DEBUG=4` diagnostic recipes for the failure
     mode where a third-party WCH `CH34xVCPDriver` *would* claim
     interface 0 (`kmutil unload -b <bundleID>` workaround).

No upstream library forks, no PR chain, no additional lib_deps —
the existing `pine64/libch341-spi-userspace` + libusb-1.0 stack does
the right thing on macOS already.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 08:31:08 -05:00
f037ce2165 add heltec-v4-r8 board (#10268)
* add heltec-v4-r8 board

* Fixed default SPI pin and macro definition errors.

* update platformio.ini according device-ui LGFX display definitions

Co-authored-by: Copilot <copilot@github.com>

* fix commit reference

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: mverch67 <manuel.verch@gmx.de>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
2026-04-27 09:25:19 -05:00
Ben Meadors bc17d004ab Merge branch 'master' into develop 2026-04-27 08:05:22 -05:00
Ben MeadorsGitHubJonathan BennettCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
06a6c3ee20 Native MacOS hello world (#10309)
* Native MacOS hello world

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update variants/native/portduino/platformio.ini

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: ensure null-termination in getSerialString() and handle len==0

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e5647919-2255-48ad-bcaa-7a2c2fdbf212

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-26 22:07:07 -05:00
bfadf0c36a fix(Router): localize p_encrypted to prevent recursive-overwrite leak (#10311)
Router::handleReceived stores its allocCopy of the encrypted packet in the
class member p_encrypted. callModules() invokes module replies that re-enter
the router via MeshService::sendToMesh -> Router::sendLocal, which on a
broadcast reply recursively calls handleReceived. The inner call overwrites
the outer's p_encrypted without releasing it; on the way out it nulls the
member, the outer release(p_encrypted) now releases nullptr, and the original
allocation is permanently leaked. ~381 B per recursion.

Promote p_encrypted to a function-local so each invocation owns its own copy
for its full lifetime. The MQTT-publish null check at the call site (added by
PR #9136 as a workaround for this bug) stays in place because allocCopy can
still legitimately return nullptr on packetPool exhaustion.

Copilot's review of PR #8999 (the original introduction) flagged this exact
pattern at merge time:
  "Storing p_encrypted as a class member can cause issues with recursive or
  concurrent calls to handleReceived() since each call would overwrite the
  previous packet pointer."

The historical reason for the member (S&F needing to retain the encrypted
copy across calls) was satisfied differently by PR #9799 (ServerAPI converted
to std::unique_ptr + cleanup on connection close), so the member is no longer
load-bearing.

Reproduces issues #9632 / #10101 / #8729 (heap leak when MeshMonitor
connected; TCP drops on Station G2 / LILYGO ServerAPI dump abort).

Hardware A/B on Station G2 under sustained TCP-API retry storm (open :4403,
request config, disconnect mid-stream, repeat at ~0.6/s) - 9 min run:

  | Build         | heapFree drift | rebootCount delta |
  | this patch    | -1.5 KB (noise)| 0                 |
  | stock 2.7.13  | -73 KB (8.1KB/min) | +1 (OOM crash) |

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-26 21:02:42 -05:00
Jonathan BennettandGitHub 24c4162a75 Standardize PMU IRQ handling and enable power button cancel on tbeam-s3 (#10285)
* Standardize PMU IRQ handling and enable power button as cancel on tbeam s3

* Original T-beam, too
2026-04-26 19:58:23 -05:00
Ben Meadors b148fac340 Update framework version reference for Adafruit nRF52 to latest master branch 2026-04-26 09:49:39 -05:00
8dde4eeee1 BaseUI: Color Support for TFT Nodes (#10233)
* True Colors on TFT (Heltec Mesh Node T114, Heltec Vision Master T190, CardPuter Adv, T-Deck, T-Lora Pager)

* Theme support - New and some Classic Themes!

* Colored Compass

---------

Co-authored-by: Jason P <applewiz@mac.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-26 07:44:56 -05:00
Ben Meadors 4d4e14600c Merge remote-tracking branch 'origin/master' into develop 2026-04-25 15:55:16 -05:00
Ben Meadors 0bd8dee346 Merge remote-tracking branch 'origin/master' into develop 2026-04-25 15:06:28 -05:00
2828dbe4ca t5s3-epaper: Move variant.cpp -> extra_variants/variant.cpp ...again (#10297)
Fixes issues with #includes inherited from `configuration.h` when building for pioarduino.

Aligns t5s3_epaper with other variants like t_deck_pro.

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-25 13:36:31 -04:00
Ben Meadors a8c8fd7002 Remove redundant power interrupt methods for ESP32 2026-04-25 07:11:03 -05:00
Ben MeadorsandCopilot e7c02da24b Merge remote-tracking branch 'origin/master' into develop
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 06:41:38 -05:00
HarukiToredaandGitHub 7421953e8f InkHUD: Add full touch support to T5s3 (#10286)
* InkHUD touch rework

* Applet Switcher

* Update ED047TC1.cpp

* trunk fix

* Custom tip screen for T5s3

* Update TouchScreenImpl1.cpp

* Update ED047TC1.cpp

* Delete variant.cpp
2026-04-25 05:22:24 -05:00
9306e66067 fix(inkhud): scale MapApplet markers with fontSmall line height (#10288)
Marker boxes, the own-node bullseye, and the labeled-marker cross were
all hardcoded in pixels (11px box, r=8 circle, 12px cross). On the
T5S3 with a 12pt fontSmall (~17px line height) the hop-count digit
overflowed its box entirely. Sizes now derive from fontSmall.lineHeight()
so the applet renders correctly on both small (6pt) and large (12pt+)
display variants.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:20:39 -04:00
Jonathan BennettandGitHub 7adfc3f992 Remove incorrect LED_STATE_ON definition for t-beam-s3 (#10280)
Fixes #9912  and #10170
2026-04-24 15:17:33 +10:00
ba9cadc14d Fix INA226 detection for non-TI compatible chip (Silergy) (#10247)
* Fix INA226 detection for non-TI compatible chip (Silergy)

* Removed extra I2C transaction + 20ms delay on every scan of address 0x40 (including real SHT2x sensors).

Changes suggested by Copilot

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Apply formatting (trunk fmt)

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-24 13:09:37 +10:00
924411de59 T-Watch S3 Power button managment (#9855)
* PMU interrupt pin defined in t-watch s3

* Implement button control on T-Watch S3

Added interrupt handling for the Power/Corona button on T-Watch S3, I use it to control screen state.

* Reducing labels

* Reducing labels

* Updated the comment

* ISR is now IRAM-safe

Updated interrupt management not to cause random crashes.

* Trunk

* Simplify and use INPUT_BROKER_CANCEL

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-23 19:53:59 -05:00
d9195944df PositionModule::sendLostAndFoundText: use stack buffer, eliminate heap alloc (#10251)
* PositionModule::sendLostAndFoundText: use stack buffer, eliminate heap alloc

The lost-and-found message was built with an unnecessary heap allocation:

    char *message = new char[60];
    sprintf(message, "..."...);
    ...
    delete[] message;

Two problems:

1. **Buffer too small.** The format string expands with two %f (IEEE 754
   doubles), which `sprintf` prints with full precision — easily 15+
   digits each plus separators — so the actual rendered string can run
   40-50 characters before even considering the emoji (4 UTF-8 bytes)
   and the embedded BEL. A pathological lat/lon can overflow 60 bytes
   and corrupt heap metadata. Unbounded `sprintf` with no size check.

2. **Heap churn in a GPS callback.** This function is called from the
   position-update path which is already heap-sensitive. An infrequent
   60-byte transient alloc isn't catastrophic, but stack is trivially
   available here and removes the failure mode entirely.

Fix: replace with a 128-byte stack buffer and `snprintf` bounded by
`sizeof(message)`. Drop the matching `delete[]` since there's nothing
to delete.

Behavior is identical on the happy path; the overflow case now
truncates safely instead of scribbling over heap.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* PositionModule.cpp: add trailing newline for clang-format

* Address Copilot review: cleaner snprintf size handling

Review feedback from @Copilot on PR #10251: the ternary-plus-static-cast
form mixed signed/unsigned types (int written vs. pb_size_t payload.size
vs. size_t sizeof(message)) and was harder to read than necessary.

Cleaner form:
  const size_t msg_len = std::min(static_cast<size_t>(written), sizeof(message) - 1);
  p->decoded.payload.size = msg_len;

Same behaviour (clamp to buffer-minus-NUL) with one explicit cast and
a size_t variable that names the meaning. Handles the encoding-error
path (written < 0) separately so no bad values leak into payload.size.

* Trunk

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-23 18:20:07 -05:00
83a98c81f6 Hash table index for O(1) packet history lookups (#9499)
* Use hash table for O(1) lookup of recently seen packets

* Eliminate a packet lookup during deduplication

* Infinite loop checks for find and remove

* Consolidate conditional compilation

* Exclude hash table from minimal build

* Additional comment on hash table capacity

* Unit tests for packet history changes

* Update incorrect comment about size clamp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Const

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-23 18:04:34 -05:00
Jonathan BennettandGitHub 837637b70c Only enable wakeup via EXT_CHRG_DETECT if we shut down due to low power (#10263) 2026-04-23 15:37:35 -05:00
Valentin V. BartenevandGitHub 7b3f58875a Fix example comment in airtime.h (#10275)
Looks like a copy'n'paste typo from the previous line.
It definitely meant to be RX_ALL_LOG according to comment.
2026-04-23 14:44:39 -05:00
Andrew YongandGitHub b2d980fc25 feat(Power): support EXT_PWR_DETECT_MODE & EXT_PWR_DETECT_VALUE, simplify EXT_PWR_DETECT (#10140)
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-04-23 14:32:17 -05:00
2ed7bba5e7 fix(Power): refactor EXT_CHRG_DETECT to compile-time macros (#10191)
Mirror the EXT_PWR_DETECT pattern: replace runtime static variables
(ext_chrg_detect_mode, ext_chrg_detect_value) with compile-time macros.
Auto-infer EXT_CHRG_DETECT_VALUE from EXT_CHRG_DETECT_MODE when the mode
is INPUT_PULLUP (→ LOW) or INPUT_PULLDOWN (→ HIGH); default to HIGH.

This fixes inverted polarity on variants that define EXT_CHRG_DETECT_MODE
INPUT_PULLUP without an explicit EXT_CHRG_DETECT_VALUE (e.g. russell):
previously the runtime default of HIGH caused isCharging() to return the
opposite of the correct value. With auto-inference the correct LOW active
level is now derived at compile time.

Remove the now-redundant EXT_CHRG_DETECT_VALUE HIGH from ELECROW-ThinkNode-M4
variant.h since HIGH is the inferred default.


Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>

Signed-off-by: Andrew Yong <noreply@example.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-23 13:24:05 -05:00
Catalin PatuleaandGitHub 22d50fe437 NimbleBluetooth misc cleanups (#10264)
* Delete unused clearNVS() (last used in commit 761804b1).

* virtual methods: add 'override' to ensure we get the signature right.

This is a safety net for pioarduino/NimBLE work where there's multiple
similar variants of the same method (eg. onConnect) and it's easy to get
the wrong one and accidentally miss a callback.
2026-04-23 09:18:41 -05:00
Ben Meadors cde5a08bc5 Merge remote-tracking branch 'origin/master' into develop 2026-04-23 06:48:43 -05:00
55bf8c25fc PhoneAPI: add missing tak_tag case + skip reserved gap in module-config iteration (#10256)
* PhoneAPI: add missing tak_tag case + skip reserved gap in module-config iteration

The STATE_SEND_MODULECONFIG state machine iterates config_state through
ModuleConfigType enum values (1..MAX+1 = 16) and switches on
meshtastic_ModuleConfig_*_tag values. Two of the resulting tag values
land in the default / LOG_ERROR path:

1. `tak_tag` (16) — the meshtastic_ModuleConfig_TAKConfig struct exists
   in the protobuf and has a `.tak` member in payload_variant, but no
   PhoneAPI case ever sends it to the phone. As a result, Android
   clients can't read the persisted TAK (Team Awareness Kit) module
   config at all. Added case that sends moduleConfig.tak, matching the
   pattern used for all other module-config tags (paxcounter,
   traffic_management, etc.). NodeDB already persists the struct via
   the moduleConfig protobuf save; this just wires the read path to
   the phone.

2. Tag 14 — reserved gap in the oneof numbering. No payload_variant
   member exists at tag 14. Without this patch, every phone reconnect
   walks through config_state=14 and hits
   `LOG_ERROR("Unknown module config type %d", config_state)`. On an
   active deployment that's ~1,400 LOG_ERROR lines per day per node —
   burying real errors. Added explicit `case 14: break;` so the gap
   is silently skipped.

Also: lowered the `default:` log level from LOG_ERROR to LOG_DEBUG. A
truly-new unknown type number would indicate firmware lagging the
protobuf — annoying but not an error event worth LOG_ERROR, especially
since this path runs on every phone handshake. If a new ModuleConfig
tag appears, devs will notice via the phone UI missing it, not via log.

Observed on a Station G2 fleet: 1403 "Unknown module config type 16"
and 1427 "Unknown module config type 14" LOG_ERROR lines in 24 hours
from routine phone reconnects.

* Get rid of the placeholder

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-23 06:42:05 -05:00
nightjoker7andGitHub 399dde0f4b Router: demote cross-channel decrypt failures from ERROR to DEBUG (#10259)
The "Invalid protobufs (bad psk?)" and "Invalid portnum (bad psk?)"
messages fire every time a neighbor transmits on a channel whose
8-bit hash matches one of ours but the PSK differs. In RF environments
with multiple mesh groups nearby this is routine — a single device
can see dozens of these per minute from SAR/MeshCA/private networks
sharing a hash collision.

LOG_ERROR for a benign "not our PSK" event:
- spams the log when you have any neighboring mesh group
- makes a genuine PSK misconfiguration on YOUR own channel
  indistinguishable from the constant cross-channel noise
- hides actual errors in the stream

LOG_DEBUG matches how similar decryption-failure paths are handled
elsewhere (eg. the PKC "decrypt attempted but failed" uses LOG_WARN).
Dropping the trailing "!" as well — these are expected events, not
exceptional ones.
2026-04-23 06:19:05 -05:00
66971a0a26 RadioLibInterface: clear static instance on destruction to prevent UAF (#10254)
The constructor sets `RadioLibInterface::instance = this` immediately,
before `init()` runs. `initLoRa()` in RadioInterface.cpp creates each
radio variant with `new SX1262Interface(...)` or similar, then calls
`init()`, and if init fails the `unique_ptr<RadioInterface>` is reset
to nullptr — destroying the object — while the static `instance`
pointer continues to point at the freed memory.

Main loop then checks `RadioLibInterface::instance != nullptr` and
calls `pollMissedIrqs()` or `resetAGC()` on the dangling pointer →
Guru Meditation (IllegalInstruction / LoadProhibited).

Reported in #9880 on an ESP32-S3 dev board without radio hardware
attached, where init always fails and the leftover pointer crashes
the device on the next `loop()` iteration.

Fix: add a virtual destructor to `RadioLibInterface` that clears the
static pointer iff it still references this object. A later successful
init() may have replaced `instance` with a different interface — the
`instance == this` guard preserves that case.

Fixes #9880

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-23 06:16:08 -05:00
Thomas GöttgensandGitHub 4c24218afb Revert "Update LovyanGFX to v1.2.20 (#10232)" (#10269) (#10270)
This reverts commit fb1de111d7.
2026-04-23 12:40:27 +02:00
Ben Meadors 4b4914736f Merge remote-tracking branch 'origin/master' into develop 2026-04-22 21:52:25 -05:00
nightjoker7andGitHub a6b1a69630 StoreForwardModule::historyAdd: memcpy source size, not buffer capacity (#10250)
`memcpy(... p.payload.bytes, meshtastic_Constants_DATA_PAYLOAD_LEN)`
reads past the actual payload when the incoming packet's payload is
shorter than `DATA_PAYLOAD_LEN` (237 bytes). The code just above
already records the correct size:

    this->packetHistory[...].payload_size = p.payload.size;

but then the memcpy ignores that and copies the full buffer capacity,
pulling uninitialized / adjacent memory bytes into the history entry.
Those extra bytes are later rebroadcast whenever the Store & Forward
module replays the packet.

Fix: memcpy using `p.payload.size` (the actual payload length) instead
of the constant buffer capacity.

Classification: bounded out-of-bounds READ into the protobuf scratch
buffer. Not directly exploitable for RCE (the destination buffer is
also DATA_PAYLOAD_LEN), but leaks adjacent memory into replayed
packets and is a latent correctness bug.
2026-04-22 21:04:37 -05:00
Jonathan BennettandGitHub 28e705de5c Detach power interrupts for sleep (#10230)
* Detach power interrupts for sleep

* Gate PMU IRQ behind a found PMU
2026-04-22 14:27:48 -05:00
AustinandGitHub fcb9ec0c2d t5s3-epaper: Move variant.cpp -> extra_variants/variant.cpp (#10241)
Fixes issues with #includes inherited from `configuration.h` when building for pioarduino.

Aligns t5s3_epaper with other variants like t_deck_pro.
2026-04-22 11:42:02 -05:00
AustinandGitHub a4b55bc6f2 cardputer-adv: Move variant.cpp -> extra_variants/variant.cpp (#10242)
Fixes issues with #includes inherited from `configuration.h` when building for pioarduino.

Aligns cardputer-adv with other variants like t_deck_pro.
2026-04-22 11:41:49 -05:00
Jonathan Bennett b53fe7a1e7 T watch pinfix (#10231)
* Minor button debugging bits

* pin0 is a pin, pin -1 means disabled
2026-04-21 20:03:40 -05:00
TomandGitHub db9fdd6794 Fix: filter out SKIPPED tests in PlatformIO output to improve log clarity (#10214) 2026-04-21 16:43:35 -05:00
3b4c66439d feat(t5s3-epaper): add InkHUD port for LilyGo T5 E-Paper S3 Pro (#10211)
* niche: add InkHUD port for LilyGo T5-E-Paper-S3-Pro (ED047TC1)

Add a NicheGraphics EInk driver adapter for the 4.7" ED047TC1 parallel
e-paper display used on the T5-E-Paper-S3-Pro (H752-01). The driver
wraps FastEPD and handles the polarity difference between InkHUD's
buffer format (0xFF = white) and FastEPD's (0x00 = white).

Rewrite variants/esp32s3/t5s3_epaper/nicheGraphics.h which was an
incomplete copy of the Heltec VM-E290 setup referencing undefined SPI
pin macros and a non-existent BUTTON_PIN_SECONDARY. The board uses a
parallel display, not the small SPI DEPG0290BNS800 that was referenced.

* fix: guard inputBroker null dereference in TouchScreenImpl1::init()

When MESHTASTIC_EXCLUDE_INPUTBROKER is defined (e.g. InkHUD builds),
inputBroker is nullptr. Calling inputBroker->registerSource() in that
state caused a LoadProhibited panic on any board that has both
HAS_TOUCHSCREEN=1 and the InputBroker excluded.

Add a null check before registerSource() to prevent the crash.

* niche: fix display rotation for T5-E-Paper-S3-Pro InkHUD port

Set rotation=3 (270° CW) in nicheGraphics.h to correct for FastEPD
scanning the ED047TC1 panel in portrait orientation, resulting in
correct landscape display output.

* fix: update buffer format descriptions and remove polarity inversion for InkHUD and FastEPD

* fix: update ED047TC1 driver to handle inactive pixel borders and adjust safe-area dimensions

* fix: comment out ruler diagnostic for E-Ink driver

* feat: implement TouchInkHUDBridge for direct touch event handling in InkHUD

* niche: add FreeSans 18pt/24pt Win1253 (Greek) fonts for larger InkHUD displays

Add Win1253-encoded FreeSans 18pt and 24pt font headers to support Greek
script on larger InkHUD screens (e.g., the 4.7" ED047TC1 at ~234 DPI).
Register FREESANS_24PT_WIN1253 and FREESANS_18PT_WIN1253 macros in AppletFont.h.
Set fontLarge=24pt, fontMedium=18pt, fontSmall=12pt in nicheGraphics.h for the
T5-E-Paper-S3-Pro.

* feat(ed047tc1): use true partial update for FAST refresh

Replace fullUpdate(CLEAR_FAST) with partialUpdate() for FAST display
updates. FastEPD's partialUpdate() diffs pCurrent against pPrevious
and only applies the update waveform to rows that have changed, leaving
unchanged rows with a neutral signal.

This reduces visible flicker on routine updates (new messages, position
changes) — only the affected region of the screen refreshes. Full-screen
CLEAR_SLOW updates are preserved for periodic ghosting cleanup, driven
by InkHUD's setDisplayResilience() ratio.

* feat(t5s3-epaper): enable frontlight via LatchingBacklight

Wire up BOARD_BL_EN (GPIO11) to InkHUD's LatchingBacklight driver.
Enable the backlight menu item so users can toggle "Keep Backlight On"
via Settings. The backlight turns on automatically when the menu opens
and off when it closes.

* Fix RTC chip (PCF8563 not PCF85063) and GT911 I2C address collision

- variant.h used PCF85063_RTC but the board has a PCF8563. The difference
  is the RAM register: PCF85063 has 1 byte of RAM; PCF8563 does not. The
  PCF85063 driver was trying to write this register on init, failing every
  time, and setDateTime writes were silently discarded — RTC time was
  never persisted across reboots. Switch to PCF8563_RTC/PCF8563_INT.

  Before:
    [E][SensorPCF85063.hpp:375] initImpl(): Failed to write to RAM memory
      register. Maybe this chip is pcf8563.
    Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:23
    PCF85063 setDateTime 2026-04-05 18:40:59
    Read RTC time from PCF85063 getDateTime as 2026-04-05 00:00:19  ← lost

  After:
    PCF8563 found at address 0x51
    Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:37  ← persisted
    PCF8563 setDateTime 2026-04-05 18:58:44
    Read RTC time from PCF8563 getDateTime as 2026-04-05 18:58:44  ← round-trips

- GT911 touch was initialized with GT911_SLAVE_ADDRESS_L (0x5D), which
  collides with the SFA30 air quality sensor also at 0x5D on the same
  I2C bus. Switch to GT911_SLAVE_ADDRESS_H (0x14): the library drives
  INT high during reset to program the GT911 to address 0x14,
  eliminating the address conflict.

  Before:
    SFA30 found at address 0x5d
    [I][TouchDrvGT911.hpp:568] initImpl(): Try using 0x5D as the device address

  After:
    SFA30 found at address 0x5d
    [I][TouchDrvGT911.hpp:544] initImpl(): Try using 0x14 as the device address

* t5s3_epaper: fix GT911 ghost-SFA30 via early I2C address latch

Investigation findings
----------------------
Boot logs showed "SFA30 found at address 0x5d" on every cold power-on,
and AirQualityTelemetry was registering an SFA30 sensor. However, every
readMeasuredValues() call returned error 268 (0x010C = Sensirion
WriteError | I2cAddressNack), meaning the I2C write to 0x5D was being
NACK'd — inconsistent with a real SFA30.

Root cause: the GT911 touch controller latches its I2C address from the
INT pin level at reset time (GT911 datasheet §4.3). GPIO3 (INT) defaults
LOW on ESP32-S3 cold boot → GT911 always powers up at 0x5D
(SLAVE_ADDRESS_L). The I2C scanner runs before lateInitVariant() had a
chance to reprogram the chip.

The scanner's SFA30 detection (ScanI2CTwoWire.cpp) sends the 2-byte
command 0xD060 to 0x5D and requests 48 bytes back. GT911 ACKs the
write (treating it as a register address) and returns 48 bytes of
register data, passing the length check — a false-positive SFA30
detection.

Confirmed via second cold-boot log: after the previous commit moved GT911
to 0x14 in lateInitVariant(), address 0x5D *still* appeared in the scan
because the scanner runs first. The board has no physical SFA30 fitted.

Fix
---
Add the GT911 address-latch reset sequence to earlyInitVariant(), before
Wire is initialised and before the I2C scan runs. Per the datasheet:
drive RST LOW, drive INT HIGH (selects address 0x14 / SLAVE_ADDRESS_H),
hold >100 µs, release RST, wait >5 ms startup. GPIO-only, no Wire
dependency. lateInitVariant() then repeats this sequence internally via
touch.begin(); the double-reset is harmless.

Verified in boot log:
  Before: "SFA30 found at address 0x5d", 5 I2C devices, NACK errors
  After:  no SFA30 entry, 4 I2C devices (TCA9535/PCF8563/BQ27220/BQ25896),
          GT911 found at 0x14 and touch initialised successfully,
          AirQualityTelemetry registers no sensors (correct — no SFA30 present)

* t5s3_epaper: add variant_shutdown() for touch sleep and backlight off

Put GT911 into low-power standby (command 0x05) and drive BOARD_BL_EN
LOW before deep sleep to avoid unnecessary current draw.

* t5s3_epaper: fix touch gesture routing and coordinate mapping

readTouch() now transforms raw GT911 axes to visual-frame coordinates
based on the current display rotation (rotation=3 is the hardware
identity). This ensures TouchScreenBase detects swipe direction
correctly regardless of which rotation the user has selected.

TouchInkHUDBridge dynamically sets joystick.alignment = (4-rotation)%4
on each touch event so that (rotation+alignment)%4==0 always, keeping
nav calls pass-through without remapping.

nicheGraphics.h now calls loadSettings() first so that rotation is
persisted across reboots. rotation=3 and other first-boot defaults are
only applied when tips.firstBoot is set. alignment is recomputed from
the loaded rotation on every boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* t5s3_epaper: fix GT911 sleep timing via notifyDeepSleep observer

touch.sleep() was called from variant_shutdown(), which runs inside
cpuDeepSleep() — after Wire.end() had already torn down the I2C bus in
doDeepSleep(). This caused Wire NULL TX buffer errors and left the GT911
awake during deep sleep.

Register a CallbackObserver on notifyDeepSleep, which fires before
Wire.end(), so the I2C command reaches the chip while the bus is live.
Pattern matches LatchingBacklight and other NicheGraphics components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* t5s3_epaper: fix touch nav and applet defaults in nicheGraphics

Enable joystick mode post-begin so menu scroll and swipe-up/down
gestures are not silently dropped by the joystick.enabled gate in
Events.cpp. Activate DMs and Channel 0/1 applets with correct
autoshow defaults matching the mini-epaper-s3 reference pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update nicheGraphics.h

* t5s3_epaper: fix ED047TC1 driver docs and remove spurious beginPolling

Addressing PR review comments:

Remove beginPolling(1, 0) after the blocking FastEPD update — it
incorrectly set updateRunning=true for one loop cycle after the
hardware was already done, causing busy() to briefly return true.
Since isUpdateDone() always returns true, no polling is needed.

Also fix stale comments: safe-area buffer size was 944×532, now
944×523; V_OFFSET_ROWS didn't exist, replaced with the actual
V_OFFSET_TOP=9 / V_OFFSET_BOTTOM=8 constant names.

* t5s3_epaper: clean up applet addition formatting in setupNicheGraphics

* t5s3_epaper: guard ED047TC1.cpp against non-T5S3 InkHUD builds

The InkHUD base config pulls in all of src/graphics/niche/ so every
InkHUD device compiled ED047TC1.cpp, triggering the #error on line 48
for boards that define neither T5_S3_EPAPER_PRO_V1 nor V2.

Wrap the file body with #ifdef T5_S3_EPAPER_PRO so it is only compiled
for T5S3 targets. The #error is preserved inside the guard to catch
future hardware revisions that forget to update the driver.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
2026-04-21 15:35:02 -05:00
HarukiToredaandGitHub 945f4780ea BaseUI: Nodelist screen/favorite screen cleanup (#10197)
* nodelist screen cleanup

* Update UIRenderer.cpp

* Update src/graphics/draw/UIRenderer.cpp

* removed brackets from hop and made signal mutually exclusive
2026-04-21 10:31:29 -05:00
0e38a15d46 Update protobufs (#10223)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-04-21 17:13:55 +02:00
Jaime RoldanandGitHub 63bce1f01a fix(nodedb): force null-terminate name fields in UserLite/User conversions (#8174) (#10218) 2026-04-21 09:52:19 -05:00
nightjoker7andGitHub 4090d9f2b3 SX126x: re-apply 0x8B5 register in resetAGC() to preserve RX sensitivity (#10219)
The CALIBRATE_ALL (0x7F) command inside resetAGC() clears bit 0 of the
undocumented 0x8B5 register. That bit is set once in init() by #9571 and
#9777 to improve SX1262 RX sensitivity, and the AGC-reset path was not
re-applying it. Result: every SX1262 node silently loses the RX
sensitivity patch ~60s after boot and never recovers until reboot.

Empirically confirmed on Heltec Mesh Node T114 (nRF52840 + SX1262):
  - Post-calibration read of 0x8B5 = 0x04 (bit 0 cleared)
  - After re-apply: 0x05 (bit 0 set)
Reproducible every AGC_RESET_INTERVAL_MS tick.

Fix re-applies the register bit alongside the existing post-calibration
re-applies (setDio2AsRfSwitch, setRxBoostedGainMode).
2026-04-21 09:50:01 -05:00
d50caf231b Add encryption overview to agent instructions in AGENTS.md (#10207)
* Add encryption overview to agent instructions in AGENTS.md

* Update AGENTS.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Clarify nonce and wire overhead details in encryption section of copilot instructions

* Enhance encryption documentation in copilot instructions and agents guide for clarity on key management and reset behaviors

* Update .github/copilot-instructions.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix botched merge conflict resolution

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-19 16:05:28 -05:00
f396200d38 Add authoring guide for native unit tests in README.md (#10201)
* Add authoring guide for native unit tests in README.md

* Enhance documentation for agent tooling and native unit tests in README and related files

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-19 13:30:50 -05:00
Ben Meadors 6c04c37294 Merge remote-tracking branch 'origin/master' into develop 2026-04-19 12:50:12 -05:00
Clive BlackledgeandGitHub 34aa5e995b Fix: prompt markdownlint md040 fix for new prompts. (#10199)
* Add ESP32 Power Management lessons learned document

Documents our experimentation with ESP-IDF DFS and why it doesn't
work well for Meshtastic (RTOS locks, BLE locks, USB issues).

Proposes simpler alternative: manual setCpuFrequencyMhz() control
with explicit triggers for when to go fast vs slow.

* docs(prompts): fix markdown fence language tags

* docs: remove ESP32 power management notes
2026-04-18 08:29:30 -05:00
Ben MeadorsGitHubCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
c8dac10348 Add MCP server for interacting with meshtastic devices and testing framework / TUI (#10194)
* Start of MCP server and test suite

* Add MCP server for interacting with meshtastic devices and testing framework / TUI

* Update mcp-server/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix mcp-server review feedback from thread

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/91dc128a-ed50-4d07-8bb2-3dc6623a05f7

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Enhance StreamAPI and PhoneAPI for improved log record handling and concurrency control

* Semgrep fixes

* Trunk and semgrep fixes

* optimize pio streaming tee file writes

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* chore: remove redundant log handle assignment

Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

* Consolidate type imports and remove placeholder test files

* Add tests for config persistence and more exchange messages

* Refactor position test to validate on-demand request/reply behavior

* Remove  position request/reply test and update README for telemetry behavior

* Fix transmit history file to get removed on factory reset

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-18 08:17:44 -05:00
aab4cd086f Compass improvements/refactoring (#10166)
* Infinite calibration loop fix

* Save calibration

* Screen refresh

* reduce repeated code

* reduce repeated code to reduce flash

* fix Waypoint compass size and no fix no heading labels

* Don't show compass unless we have a heading and location

* If no calculated heading from moving, we should have no heading

* Slow walking calculated heading and auto stale heading when not moving

* Triming flash space

* cleanup

* show "?" when no location or heading for distance and heading screen

* cleanup

* Stale heading logic

* final trim

* Compass Calibration screen redesign

* Trunk Fix

* Compile fix

* patch

* Update src/motion/MotionSensor.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update WaypointModule.cpp

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-18 06:53:22 -05:00
Jason PandGitHub 6208c243f9 BaseUI: Implementation of Status Message for Favorite and NodeList views (#9504)
* Implementation of Status Message

* Change drawNodeInfo to drawFavoriteNode

* Truncate overflow on Favorite frame

* Set MAX_RECENT_STATUSMESSAGES to 5 to meet memory usage targets
2026-04-17 08:42:56 -05:00
Jonathan BennettandGitHub 3a87e746a8 No longer need undefines, thanks to #10179 (#10180) 2026-04-16 21:34:28 -05:00
Ben Meadors cd04206334 Merge branch 'master' into develop 2026-04-16 21:22:17 -05:00
Jonathan BennettandGitHub 2768080edf More cleanly remove LED_BUILTIN (#10179)
* Test PR to remove LED_BUILTIN 

Comment out the LED_BUILTIN definition in platformio.ini

* Add LED_BUILTIN definition to nrf52840.ini
2026-04-16 13:12:31 -05:00
RuledoandGitHub 466cc4cecd Add Luckfox Pico Max Waveshare Pico LoRa config (#10175)
Add a meshtasticd config for the Luckfox Pico Max with the Waveshare Pico LoRa SX1262 TCXO HAT.

Tested on hardware with successful SX1262 init, broadcast, and direct messaging.
2026-04-16 10:41:06 -05:00
fe90c49795 fix/feat(stm32/russell): Serial2 build fix and BME680 support (#10097)
* fix(stm32/russell): define ENABLE_HWSERIAL2 and Serial2 pins

The Russell board variant was missed during Initial serialModule cleanup
(PR #9465), which began requiring Serial2 to be explicitly defined via
ENABLE_HWSERIAL2 and PIN_SERIAL2_TX/RX rather than relying on implicit
defaults, causing a build error.

Signed-off-by: Andrew Yong <me@ndoo.sg>

* feat(stm32/russell): add BME680 support, exclude modules to fit flash

The BME680 is hardware footprint compatible with the BME280 already
present on the Russell board, so add it as an additional lib dep to
enable environment sensing (temperature, humidity, pressure,
gas resistance).

The STM32 target has very limited flash. Even traceroute alone causes
overflow, so the following modules are excluded to stay within budget:

  - RANGETEST
  - DETECTIONSENSOR
  - EXTERNALNOTIFICATION
  - POWERSTRESS
  - NEIGHBORINFO
  - TRACEROUTE
  - WAYPOINT

AIR_QUALITY_SENSOR is also excluded as it requires the BSEC2 library
for real IAQ output, which alone overflows flash by ~44KB on this
target. The Adafruit BME680 library is used instead for raw sensor
readings.

Signed-off-by: Andrew Yong <me@ndoo.sg>

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-16 10:39:37 -05:00
Chloe BethelandChloe Bethel 31418ca821 stm32wl: reserve 2KB of stack via linker script to match NRF52, change sbrkHeadroom to use the start of the reserved stack region instead of the current stack pointer
The linker script was created by merging variants/STM32WLxx/WL54JCI_WL55JCI_WLE4J(8-B-C)I_WLE5J(8-B-C)I/ldscript.ld and system/ldscript.ld from stm32duino/Arduino_Core_STM32.
2026-04-16 13:57:21 +01:00
Andrew YongandChloe Bethel 0ee5777c15 stm32wl(mem): fix getFreeHeap() underreporting on dynamic sbrk heap
mallinfo().fordblks counts only free bytes within the committed arena.
On STM32WL (newlib sbrk heap) the arena grows lazily from _end toward SP,
so fordblks reads near-zero at early boot even when ~48 KB of addressable
space remains. This caused NodeDB::isFull() to fire prematurely and evict
nodes on a freshly booted device.

Fix getFreeHeap() to include uncommitted sbrk headroom (SP - sbrk(0)) so
the returned value reflects true available memory throughout the boot
lifecycle.

Introduce MESHTASTIC_DYNAMIC_SBRK_HEAP as an opt-in build flag (set in
stm32.ini) so the fix is gated to platforms with a dynamic sbrk heap
rather than a static heap. Future platforms with the same heap model can
opt in by adding this flag.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 13:57:21 +01:00
Andrew YongandGitHub 026213aab7 feat(stm32): Add STM32 ADC support to AnalogBatteryLevel (#9369)
Integrate STM32 battery monitoring into AnalogBatteryLevel, supporting
external GPIO ADC pins as well as internal VBAT channel.

Features:
- ADC reading using STM32 LL (Lower Layer) macros supporting external
  ADC channels and internal VBAT channel (AVBAT)
- ADC compensation using STM32 LL macros with factory-calibrated VREFINT
  (AVREF) for accurate voltage measurement
- LFP battery OCV curve for STM32WL using AVBAT (STM32 VDD absolute
  maximum supply voltage 3.9V, direct connection of Li-Po batteries is
  not supported)

Internal VBAT channel implemented in:
- Russell
- RAK3172

In these variants, ADC_MULTIPLIER = (1.01f * 3) = 3.30 as there is a
3:1 internal divider (DS13105 Rev 12 §5.3.21), and a bit of tolerance
as the actual 10% spec leads to readings much too high.


Assisted-by: Claude:sonnet-4-5

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-04-16 10:58:54 +01:00
0cab43fb43 Add PortduinoSetOptions to overwrite the realhardware bool (#10157)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-14 21:32:48 +02:00
1341cd4078 Automated version bumps (#10159)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-04-14 13:11:47 -05:00
Jennifer SanchezandGitHub 4059202a5c Added support for Spreading Factors 5 and 6 on compatible radios (#10160) 2026-04-14 13:11:36 -05:00
Ben MeadorsandCopilot d24d8806e1 Fix heap blowout on TBeams (#10155)
* Fix heap blowout on TBeams

* Update src/graphics/draw/MessageRenderer.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Set MESSAGE_HISTORY_LIMIT to 10 for original ESP32 to optimize RAM usage

* Optimize message frame allocation to prevent excessive memory usage

* Refine message history limits for resource-constrained builds and cap cached lines to prevent heap overflow

* Update src/graphics/draw/MessageRenderer.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-14 13:05:58 -05:00
Thomas GöttgensandBen Meadors a67eb15ad3 fix last cppcheck issue (#10154) 2026-04-14 13:05:58 -05:00
Ben Meadors 9e182a595c Enhance release notes generation with commit range comparison 2026-04-14 13:05:58 -05:00
Tom FifieldandGitHub 4587dc2d64 Add RADIOLIB_EXCLUDE_LR2021 in places that excluded LR11x0 (#10112)
To save resources, some devices where LR11x0 was never an option
excluded it from compilation, using RADIOLIB_EXCLUDE_LR11X0 .

As we will soon have LR2021 support, apply the same treatment
to that chip to these devices, by adding RADIOLIB_EXCLUDE_LR2021
2026-04-14 16:55:11 +02:00
00fccd87f9 Update protobufs (#10161)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-04-14 15:27:05 +02:00
Andrew YongandGitHub 01bd4cfb73 feat(stm32wl): add reboot-to-bootloader support via enter_dfu_mode_request (#10158) 2026-04-14 13:46:33 +02:00
Jonathan BennettandGitHub 125c1b7f13 Make consoleInit() Reentrant, and initialize it earlier on native (#10156) 2026-04-14 06:41:04 -05:00
Thomas GöttgensandGitHub d6cf67b6bc Clarify behavior when no radio instance is present
Add comment explaining silent behavior when no radio instance is available.
2026-04-14 12:19:58 +02:00
77f378dd53 Reduce key duplication by enabling hardware RNG (#8803)
* Reduce key duplication by enabling hardware RNG

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Use micros() for worst case random seed for nrf52

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Minor cleanup, remove dead code and clarify comment

* trunk

* Add useRadioEntropy bool, default false.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-13 12:05:23 -05:00
Ben Meadors 16dcafa7fb Merge remote-tracking branch 'origin/master' into develop 2026-04-13 06:28:41 -05:00
4ac74edf38 fix(native): implement BinarySemaphorePosix with proper pthread synchronization (#9895)
* fix(native): implement BinarySemaphorePosix with proper pthread synchronization

The BinarySemaphorePosix class (used on all Linux/portduino/native builds)
had stub implementations: give() was a no-op and take() just called
delay(msec) and returned false. This broke the cooperative thread scheduler
on native platforms — threads could not wake the main loop, radio RX
interrupts were missed, and telemetry never transmitted over the mesh.

Replace the stubs with a proper binary semaphore using pthread_mutex_t +
pthread_cond_t + bool signaled:

- take(msec): pthread_cond_timedwait with CLOCK_REALTIME timeout, consumes
  signal atomically (binary semaphore semantics)
- give(): sets signaled=true, signals condition variable
- giveFromISR(): delegates to give(), sets pxHigherPriorityTaskWoken

Tested on Raspberry Pi 3 Model B (ARM64, Debian Bookworm) with Adafruit
LoRa Radio Bonnet (SX1276). Before fix: no radio TX/RX, no telemetry on
mesh. After fix: bidirectional LoRa, MQTT gateway, telemetry all working.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ARCH_PORTDUINO

* Refactor BinarySemaphorePosix header for ARCH_PORTDUINO

* Change preprocessor directive from ifndef to ifdef

* Gate new Semaphore code to Portduino and fix STM compilation

* Binary Semaphore Posix better error handling

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-04-12 22:41:25 -05:00
Jonathan Bennett 69495dcd98 Merge remote-tracking branch 'origin/master' into develop 2026-04-12 17:29:40 -05:00
Catalin PatuleaandGitHub 288d7a3e7b Revert "Add include directive for mbedtls error handling in build flags" (#10073)
This reverts commit 391928ed08.

Since esp32_https_server commit e2c9f98, this is no longer necessary.

Also, the esp32-common.ini '-include mbedtls/error.h' causes a build error
under IDF v5 (https://github.com/meshtastic/firmware/pull/9122#issuecomment-4185767085):

  <command-line>: fatal error: mbedtls/error.h: No such file or directory

so this change fixes that error.
2026-04-11 06:35:42 -05:00
Catalin PatuleaandGitHub 4990bf51e3 Delete PointerQueue::dequeuePtrFromISR, unused since commit db766f1 (#99). (#10090) 2026-04-10 16:20:25 -05:00
TomandGitHub b2c8cbb78d Enhance traffic management by adjusting position update interval and refining hop exhaustion logic based on channel congestion (#9921) 2026-04-10 10:53:04 -05:00
Jonathan BennettandGitHub 8061f83704 Modify log output to show milliseconds (#10115)
Updated timestamp format to include milliseconds.
2026-04-10 07:21:24 -05:00
TomandGitHub a3b49b9028 Add powerlimits to reconfigured radio settings as well as init settings. (#10025)
* Add powerlimits to reconfigured radio settings as well as init settings.

* Refactor preamble length handling for wide LoRa configurations

* Moved the preamble setting to the main class and made the references static
2026-04-09 16:18:05 -05:00
Ben Meadors 17945884a5 Merge remote-tracking branch 'origin/master' into develop 2026-04-09 06:21:33 -05:00
Ben Meadors 1116f06139 Merge remote-tracking branch 'origin/master' into develop 2026-04-08 13:37:25 -05:00
c728cfdaf4 Automated version bumps (#10092)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-04-06 06:39:53 -05:00
9322bcdb21 fix: redact MQTT password from log output (#10064)
MQTT password was logged in cleartext via LOG_INFO when connecting to
the broker, exposing credentials to anyone with log access. Replace
the password format specifier with a static mask.

Co-authored-by: Patrickschell609 <patrickschell609@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 07:54:51 -05:00
2f19a1d7a4 Consolidate SHTs into one class (#9859)
* Consolidate SHTs into one class

* Remove separate SHT imports
* Create one single SHTXX sensor type
* Let the SHTXXSensor class handle variant detection

* Minor logging improvements

* Add functions to set accuracy on SHT3X and SHT4X

* Fix variable init in constructor

* Add bus to SHT sensor init

* Update src/modules/Telemetry/Sensor/SHTXXSensor.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/Telemetry/Sensor/SHTXXSensor.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix accuracy conditions on SHTXX

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Merge upstream

* Add SHT2X detection on 0x40

* Read second part of SHT2X serial number

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-04 06:51:15 -05:00
71c8143f72 Update protobufs (#10074)
Co-authored-by: fifieldt <1287116+fifieldt@users.noreply.github.com>
2026-04-04 13:58:23 +11:00
222d3e6b62 Fix zero CR and add unit tests for applyModemConfig coding rate behavior (#10070)
* Fix zero CR and add unit tests for applyModemConfig coding rate behavior

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: initialize region configuration in setUp for RadioInterface tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-04 08:27:39 +11:00
Chloe BethelandGitHub 2e6519bb98 Add a hardfault handler so it's more obvious when STM32 crashes. (#10071)
* Add hardfault handler so it's more obvious when STM32 crashes.

* thanks copilot
2026-04-03 14:50:39 +01:00
726d539174 fix: prevent division by zero in wind sensor averaging (#10059)
SerialModule's weather station parser divides by velCount and dirCount
to compute wind speed/direction averages. Both counters are only
incremented when their respective sensor readings arrive, but the
division runs whenever gotwind is true (set by EITHER reading) and
the averaging interval has elapsed.

If only WindDir arrives without WindSpeed (or vice versa), or if the
timer fires before any readings accumulate, the division produces
undefined behavior (floating-point divide by zero on embedded = NaN
or hardware fault depending on platform).

Fix: add velCount > 0 && dirCount > 0 guard to the averaging block.

Co-authored-by: Patrickschell609 <patrickschell609@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-03 06:07:59 -05:00
e7ee4bea18 Fix TFTDisplay::display to align pixels at 32-bit boundary (#9956)
* Fix TFTDisplay partial update: align spans to 32-bit boundary for GDMA

The device, such as esp32c6, require 32-bit alignment for the data.

The patch aligns start and end of the update pixels buffer.

* Update src/graphics/TFTDisplay.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-04-02 20:57:19 -05:00
TomandGitHub efd2613bd7 feat: add new configuration files for LR11xx variants (#9761)
* feat: add new configuration files for LR11xx variants

* style: reformat RF switch mode table for improved readability
2026-04-01 10:46:27 +11:00
f88bc732cc Improved manual build flow to make it easier (#8839)
* Improved flow to make easier

The emojis are intentional! I had minimal LLM input!!!

* try and fix input variable sanitisation

* and again

* again

* Copilot fixed it for me

* copilot didn't fix it for me

* copilot might have fixed it and I broke it by copypasting

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-31 07:53:59 -05:00
Ben Meadors 0746f8cfff Merge remote-tracking branch 'origin/master' into develop 2026-03-30 14:37:29 -05:00
Ben Meadors 0ac95e370b Merge remote-tracking branch 'origin/master' into develop 2026-03-30 14:10:25 -05:00
283ccb83d1 Configure NFC pins as GPIO for older bootloaders (#10016)
* Configure NFC pins as GPIO for older bootloaders

Should hopefully solve the issue in https://github.com/meshtastic/firmware/issues/9986

* Fix formatting of CONFIG_NFCT_PINS_AS_GPIOS flag in platformio.ini

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-30 09:03:10 -05:00
5319bc7c2c Fix W5100S socket exhaustion blocking MQTT and additional TCP clients (#9770)
The W5100S Ethernet chip has only 4 hardware sockets. On RAK4631
Ethernet gateways with syslog and NTP enabled, all 4 sockets were
permanently consumed (NTP UDP + Syslog UDP + TCP API listener + TCP
API client), leaving none for MQTT, DHCP lease renewal, or additional
TCP connections.

- NTP: Remove permanent timeClient.begin() at startup; NTPClient::update()
  auto-initializes when needed. Add timeClient.end() after each query to
  release the UDP socket immediately.
- Syslog: Remove socket allocation from Syslog::enable(). Open and close
  the UDP socket on-demand in _sendLog() around each message send.
- MQTT: Fix socket leak in isValidConfig() where a successful test
  connection was never closed (PubSubClient destructor does not call
  disconnect). Add explicit pubSub->disconnect() before returning.

Made-with: Cursor

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-30 07:12:23 -05:00
Ben MeadorsandGitHub db694f2f24 Merge pull request #10031 from meshtastic/master
Backmerge master -> develop
2026-03-29 15:01:39 -05:00
Ben Meadors 814f1289f6 Merge remote-tracking branch 'origin/master' into develop 2026-03-28 07:26:46 -05:00
0b7556b590 MUI: WiFi map tile download: heltec V4 adaptations (#10011)
* rotated MUI

* mui-maps heltec-v4 adaptations

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-27 15:39:26 -05:00
Ben Meadors f7e4ac3e43 Merge remote-tracking branch 'origin/master' into develop 2026-03-27 08:43:19 -05:00
c36ae159ed Fix rak_wismeshtag low‑voltage reboot hang after App configuration (#9897)
* Fix TAG low‑voltage reboot hang after App configuration

* nRF52: Move low-VDD System OFF logic to variant hook

* Addressed review

* serialize SAADC access with shared mutex for VDD and battery reads

* raise LPCOMP wake threshold to ensure rising-edge wake

* Trunk fmt

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-27 06:56:19 -05:00
Chloe BethelandGitHub 33e7f16c05 Supporting STM32WL is like squeezing blood from a stone (#10015) 2026-03-27 06:52:00 -05:00
Jason PandGitHub d3a86841b9 Update External Notifications with a full redo of logic gates (#10006)
* Update External Notifications with a full redo of pathways

* Correct comments.

* Fix TWatch S3 and use HAS_DRV2605
2026-03-25 14:46:24 -05:00
Austin Lane e14b8d385a Remove unneeded GH perms
Reduce perms to least-necessary
Remove merge_queue.yml since it's never been used and is now stale
Remove comment-artifact, it hasn't worked in ages.
2026-03-24 08:13:59 -04:00
AustinandGitHub 8ce1a872eb Add timeout to PPA uploads (#9989)
Don't allow dput to run for more than 15 minutes (successful runs take about ~8 minutes)
2026-03-23 20:15:56 -05:00
Austin Lane abfa346630 Cleanup GH Actions 2026-03-23 11:17:30 -04:00
2ffe2d829c Fixes #9850: Double space issue with Cyrillic OLED font (#9971)
* Fixes double space OLEDDisplayFontsRU.cpp

* Fixes double space OLEDDisplayFontsUA.cpp

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-23 07:06:53 -05:00
bfaf6c6b20 fix(routing): prevent licensed users from rebroadcasting packets to or from unlicensed users (#9958)
* fix(routing): prevent licensed users from rebroadcasting packets from unlicensed or unknown users

* fix(routing): prevent licensed users from rebroadcasting packets to or from unlicensed users

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 13:47:52 -05:00
d293d654a0 add heltec_mesh_node_t096 board. (#9960)
* add heltec_mesh_node_t096 board.

* Fixed the GPS reset pin comments.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Added compiles if NUM_PA_POINTS is not defined.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Correct the pin description.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Specify the version of the dependency library TFT_eSPI.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Adding fields missing from the .ini file.

* Modify the screen SPI frequency to 40 MHz.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 09:54:46 -05:00
8384659608 fix: apply all LoRa config changes live without rebooting (#9962)
* fix: apply all LoRa config changes live without rebooting

All LoRa radio settings (SF, BW, CR, frequency, power, preset,
sx126x_rx_boosted_gain) now apply immediately via reconfigure()
without requiring a node reboot.

- AdminModule: requiresReboot = false for all LoRa config changes;
  LoRa changes were already handled by the configChanged observer
  calling reconfigure() but the reboot flag was set unnecessarily
- AdminModule: validate LORA_24 region against radio hardware at
  config time; reject with BAD_REQUEST if hardware lacks 2.4 GHz
  capability (wideLora() returns false or no radio instance)
- SX126xInterface/LR11x0Interface: apply sx126x_rx_boosted_gain in
  reconfigure(); register 0x08AC is writable in STDBY mode (SX1261/2
  datasheet §9.6); retention registers written so setting survives
  warm-sleep cycles; log warning on setter failure
- DebugRenderer: show BW/SF/CR on debug screen when custom modem is
  active instead of the preset name
- DisplayFormatters: clarify comment on getModemPresetDisplayName

* fix: remove redundant reboot after LoRa config changes in on-device menus

Region, frequency slot, and radio preset pickers in MenuHandler all
called reloadConfig() then immediately set rebootAtMsec. reloadConfig()
already fires the configChanged observer which calls reconfigure(), so
the forced reboot was unnecessary — same rationale as the parent commit.

* fix: guard LORA_24 region selection against hardware capability in on-device menu

Without a reboot, reconfigure() now applies region changes directly.
Previously getRadio() caught the LORA_24-on-sub-GHz mismatch post-reboot
and reverted to UNSET — that safety net is gone. Add an explicit wideLora()
check in LoraRegionPicker so sub-GHz-only hardware silently ignores LORA_24
selection instead of attempting a live reconfigure with an invalid frequency.

---------

Co-authored-by: elwimen <elwimen@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 09:39:41 -05:00
Robert SasakandGitHub a632d0133f Add LED_BUILTIN for variant tlora_v1 (#9973) 2026-03-22 08:42:13 -05:00
f982b58d61 Fix: Enable touch-to-backlight on T-Echo (not just T-Echo Plus) (#9953)
The touch-to-backlight feature was gated behind TTGO_T_ECHO_PLUS, but
the regular T-Echo has the same backlight pin (PIN_EINK_EN, P1.11).
This changes the guard to use PIN_EINK_EN only, so any device with an
e-ink backlight pin gets the feature.

Fixes #7630

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 06:43:41 -05:00
Ben Meadors efb0b299b2 Merge remote-tracking branch 'origin/master' into develop 2026-03-22 06:42:37 -05:00
eb80d3141d Fixes #9792 : Hop with Meshtastic ffff and ?dB is added to missing hop in traceroute (#9945)
* Fix issue 9792, decode packet for TR test

* Fix 9792: Assure packet id decoded for TR test

* Potential fix for pull request finding

Log improvement for failure to decode packet.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* trunk fmt

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tom Fifield <tom@tomfifield.net>
2026-03-21 07:33:45 +11:00
Ben Meadors 3604c1255d Consolidate PKI key generation logic into ensurePkiKeys method 2026-03-20 09:53:12 -05:00
22d63fa69c Lora settings expansion and validation logic improvement (#9878)
* Enhance LoRa configuration with modem presets and validation logic

* Rename bootstrapLoRaConfigFromPreset tests to validateModemConfig for clarity and consistency

* additional tidy-ups to the validateModemConfig - still fundamentally broken at this point

* Enhance region validation by adding numPresets to RegionInfo and implementing validateRegionConfig in RadioInterface

* Add validation for modem configuration in applyModemConfig

* Fix region unset handling and improve modem config validation in handleSetConfig

* Refactor LoRa configuration validation methods and introduce clamping method for invalid settings

* Update handleSetConfig to use fromOthers parameter to either correct or reject invalid settings

* Fix some of the copilot review comments for LoRa configuration validation and clamping methods; add tests for region and preset handling

* Redid the slot default checking and calculation. Should resolve the outstanding comments.

* Add bandwidth calculation for LoRa modem preset fallback in clampConfigLora

* Remove unused preset name variable in validateConfigLora and fix default frequency slot check in applyModemConfig

* update tests for region handling

* Got the synthetic colleague to add mock service for testing

* Flash savings... hopefully

* Refactor modem preset handling to use sentinel values and improve default preset retrieval

* Refactor region handling to use profile structures for modem presets and channel calculations

* added comments for clarity on parameters

* Add shadow table tests and validateConfigLora enhancements for region presets

* Add isFromUs tests for preset validation in AdminModule

* Respond to copilot github review

* address copilot comments

* address null poointers

* fix build errors

* Fix the fix, undo the silly suggestions from synthetic reviewer.

* we all float here

* Fix include path for AdminModule in test_main.cpp

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* More suggestion fixes

* admin module merge conflicts

* admin module fixes from merge hell

* fix: initialize default frequency slot and custom channel name; update LNA mode handling

* save some bytes...

* fix: simplify error logging for bandwidth checks in LoRa configuration

* Update src/mesh/MeshRadio.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-20 07:43:47 -05:00
Ben Meadors fc030d2d19 Merge remote-tracking branch 'origin/master' into develop 2026-03-20 07:43:15 -05:00
Ben Meadors 4a534f02a4 fix(gps): prevent GPS re-enablement in NOT_PRESENT mode 2026-03-19 15:27:59 -05:00
0ea408e12b fix: MQTT settings silently fail to persist when broker is unreachable (#9934)
* fix: MQTT settings silently fail to persist when broker is unreachable

isValidConfig() was testing broker connectivity via connectPubSub() as
part of config validation. When the broker was unreachable (network not
ready, DNS failure, server down), the function returned false, causing
AdminModule to skip saving settings entirely — silently.

This removes the connectivity test from isValidConfig(), which now only
validates configuration correctness (TLS support, default server port).
Connectivity is handled by the MQTT module's existing reconnect loop.

Fixes #9107

* Add client warning notification when MQTT broker is unreachable

Per maintainer feedback: instead of silently saving when the broker
can't be reached, send a WARNING notification to the client saying
"MQTT settings saved, but could not reach the MQTT server."

Settings still always persist regardless of connectivity — the core
fix from the previous commit is preserved. The notification is purely
advisory so users know to double-check their server address and
credentials if the connection test fails.

When the network is not available at all, the connectivity check is
skipped entirely with a log message.

* Address Copilot review feedback

- Fix warning message wording: "Settings will be saved" instead of
  "Settings saved" (notification fires before AdminModule persists)
- Add null check on clientNotificationPool.allocZeroed() to prevent
  crash if pool is exhausted (matches AdminModule::sendWarning pattern)
- Fix test comments to accurately describe conditional connectivity
  check behavior and IS_RUNNING_TESTS compile-out

* Remove connectivity check from isValidConfig entirely

Reverts the advisory connectivity check added in the previous commit.
While the intent was to warn users about unreachable brokers,
connectPubSub() mutates the isConnected state of the running MQTT
module and performs synchronous network operations that can block
the config-save path.

The cleanest approach: isValidConfig() validates config correctness
only (TLS support, default server port). The MQTT reconnect loop
handles connectivity after settings are persisted and the device
reboots. If the broker is unreachable, the user will see it in the
MQTT connection status — no special notification needed.

This returns to the simpler design from the first commit, which was
tested on hardware and confirmed working.

* Use lightweight TCP check instead of connectPubSub for validation

Per maintainer feedback: users need connectivity feedback, but
connectPubSub() mutates the module's isConnected state.

This uses a standalone MQTTClient TCP connection test that:
- Checks if the server IP/port is reachable
- Sends a WARNING notification if unreachable
- Does NOT establish an MQTT session or mutate any module state
- Does NOT block saving — isValidConfig always returns true

The TCP test client is created locally, used, and destroyed within
the function scope. No side effects on the running MQTT module.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-19 13:00:00 -05:00
644d0d4a15 Fix for preserving pki_encrypted and public_key when relaying UDP multicast packets to radio. (#9916)
* Fix for preserving pki_encrypted and public_key when relaying UDP multicast packets to radio.

PKI DMs sent over UDP multicast had their pki_encrypted flag and public_key fields explicitly cleared before being forwarded to the LoRa radio. This caused the receiving node to treat the packet as a channel-encrypted message it couldn't decrypt, silently dropping it.

The MQTT ingress path correctly preserves these fields. The UDP multicast ingress path should behave the same way.

* Zeroize MeshPacket before decoding

Zeroize MeshPacket before decoding to prevent data leakage.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-19 10:52:52 -05:00
f04746a928 Fix RAK4631 Ethernet gateway API connection loss after W5100S brownout (#9754)
* Fix RAK4631 Ethernet gateway API connection loss after W5100S brownout

PoE power instability can brownout the W5100S while the nRF52 MCU keeps
running, causing all chip registers (MAC, IP, sockets) to revert to
defaults. The firmware had no mechanism to detect or recover from this.

Changes:
- Detect W5100S chip reset by periodically verifying MAC address register
  in reconnectETH(); on mismatch, perform full hardware reset and
  re-initialize Ethernet interface and services
- Add deInitApiServer() for clean API server teardown during recovery
- Add ~APIServerPort destructor to prevent memory leaks
- Switch nRF52 from EthernetServer::available() to accept() to prevent
  the same connected client from being repeatedly re-reported
- Add proactive dead-connection cleanup in APIServerPort::runOnce()
- Add 15-minute TCP idle timeout to close half-open connections that
  consume limited W5100S hardware sockets

Fixes meshtastic/firmware#6970

Made-with: Cursor

* Log actual elapsed idle time instead of constant timeout value

Address Copilot review comment: log millis() - lastContactMsec to show
the real time since last client activity, rather than always logging the
TCP_IDLE_TIMEOUT_MS constant.

Made-with: Cursor

* Update src/mesh/api/ServerAPI.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Stop UDP multicast handler during W5100S brownout recovery

After a W5100S chip brownout, the udpHandler isRunning flag stays
true while the underlying socket is dead. Without calling stop(),
the subsequent start() no-ops and multicast is silently broken
after recovery.

Made-with: Cursor

* Address Copilot review: recovery flags and timeout constant

Move ethStartupComplete and ntp_renew reset to immediately after
service teardown, before Ethernet.begin(). Previously, if DHCP
failed the early return left ethStartupComplete=true, preventing
service re-initialization on subsequent retries.

Replace #define TCP_IDLE_TIMEOUT_MS with static constexpr uint32_t
for type safety and better C++ practice.

Made-with: Cursor

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-19 08:37:39 -05:00
WesselandGitHub 1be2529fb9 Enable LNA by default for Heltec v4.3 (#9906)
It should only be disabled by users that have problems with it.
2026-03-19 08:11:10 -05:00
Andrew YongandGitHub 959756abf1 fix(tlora-pager): Remove SDCARD_USE_SPI1 so SX1262 and SD card can share SPI bus (#9870)
Problem:
- Inserting a µSD card causes RadioLib to hit a critical error and reboot
- Device enters a boot loop as the SD card remains inserted

Reproduction:
- Insert a µSD card and power on
- RadioLib reports a critical error on boot
- Device reboots, repeating indefinitely

Root cause:
- On T-Lora Pager, SX1262 and the µSD slot share the same physical SPI bus
  (same SCK/MOSI/MISO pins, differentiated only by CS)
- SDCARD_USE_SPI1 is intended for boards where SD is on a separate SPI bus;
  it initializes a second ESP32 SPI peripheral (SPI3) for SD
- SPI2 is already driving those same pins for LoRa, so both controllers
  simultaneously drive the same GPIO lines, causing bus contention

Fix:
- Remove SDCARD_USE_SPI1 so both devices share a single SPI peripheral (SPI2),
  with CS pins providing device selection as intended
- Tested on a custom fork of device-ui; LoRa and SD card map tiles both work
  correctly with an SD card inserted

Signed-off-by: Andrew Yong <me@ndoo.sg>
2026-03-19 07:20:15 -05:00
c88b802e32 Remove early return during scan of BME address for BMP sensors (#9935)
* Enable pre-hop drop handling by default

* Remove early break if BME/DPS sensors are not detected at the BME address

* revert sneaky change

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-19 07:12:50 -05:00
fw190d13andGitHub 9f74fc11de hexDump: Add const to the buf parameter in hexDump. (#9944)
The function only reads the buffer, so marking it const clarifies intent
and prevents accidental modification.
2026-03-19 06:13:34 -05:00
HarukiToredaandGitHub 2ef09d17b9 BaseUI: Emote Refactoring (#9896)
* Emote refactor for BaseUI

* Trunk Check

* Copilot suggestions
2026-03-17 20:42:37 -05:00
Ben Meadors 19d070c284 Trunk 2026-03-17 14:01:53 -05:00
Ben Meadors e24db2994b Merge remote-tracking branch 'origin/master' into develop 2026-03-17 13:28:25 -05:00
ac7a58cd45 Fix: Traceroute through MQTT misses uplink node if MQTT is encrypted (#9798)
* Attempt to fix issue 9713

* Code formatting issue.

* Remade the fix to follow Copilot observations on PR

* Rebuild after AI and GUVWAF

* Update src/mesh/Router.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/mesh/Router.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* trunk fmt

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: GUVWAF <thijs@havinga.eu>
Co-authored-by: GUVWAF <78759985+GUVWAF@users.noreply.github.com>
2026-03-17 06:48:29 +11:00
53c21eb30d Deprecate/block packets with a missing/invalid hop_start value (pre-hop firmware) (related to issue #7369) (#9476)
* Deprecate forwarding for invalid hop_start

* Add pre-hop packet drop policy

* Log ignored rebroadcasts for pre-hop packets

* Respect pre-hop policy ALLOW in routing gates

* Exempt local packets from pre-hop drop policy

* Format pre-hop log line

* Add MODERN_ONLY rebroadcast mode for pre-hop packets

* Simplify implementation for drop packet only behaviour

* Revert formatting-only changes

* Match ReliableRouter EOF formatting

* Make pre-hop drop a build-time flag

* Rework to compile/build flag MESHTASTIC_PREHOP_DROP

* Set MESHTASTIC_PREHOP_DROP off by default

* Inline pre-hop hop_start validity check

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jord <650645+DivineOmega@users.noreply.github.com>
2026-03-16 06:35:33 -05:00
Tom FifieldandGitHub e51e6cad84 Remove GPS Baudrate locking for Seeed Xiao S3 Kit (#9374)
The Seeed Xiao S3 Kit's default GPS is an L76K which operates at 9600 baud, so when this variant was defined that baud rate was specified.

However, this is a development board and it is expected that users can attach their own devices. This includes GPS, which may operate at a different baud rate. The current fixed baud rate prevents this, so this patch removes that setting.

This will revert to the regular automatic probe method. This will successfully detect the L76K as before (the same speed as before since 9600 baud is the first baud rate checked), but also allow other GPSes at other baud rates to be detected.

Thanks to @ScarpMarc for the report

Fixes https://github.com/meshtastic/firmware/issues/9373#issuecomment-3774802763
2026-03-16 19:28:21 +11:00
TomandGitHub 4890f7084f Add spoof detection for UDP packets in UdpMulticastHandler (#9905)
* Add spoof detection for UDP packets in UdpMulticastHandler

* Implement isFromUs function for packet origin validation

* ampersand
2026-03-14 19:34:19 -05:00
oscgonferandGitHub 3fcbfe4370 Remove a bunch of warnings in SEN5X (#9884) 2026-03-12 06:18:56 -05:00
da808cb43b Automated version bumps (#9886)
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-11 06:52:18 -05:00
Ben Meadors 82580c6798 Initialize LoRaFEMInterface with default fem_type 2026-03-11 06:48:46 -05:00
016e68ec53 Traffic Management Module for packet forwarding logic (#9358)
* Add ESP32 Power Management lessons learned document

Documents our experimentation with ESP-IDF DFS and why it doesn't
work well for Meshtastic (RTOS locks, BLE locks, USB issues).

Proposes simpler alternative: manual setCpuFrequencyMhz() control
with explicit triggers for when to go fast vs slow.

* Addition of traffic management module

* Fixing compile issues, but may still need to update protobufs.

* Fixing log2Floor in cuckoo hash function

* Adding support for traffic management in PhoneAPI.

* Making router_preserve_hops work without checking if the previous hop was a router. Also works for CLIENT_BASE.

* Adding station-g2 and portduino varients to be able to use this module.

* Spoofing from address for nodeinfo cache

* Changing name and behavior for zero_hop_telemetry / zero_hop_position

* Name change for exhausting telemetry packets and setting hop_limit to 1 so it will be 0 when sent.

* Updated hop logic, including exhaustRequested flag to bypass some checks later in the code.

* Reducing memory on nrf52 nodes further to 12 bytes per entry, 12KB total using 8 bit hashes with 0.4% collision. Probably ok. Adding portduino to the platforms that don't need to worry about memory as much.

* Fixing hopsAway for nodeinfo responses.

* traffic_management.nodeinfo_direct_response_min_hops -> traffic_management.nodeinfo_direct_response_max_hops

* Removing dry run mode

* Updates to UnifiedCacheEntry to use a common cache, created defaults for some values, reduced a couple bytes per entry by using a resolution-scale time selection based on configuration value.

* Enhance traffic management logging and configuration. Updated log messages in NextHopRouter and Router to include more context. Adjusted traffic management configuration checks in AdminModule and improved cache handling in TrafficManagementModule. Ensured consistent enabling of traffic management across various variants.

* Implement destructor for TrafficManagementModule and improve cache allocation handling. The destructor ensures proper deallocation of cache memory based on its allocation source (PSRAM or heap). Additionally, updated cache allocation logic to log warnings only when PSRAM allocation fails.

* Update TrafficManagementModule with enhanced comments for clarity and improve cache handling logic. Update protobuf submodule to latest commit.

* Creating consistent log messages

* Remove docs/ESP32_Power_Management.md from traffic_module

* Add unit tests for Traffic Management Module functionality

* Fixing compile issues, but may still need to update protobufs.

* Adding support for traffic management in PhoneAPI.

* Making router_preserve_hops work without checking if the previous hop was a router. Also works for CLIENT_BASE.

* Enhance traffic management logging and configuration. Updated log messages in NextHopRouter and Router to include more context. Adjusted traffic management configuration checks in AdminModule and improved cache handling in TrafficManagementModule. Ensured consistent enabling of traffic management across various variants.

* Implement destructor for TrafficManagementModule and improve cache allocation handling. The destructor ensures proper deallocation of cache memory based on its allocation source (PSRAM or heap). Additionally, updated cache allocation logic to log warnings only when PSRAM allocation fails.

* Update TrafficManagementModule with enhanced comments for clarity and improve cache handling logic. Update protobuf submodule to latest commit.

* Add mock classes and unit tests for Traffic Management Module functionality.

* Refactor setup and loop functions in test_main.cpp to include extern "C" linkage

* Update comment to include reduced  memory requirements

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Re-arranging comments for programmers with the attention span of less than 5 lines of code.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update comments in TrafficManagementModule to reflect changes in timestamp epoch handling and memory optimization details.

* bug: Use node-wide config_ok_to_mqtt setting for cached NodeInfo replies.

* Better way to handle clearing the ok_to_mqtt bit

* Add bucketing to cuckoo hashing, allowing for 95% occupied rate before major eviction problems.

* Extend nodeinfo cache for psram devices.

* Refactor traffic management to make hop exhaustion packet-scoped. Nice catch.

* Implement better position precision sanitization in TrafficManagementModule.

* Added logic in TrafficManagementModule to invalidate stale traffic state. Also, added some tests to avoid future me from creating a regression here.

* Fixing tests for native

* Enhance TrafficManagementModule to improve NodeInfo response handling and position deduplication logic. Added tests to ensure local packets bypass transit filters and that NodeInfo requests correctly update the requester information in the cache. Updated deduplication checks to prevent dropping valid position packets under certain conditions.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 06:12:12 -05:00
79f469ce71 pioarduino Heltec v4: fix build due to LED_BUILTIN compile error. (#9875)
Fixes this build error:

  <command-line>: error: expected unqualified-id before '-' token
  /home/<snip>/.platformio/packages/framework-arduinoespressif32/variants/esp32s3/pins_arduino.h:15:22: note: in expansion of macro 'LED_BUILTIN'
     15 | static const uint8_t LED_BUILTIN = SOC_GPIO_PIN_COUNT + PIN_RGB_LED;
        |                      ^~~~~~~~~~~

More info: https://github.com/meshtastic/firmware/pull/9122#issuecomment-4028263894

The fix is consistent with variants/esp32s3/heltec_v3/platformio.ini

This commit is intentionally on the develop branch, because it's harmless to
develop branch, and makes us more ready for pioarduino when the time comes.

Heltec v4 introduced in: https://github.com/meshtastic/firmware/pull/7845

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-10 06:19:25 -05:00
0501e177e9 T-mini Eink S3 Support for both InkHUD and BaseUI (#9856)
* Tmini Eink fix

* tuning

* better refresh

* Fix to lora pins to be like the original.

* Update pins_arduino.h

* removed dead flags from previous tests

* Update src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 17:37:34 -05:00
624 changed files with 52392 additions and 10745 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "f=$(tr -d '\\n' | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 | sed 's/.*:[[:space:]]*\"//; s/\"$//'); [ -n \"$f\" ] && [ -f \"$f\" ] || exit 0; t=$(command -v trunk || echo \"$HOME/.cache/trunk/launcher/trunk\"); [ -x \"$t\" ] || { echo \"trunk-fmt hook: trunk not found; its launcher needs curl or wget to bootstrap the CLI (see 'Formatting & the trunk toolchain' in .github/copilot-instructions.md)\" >&2; exit 1; }; out=$(\"$t\" fmt --force \"$f\" 2>&1) || { echo \"trunk-fmt hook: trunk fmt failed on $f: $out\" >&2; exit 1; }",
"timeout": 120,
"statusMessage": "Formatting (trunk)..."
}
]
}
]
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ ENV PIP_ROOT_USER_ACTION=ignore
# trunk-ignore(hadolint/DL3008): apt packages are not pinned.
# trunk-ignore(terrascan/AC_DOCKER_0002): apt packages are not pinned.
RUN apt-get update && apt-get install --no-install-recommends -y \
cmake git zip libgpiod-dev libbluetooth-dev libi2c-dev \
cmake git zip libgpiod-dev libjsoncpp-dev libbluetooth-dev libi2c-dev \
libunistring-dev libmicrohttpd-dev libgnutls28-dev libgcrypt20-dev \
libusb-1.0-0-dev libssl-dev pkg-config libsqlite3-dev libsdl2-dev && \
apt-get clean && rm -rf /var/lib/apt/lists/* && \
+1 -1
View File
@@ -31,7 +31,7 @@ cmake --install "$WORK/ulfius/$SANITIZER" --prefix /usr
cd "$SRC/firmware"
PLATFORMIO_EXTRA_SCRIPTS=$(echo -e "pre:.clusterfuzzlite/platformio-clusterfuzzlite-pre.py\npost:.clusterfuzzlite/platformio-clusterfuzzlite-post.py")
STATIC_LIBS=$(pkg-config --libs --static libulfius openssl libgpiod yaml-cpp bluez --silence-errors)
STATIC_LIBS=$(pkg-config --libs --static libulfius openssl libgpiod yaml-cpp jsoncpp bluez --silence-errors)
export PLATFORMIO_EXTRA_SCRIPTS
export STATIC_LIBS
export PLATFORMIO_WORKSPACE_DIR="$WORK/pio/$SANITIZER"
+1
View File
@@ -16,6 +16,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
libssl-dev \
libulfius-dev \
libyaml-cpp-dev \
libjsoncpp-dev \
pipx \
pkg-config \
python3 \
+1 -1
View File
@@ -13,7 +13,7 @@ runs:
shell: bash
run: |
sudo apt-get -y update --fix-missing
sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev lsb-release
sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev lsb-release
- name: Setup Python
uses: actions/setup-python@v6
+1 -1
View File
@@ -11,4 +11,4 @@ runs:
- name: Install libs needed for native build
shell: bash
run: |
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
+142 -8
View File
@@ -135,6 +135,116 @@ On top of authorization, any remote admin message that **mutates** state (not a
- **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast.
- **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo.
## NodeDB Layout (v25)
`DEVICESTATE_CUR_VER = 25`, `DEVICESTATE_MIN_VER = 24`. The on-device NodeDB was split in v25 into a slim header table plus four optional satellite stores. Older v24 saves auto-migrate at boot. Old training-data instincts (`node->user.long_name`, `node->position.latitude_i`, `node->is_favorite`, `node->device_metrics.battery_level`) are wrong now — the fields aren't there. Read this section before touching anything that walks `nodeDB->meshNodes`.
### Slim `NodeInfoLite`
`UserLite` is flattened onto `NodeInfoLite` (no nested sub-message); `position` and `device_metrics` are removed entirely (tags reserved). MAC address is dropped. Long names are capped at 25 chars (`max_size:25` in `deviceonly.options`); `hw_model` and `role` are `int_size:8`. Encoded size dropped from ~166 B → ~105 B per node.
Booleans are bit-packed into `NodeInfoLite.bitfield`. **Do not read or write the bits directly** — use the inline helpers in `src/mesh/NodeDB.h`:
```cpp
nodeInfoLiteHasUser(n) // bit 5 — user fields populated
nodeInfoLiteIsFavorite(n) // bit 3
nodeInfoLiteIsIgnored(n) // bit 4
nodeInfoLiteIsMuted(n) // bit 1
nodeInfoLiteIsLicensed(n) // bit 6 — Ham mode peer
nodeInfoLiteIsKeyManuallyVerified(n) // bit 0
nodeInfoLiteHasIsUnmessagable(n) // bit 8 — "is_unmessagable was sent"
nodeInfoLiteIsUnmessagable(n) // bit 7
// via_mqtt is bit 2 (mask exposed; predicate uses the mask directly)
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); // setter
```
### Satellite stores
Four `std::unordered_map<NodeNum, …>` members on `NodeDB`, each gated by its own build flag:
| Map | Value type | Build flag |
| ----------------- | ------------------------------- | ---------------------------------- |
| `nodePositions` | `meshtastic_PositionLite` | `MESHTASTIC_EXCLUDE_POSITIONDB` |
| `nodeTelemetry` | `meshtastic_DeviceMetrics` | `MESHTASTIC_EXCLUDE_TELEMETRYDB` |
| `nodeEnvironment` | `meshtastic_EnvironmentMetrics` | `MESHTASTIC_EXCLUDE_ENVIRONMENTDB` |
| `nodeStatus` | `meshtastic_StatusMessage` | `MESHTASTIC_EXCLUDE_STATUSDB` |
Defaults are ON (i.e., maps **excluded**) for STM32WL only — see `src/mesh/mesh-pb-constants.h`. On every other arch all four maps are present. When excluded, the map member is absent and the corresponding accessors return `false`.
All four maps are guarded by **`mutable concurrency::Lock satelliteMutex`** — concurrent access from receive threads, the phone API state machine, and the renderer is the rule, not the exception.
### Accessor convention
**Never hand out pointers into the maps.** Use the copy-out accessors on `NodeDB`:
```cpp
bool copyNodePosition(NodeNum, meshtastic_PositionLite &out) const;
bool copyNodeTelemetry(NodeNum, meshtastic_DeviceMetrics &out) const;
bool copyNodeEnvironment(NodeNum, meshtastic_EnvironmentMetrics &out) const;
bool copyNodeStatus(NodeNum, meshtastic_StatusMessage &out) const;
```
Each takes the lock, copies the value if present, returns `false` if the entry is absent or the DB is excluded. Pass-by-out-param is deliberate — pointer-style accessors would invite UAF and lock-leak bugs across the renderer. The "has any X" convenience predicates (`hasValidPosition` etc.) are implemented in terms of these.
Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which dispatches on `which_variant` for device vs environment metrics) — these own the lock and the eviction hooks.
### Eviction
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `demoteOldestHotNodesToWarm` (the over-cap warm-tier migration), `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly. (Note: `enforceSatelliteCaps`/`evictSatelliteOverCap` call `.erase()` directly on purpose — that's a satellite-only cap trim where the node _stays_ in the header, a different operation from this chokepoint.)
### Warm tier (long-tail identity)
On every arch except STM32WL and bare nRF52832 (`WARM_NODE_COUNT > 0`), a node evicted from the header table is not forgotten outright: `WarmNodeStore` (`src/mesh/WarmNodeStore.{h,cpp}`) keeps a 40 B `{num, last_heard, public_key}` record per evicted node — primarily so PKI DMs to/from a long-tail node keep decrypting without re-running a NodeInfo exchange (the rest of `NodeInfoLite` rebuilds from traffic in seconds).
- **Write:** `getOrCreateMeshNode`'s eviction and `demoteOldestHotNodesToWarm` (the over-cap boot migration) call `warmStore.absorb(num, last_heard, key)` _before_ the node leaves the header.
- **Read-back:** `getOrCreateMeshNode` calls `warmStore.take()` to rehydrate `last_heard` + key when a warm node is re-admitted; `copyPublicKey()` falls back to the warm tier so the PKI send path finds keys for evicted peers.
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring at `0xEA000` (below LittleFS; append + replay + compact-on-rotate, link-guarded by `nrf52840_s140_v7.ld` and `extra_scripts/nrf52_warm_region.py`). Everywhere else: a `/prefs/warm.dat` snapshot flushed by `saveIfDirty()` on the node-DB save cadence.
- **Tunables** (`mesh-pb-constants.h`): `WARM_NODE_COUNT` (per-arch; `0` disables the tier) and `MAX_NUM_NODES` (hot cap — 120 on nRF52840/generic ESP32 to fit the 28 KB LittleFS; ESP32-S3 keeps its flash-scaled 100/200/250, portduino 250). Verbose migration/self-care tracing routes through `LOG_MIGRATION`, gated by `MESHTASTIC_NODEDB_MIGRATION_VERBOSE`.
### Satellite caps
Only the freshest `MAX_SATELLITE_NODES` nodes keep satellite payloads; the rest of the header table carries just the `NodeInfoLite`. The cap is **per-platform**: 40 on RAM-constrained parts (nRF52840, generic ESP32) since the four maps live in internal SRAM (not PSRAM, ~408 B/node across the four), and 250 on flash-rich hosts (ESP32-S3, portduino) so every hot node can carry rich data as before the cap existed. `enforceSatelliteCaps()` trims each map to the cap on load (returns whether it trimmed); `evictSatelliteOverCap()` trims before each insert. Eviction is by the owning node's hot `last_heard` (stalest first, demoted/absent nodes rank as `last_heard==0`); self is never trimmed.
### On-boot self-care
`NodeDB::nodeDBSelfCare()` runs once identity is established (the constructor after key (re)gen, and `reloadFromDisk()`_not_ inside `loadFromDisk`, where `getNodeNum()` is still 0). It confirms self is present (warns if a non-empty DB is missing us — a foreign/over-cap file), pins self to index 0, demotes/trims only **non-self** overflow into the warm tier, then rewrites `nodes.proto` **once** and only if it healed something — and never while encrypted storage is locked (it would persist placeholder defaults). `loadFromDisk` deliberately leaves the loaded store untrimmed for this pass.
### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in)
There is no capability flag and no special "gradient" nonce. The **default** sync flow is:
1. Config / module-config / channel / metadata segments (same as before).
2. `STATE_SEND_OWN_NODEINFO`**our own** NodeInfo, still bundled with our position and device_metrics (because the replay snapshot excludes our own NodeNum). Emitted via `ConvertToNodeInfo(lite)`.
3. `STATE_SEND_OTHER_NODEINFOS` — every other peer's NodeInfo, **always thin** (no `position`, no `device_metrics`). Emitted via `ConvertToNodeInfoThin(lite)`.
4. `STATE_SEND_FILEMANIFEST``STATE_SEND_COMPLETE_ID` — the phone sees `config_complete_id` and treats sync as done.
5. `STATE_SEND_PACKETS` — live mesh packets, with a trailing replay drain interleaved. The replay drain walks four cached satellite stores in order (positions → telemetry → environment → status) and emits each cached entry as an ordinary `MeshPacket` on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` device + environment variants, `NODE_STATUS_APP`). These are indistinguishable on the wire from live mesh traffic, so clients need no special handling — any code that already updates UI on `POSITION_APP` etc. works.
`PhoneAPI::sendConfigComplete()` arms `replayPhase = REPLAY_PHASE_POSITIONS` for default/full sync and `SPECIAL_NONCE_ONLY_NODES`, while `SPECIAL_NONCE_ONLY_CONFIG` skips replay. The drain runs inside `STATE_SEND_PACKETS` via `popReplayPacket()`, lower priority than live traffic. When all four phases drain, `replayPhase` flips back to `REPLAY_PHASE_IDLE` and the snapshot vectors get `shrink_to_fit`ed.
STM32WL and any other build with all four `MESHTASTIC_EXCLUDE_*DB` flags set produces zero replay packets — `popReplayPacket` advances through each phase in microseconds without emitting anything.
Special nonces that still mean something:
- `SPECIAL_NONCE_ONLY_CONFIG` (69420) — skip node sync entirely, just config.
- `SPECIAL_NONCE_ONLY_NODES` (69421) — skip config segments, jump straight to `STATE_SEND_OWN_NODEINFO`. Still gets the post-COMPLETE_ID replay drain.
There are no other reserved nonces; everything else is a fresh random `want_config_id` from the client.
### v24 → v25 migration
The legacy migration code lives in **`src/mesh/NodeDBLegacyMigration.cpp`**, not in `NodeDB.cpp`. It owns the `meshtastic_NodeDatabase_Legacy` callback and `NodeDB::migrateLegacyNodeDatabase()`. The legacy proto descriptor is `protobufs/meshtastic/deviceonly_legacy.proto` (only included by the migration TU). The boot path peeks the file's leading version tag, runs the migration if `version < 25`, then re-saves in v25 layout. The legacy descriptor is scheduled for removal once `DEVICESTATE_MIN_VER` is bumped.
### Read-site rules of thumb
- Never `node->position.X` / `node->device_metrics.X` — those fields no longer exist. Pull from the satellite map via `copyNodePosition` / `copyNodeTelemetry`.
- Never `node->user.long_name``long_name`, `short_name`, `public_key`, `hw_model`, `role`, `macaddr` (gone), `is_licensed`, `is_unmessagable` are flat on `NodeInfoLite`.
- Never `node->is_favorite` / `node->is_ignored` / `node->via_mqtt` / `node->is_key_manually_verified` — use the bitfield helpers.
- Never assume `nodeDB->getMeshNode(num)->position.time` — call `copyNodePosition` and check the return.
- Don't lock `satelliteMutex` yourself in renderer code; the copy-out accessors already do.
Unit tests for the conversion layer live in `test/test_type_conversions/test_main.cpp` (Unity) — bitfield round-trips, `long_name` truncation, thin-vs-full conversions. Add cases there when extending the schema.
## Project Structure
```
@@ -190,6 +300,15 @@ firmware/
## Coding Conventions
### Formatting & the trunk toolchain
`trunk fmt` is the project formatter (`trunk_check` CI rejects unformatted code). For Claude Code users, `.claude/settings.json` ships a PostToolUse hook that runs `trunk fmt --force` on every file the agent writes or edits. The hook is pure sh/grep/sed — no python or jq required — but trunk itself must be able to run:
- Trunk's launcher (`~/.cache/trunk/launcher/trunk`, or `trunk` on PATH) downloads the CLI version pinned in `.trunk/trunk.yaml` on first use and again whenever that pin is bumped. **The launcher needs `curl` or `wget`**; without one it fails with "Cannot download… please install curl or wget", and the hook surfaces that as a warning on every write.
- No curl/wget available (e.g. a minimal WSL image)? Bootstrap by hand with any Python (PlatformIO bundles one at `~/.platformio/penv/bin/python`): download `https://trunk.io/releases/<ver>/trunk-<ver>-linux-x86_64.tar.gz` and place the `trunk` binary at `~/.cache/trunk/cli/<ver>-linux-x86_64/trunk` (chmod +x), where `<ver>` is the `cli.version` from `.trunk/trunk.yaml`.
- The hook fails loudly by design (visible warning, non-blocking). Silent no-op formatting hooks hide real breakage — don't re-add `2>/dev/null || true` around the whole thing.
- More generally: don't assume a stock Linux userland in hooks or helper scripts — minimal WSL/container images may lack `python3`, `curl`, `wget`, and `jq`. Prefer plain sh + coreutils, or PlatformIO's bundled Python for anything heavier.
### General Style
- Follow existing code style - run `trunk fmt` before commits
@@ -516,20 +635,35 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing.
### Native unit tests (C++)
Unit tests in `test/` directory with 12 test suites:
Unit tests in `test/` directory with 17 test suites:
- `test_crypto/` - Cryptography
- `test_mqtt/` - MQTT integration
- `test_radio/` - Radio interface
- `test_mesh_module/` - Module framework
- `test_meshpacket_serializer/` - Packet serialization
- `test_transmit_history/` - Retransmission tracking
- `test_admin_radio/` - LoRa region/config validation and AdminModule dispatch
- `test_atak/` - ATAK integration
- `test_crypto/` - Cryptography
- `test_default/` - Default configuration
- `test_http_content_handler/` - HTTP handling
- `test_mac_from_string/` - MAC address parsing
- `test_mesh_module/` - Module framework
- `test_meshpacket_serializer/` - Packet serialization
- `test_mqtt/` - MQTT integration
- `test_packet_history/` - Packet history tracking
- `test_position_precision/` - Position precision helpers
- `test_radio/` - Radio interface
- `test_serial/` - Serial communication
- `test_traffic_management/` - Traffic management
- `test_transmit_history/` - Retransmission tracking
- `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite)
- `test_utf8/` - UTF-8 utilities
Run with: `pio test -e native`
Run command (preferred — avoids pipe-buffering and the Ubuntu externally-managed-environment error):
```bash
~/.platformio/penv/bin/python -m platformio test -e native -f test_your_suite > /tmp/test_out.txt 2>&1
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
tail -15 /tmp/test_out.txt
```
Do **not** use `pio test … | tail -N` — it discards build errors and shows stale cached results. Do **not** use `pio test … | grep` — line-buffering makes the terminal appear hung until the process exits. Redirect to a file first, then grep.
Simulation testing: `bin/test-simulator.sh`
+39 -25
View File
@@ -4,9 +4,14 @@ on:
workflow_dispatch:
inputs:
# trunk-ignore(checkov/CKV_GHA_7)
target:
type: string
required: false
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
arch:
type: choice
options:
- all
- esp32
- esp32s3
- esp32c3
@@ -15,32 +20,18 @@ on:
- rp2040
- rp2350
- stm32
target:
type: string
required: false
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
# find-target:
# type: boolean
# default: true
# description: 'Find the available targets'
description: Choose an arch to limit the search, or 'all' to search all architectures.
default: all
permissions: read-all
jobs:
find-targets:
if: ${{ inputs.target == '' }}
strategy:
fail-fast: false
matrix:
arch:
- esp32
- esp32s3
- esp32c3
- esp32c6
- nrf52840
- rp2040
- rp2350
- stm32
- all
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -51,14 +42,37 @@ jobs:
- run: pip install -U platformio
- name: Generate matrix
id: jsonStep
env:
BUILDTARGET: ${{ inputs.target }}
MATRIXARCH: ${{ inputs.arch }}
run: |
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra)
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
echo "Targets:" >> $GITHUB_STEP_SUMMARY
echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $GITHUB_STEP_SUMMARY
if [ "$BUILDTARGET" = "" ]; then
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
echo $TARGETS | jq -r 'sort_by(.board) | sort_by(.platform) |.[] | "| " + .platform + " | " + .board + " |" ' >> $GITHUB_STEP_SUMMARY
else
echo "We build this one:" >> $GITHUB_STEP_SUMMARY
ARCH=$(echo "$TARGETS" | jq --arg BUILDTARGET "$BUILDTARGET" -r '.[] | select(.board==$BUILDTARGET) | .platform')
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
echo "| $ARCH | "$BUILDTARGET" |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "$ARCH" == "" ]]; then
echo "## ❌ Error: Target "$BUILDTARGET" not found!" >> $GITHUB_STEP_SUMMARY
else
echo "## ✅ Target "$BUILDTARGET" found, proceeding to build." >> $GITHUB_STEP_SUMMARY
fi
echo "You may need to refresh this page to make the built firmware appear below." >> $GITHUB_STEP_SUMMARY
echo "arch=$ARCH" >> $GITHUB_OUTPUT
fi
outputs:
arch: ${{ steps.jsonStep.outputs.arch }}
version:
if: ${{ inputs.target != '' }}
@@ -78,12 +92,12 @@ jobs:
build:
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
needs: [version]
needs: [version, find-targets]
uses: ./.github/workflows/build_firmware.yml
with:
version: ${{ needs.version.outputs.long }}
pio_env: ${{ inputs.target }}
platform: ${{ inputs.arch }}
platform: ${{ needs.find-targets.outputs.arch }}
gather-artifacts:
permissions:
@@ -0,0 +1,62 @@
name: Post Firmware Size Comment
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
pull-requests: write
actions: read
jobs:
post-size-comment:
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion != 'cancelled' &&
github.repository == 'meshtastic/firmware'
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Download size report
id: download
uses: actions/download-artifact@v8
continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: size-report
path: ./
- name: Post or update PR comment
if: steps.download.outcome == 'success'
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const marker = '<!-- firmware-size-report -->';
const body = fs.readFileSync('./size-report.md', 'utf8');
const prNumber = parseInt(fs.readFileSync('./pr-number.txt', 'utf8').trim(), 10);
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}
+187
View File
@@ -0,0 +1,187 @@
name: Post Web Flasher Link Comment
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
pull-requests: write
actions: read
jobs:
post-flasher-link:
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion != 'cancelled' &&
github.repository == 'meshtastic/firmware'
continue-on-error: true
runs-on: ubuntu-latest
steps:
# Per-board manifests carry the firmware's own metadata (activelySupported,
# displayName, ...) generated from each target's custom_meshtastic_* config.
- name: Download board manifests
uses: actions/download-artifact@v8
continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: manifest-*
path: ./manifests
merge-multiple: true
- name: Post or update web flasher link comment
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- web-flasher-link -->';
const run = context.payload.workflow_run;
const { owner, repo } = context.repo;
// Resolve the PR by matching the run's head SHA against the repo's open
// PRs. workflow_run.pull_requests is empty for fork PRs, and
// listPullRequestsAssociatedWithCommit won't return an open fork PR by
// its head commit — but pulls.list includes fork PRs. Matching on head
// SHA also enforces that the run is for the PR's current commit, so stale
// re-runs of an outdated commit won't match.
const openPrs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
const pr = openPrs.find((p) => p.head.sha === run.head_sha);
if (!pr) {
core.info(`No open pull request matches commit ${run.head_sha}; skipping.`);
return;
}
const prNumber = pr.number;
// Restrict to trusted authors. NOTE: author_association is computed for
// the GITHUB_TOKEN, which cannot see *private/concealed* org memberships —
// those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER
// alone silently excludes most maintainers. We allow the trusted set the
// token can actually identify (members, collaborators, and anyone with a
// previously merged PR). For strict members-only you'd need an org-read
// App/PAT token to call orgs.checkMembershipForUser.
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (!allowedAssociations.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
return;
}
// Require at least one per-arch firmware artifact from gather-artifacts
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner, repo, run_id: run.id, per_page: 100,
});
const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
if (archArtifacts.length === 0) {
core.info('No per-arch firmware artifacts found; skipping.');
return;
}
const version = archRe.exec(archArtifacts[0].name)[2];
const expiresAt = archArtifacts[0].expires_at
? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
: null;
// Read each built board's manifest (.mt.json). activelySupported,
// displayName and architecture come straight from the board's
// custom_meshtastic_* platformio config, so the list is in sync with
// the firmware itself — no external device database needed.
const fs = require('fs');
let boards = [];
try {
boards = fs.readdirSync('./manifests')
.filter((f) => f.endsWith('.mt.json'))
.map((f) => {
try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); }
catch { return null; }
})
.filter((m) => m && m.activelySupported === true && m.platformioTarget)
.map((m) => ({
board: m.platformioTarget,
platform: m.architecture || '',
// displayName is maintainer-authored text; escape table-breaking pipes
displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'),
image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '',
}))
.sort((a, b) => a.board.localeCompare(b.board));
} catch (e) {
core.warning(`Could not read board manifests: ${e.message}`);
}
const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
// Device illustrations are served by the flasher from the same image
// names the manifest declares (custom_meshtastic_images). The flasher
// serves its SPA shell (HTML, 200) for unknown paths, so confirm each
// image really resolves to an image before linking it.
const imageBase = 'https://flasher.meshtastic.org/img/devices/';
await Promise.all(boards.map(async (b) => {
if (!b.image) return;
try {
const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`);
const type = res.headers.get('content-type') || '';
if (!res.ok || !type.startsWith('image/')) b.image = '';
} catch { b.image = ''; }
}));
const boardLines = boards
.map((b) => {
const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : '';
return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`;
})
.join('\n');
// Shields.io badges. Only non-user-controlled, charset-constrained values
// (version, commit sha, counts, dates) go into badge URLs — never board
// names or the PR title — so the rendered comment cannot be spoofed.
const shieldText = (s) =>
encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
const shield = (label, message, color) =>
`https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`;
const buttonUrl =
`https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`;
const badges = [
`![firmware](${shield('firmware', version, '67EA94')})`,
`![commit](${shield('commit', run.head_sha.slice(0, 7), '2C2D3C')})`,
`![boards](${shield('boards', boards.length, '5C6BC0')})`,
];
if (expiresAt) badges.push(`![expires](${shield('expires', expiresAt, '9A4E00')})`);
// Only render the board table when there are supported boards to list
const boardTable = boards.length > 0 ? [
`<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
'',
'| | Device | Board | Platform |',
'| --- | --- | --- | --- |',
boardLines,
'',
'</details>',
'',
] : [];
const body = [
marker,
'## ⚡ Try this PR in the Web Flasher',
'',
`[![Flash this PR in the Web Flasher](${buttonUrl})](${flasherUrl})`,
'',
badges.join(' '),
'',
'> [!WARNING]',
'> This is an automated, unreviewed CI test build. Back up your device configuration',
'> before flashing, and only flash devices you are able to recover.',
'',
...boardTable,
`*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
].join('\n');
// Sticky comment: update in place when the marker is found
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: prNumber, per_page: 100,
});
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
}
@@ -0,0 +1,62 @@
name: Post Web Flasher Build Placeholder
# Drops an immediate "build in progress" comment when a PR opens, so the web
# flasher entry shows up right away. The real CI-driven workflow
# (flasher-link-comment.yml) later replaces it in place via the shared marker.
#
# SECURITY: this uses pull_request_target (write token, runs for fork PRs) but is
# safe because it never checks out or runs PR code and posts a fully static body
# — no PR title, branch name, or other untrusted input is used anywhere.
on:
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
jobs:
post-placeholder:
if: github.repository == 'meshtastic/firmware'
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Post web flasher build-in-progress placeholder
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- web-flasher-link -->';
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
// Trusted authors only (matches the real workflow). author_association
// can't reflect private org membership for the token, so concealed
// members appear as CONTRIBUTOR — include it, or maintainers are excluded.
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (!allowedAssociations.includes(pr.author_association)) {
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
return;
}
// Only seed a placeholder when no flasher comment exists yet — never
// overwrite a real (or existing placeholder) comment.
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
if (comments.some((c) => c.body?.includes(marker))) {
core.info('Flasher comment already exists; nothing to do.');
return;
}
const body = [
marker,
'## ⚡ Try this PR in the Web Flasher',
'',
'> [!NOTE]',
'> Building this pull request… the flash button, badges and supported-board',
'> list will appear here automatically once CI finishes.',
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body,
});
+109 -28
View File
@@ -82,8 +82,9 @@ jobs:
fail-fast: false
matrix:
check: ${{ fromJson(needs.setup.outputs.check) }}
# Use 'arctastic' self-hosted runner pool when checking in the main repo
runs-on: ${{ github.repository_owner == 'meshtastic' && 'arctastic' || 'ubuntu-latest' }}
# Runs on GitHub-hosted runners so checks don't compete with builds for the
# self-hosted 'arctastic' pool (which builds use).
runs-on: ubuntu-latest
if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
steps:
- uses: actions/checkout@v6
@@ -245,47 +246,126 @@ jobs:
path: ./*.elf
retention-days: 30
shame:
firmware-size-report:
if: github.repository == 'meshtastic/firmware'
continue-on-error: true
permissions:
contents: read
actions: read
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v6
if: github.event_name == 'pull_request'
with:
filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head)
fetch-depth: 0
- name: Download the current manifests
- name: Download current manifests
uses: actions/download-artifact@v8
with:
path: ./manifests-new/
path: ./manifests/
pattern: manifest-*
merge-multiple: true
- name: Upload combined manifests for later commit and global stats crunching.
- name: Collect current firmware sizes
run: python3 bin/collect_sizes.py ./manifests/ ./current-sizes.json
- name: Upload size report artifact
uses: actions/upload-artifact@v7
id: upload-manifest
with:
name: manifests-${{ github.sha }}
name: firmware-sizes-${{ github.sha }}
overwrite: true
path: manifests-new/*.mt.json
- name: Find the merge base
path: ./current-sizes.json
retention-days: 90
- name: Download baseline sizes from develop
if: github.event_name == 'pull_request'
run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV
continue-on-error: true
id: baseline-develop
env:
base: ${{ github.base_ref }}
head: ${{ github.sha }}
# Currently broken (for-loop through EVERY artifact -- rate limiting)
# - name: Download the old manifests
# if: github.event_name == 'pull_request'
# run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/
# env:
# GH_TOKEN: ${{ github.token }}
# merge_base: ${{ env.MERGE_BASE }}
# repo: ${{ github.repository }}
# - name: Do scan and post comment
# if: github.event_name == 'pull_request'
# run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/
GH_TOKEN: ${{ github.token }}
run: |
RUN_ID=$(gh run list -R "${{ github.repository }}" \
--workflow CI --branch develop --status success \
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
if [ -n "$ARTIFACT_NAME" ]; then
gh run download "$RUN_ID" -R "${{ github.repository }}" \
--name "$ARTIFACT_NAME" --dir ./baseline-develop/
cp "./baseline-develop/current-sizes.json" ./develop-sizes.json
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Download baseline sizes from master
if: github.event_name == 'pull_request'
continue-on-error: true
id: baseline-master
env:
GH_TOKEN: ${{ github.token }}
run: |
RUN_ID=$(gh run list -R "${{ github.repository }}" \
--workflow CI --branch master --status success \
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
if [ -n "$RUN_ID" ]; then
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
if [ -n "$ARTIFACT_NAME" ]; then
gh run download "$RUN_ID" -R "${{ github.repository }}" \
--name "$ARTIFACT_NAME" --dir ./baseline-master/
cp "./baseline-master/current-sizes.json" ./master-sizes.json
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Generate size comparison report
if: github.event_name == 'pull_request'
id: report
run: |
ARGS="./current-sizes.json"
if [ -f ./develop-sizes.json ]; then
ARGS="$ARGS --baseline develop:./develop-sizes.json"
fi
if [ -f ./master-sizes.json ]; then
ARGS="$ARGS --baseline master:./master-sizes.json"
fi
REPORT=$(python3 bin/size_report.py $ARGS)
if [ -z "$REPORT" ]; then
echo "has_report=false" >> "$GITHUB_OUTPUT"
else
echo "has_report=true" >> "$GITHUB_OUTPUT"
{
echo '<!-- firmware-size-report -->'
echo '# Firmware Size Report'
echo ''
echo "$REPORT"
echo ''
echo '---'
echo "*Updated for ${{ github.sha }}*"
} > ./size-report.md
cat ./size-report.md >> "$GITHUB_STEP_SUMMARY"
fi
- name: Save PR number
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
run: echo "${{ github.event.pull_request.number }}" > ./pr-number.txt
- name: Upload size report
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
uses: actions/upload-artifact@v7
with:
name: size-report
path: |
./size-report.md
./pr-number.txt
retention-days: 5
release-artifacts:
permissions: # Needed for 'gh release upload'.
@@ -333,6 +413,7 @@ jobs:
prerelease: true
name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha
tag_name: v${{ needs.version.outputs.long }}
target_commitish: ${{ github.sha }}
body: ${{ steps.release_notes.outputs.notes }}
- name: Download source deb
+36 -2
View File
@@ -37,13 +37,33 @@ jobs:
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
- 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,
# not run to GitHub's 6-hour limit.
timeout-minutes: 5
run: |
.pio/build/coverage/meshtasticd -s &
PID=$!
trap 'kill "$PID" 2>/dev/null || true' EXIT
timeout 20 bash -c "until ls -al /proc/$PID/fd | grep socket; do sleep 1; done"
echo "Simulator started, launching python test..."
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
wait
# The Python harness sends exit_simulator and exits; the simulator is
# expected to terminate on its own. Give it a moment, then verify.
# If it is still alive the exit handshake is broken — fail loudly and
# do NOT fall through to `wait`, which would otherwise block until the
# job's hard timeout.
for i in $(seq 1 10); do
kill -0 "$PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$PID" 2>/dev/null; then
echo "::error title=Simulator did not exit::meshtasticd ignored exit_simulator and is still running after the integration test. The exit_simulator admin path is broken (see AdminModule::handleReceivedProtobuf, ARCH_PORTDUINO bypass). Killing it to avoid a 6-hour CI overrun."
kill -9 "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
exit 1
fi
wait "$PID" 2>/dev/null || true
- name: Capture coverage information
if: always() # run this step even if previous step failed
@@ -86,7 +106,13 @@ jobs:
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
- name: PlatformIO Tests
run: platformio test -e coverage -v --junit-output-path testreport.xml
run: |
set -o pipefail
# Filter out SKIPPED summary rows for hardware variants that can't run on the
# native host. They flood the log and make it harder to spot real failures.
# The JUnit XML is written directly to testreport.xml before the pipe, so
# the test artifact is unaffected.
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$"
- name: Save test results
if: always() # run this step even if previous step failed
@@ -135,6 +161,14 @@ jobs:
name: platformio-test-report-${{ steps.version.outputs.long }}
merge-multiple: true
- name: Drop no-status testsuites from the report
# PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_* dir
# crossed with every hardware variant it cannot run on the native host (~4900 rows).
# They carry no pass/fail/skip status and bury the suites that actually ran. Strip
# them so the Test Report lists only suites with a real status. Only the copy the
# reporter renders is trimmed; the uploaded artifact keeps the full XML.
run: sed -i -E 's#<testsuite [^>]*tests="0"[^>]*/>##g' testreport.xml
- name: Test Report
uses: dorny/test-reporter@v3.0.0
with:
+9 -4
View File
@@ -16,13 +16,18 @@ jobs:
submodules: true
- name: Update submodule
if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' }}
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 }}
run: |
git submodule update --remote protobufs
git fetch --prune origin $GIT_BRANCH
git checkout origin/$GIT_BRANCH
- name: Download nanopb
run: |
wget https://jpa.kapsi.fi/nanopb/download/nanopb-0.4.9.1-linux-x86.tar.gz
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
@@ -33,7 +38,7 @@ jobs:
- name: Create pull request
uses: peter-evans/create-pull-request@v8
with:
branch: create-pull-request/update-protobufs
branch: create-pull-request/update-protobufs-${{ github.ref_name }}
labels: submodules
title: Update protobufs and classes
commit-message: Update protobufs
+10
View File
@@ -47,6 +47,10 @@ data/boot/logo.*
managed_components/*
arduino-lib-builder*
dependencies.lock
# JLink / RTT debug artifacts (nRF SoCs)
flash.jlink
rtt_*.txt
idf_component.yml
CMakeLists.txt
/sdkconfig.*
@@ -56,3 +60,9 @@ CMakeLists.txt
.python3
.claude/scheduled_tasks.lock
userPrefs.jsonc.mcp-session-bak
# Fake-NodeDB fixture pipeline (bin/regen-fake-nodedbs.sh)
# JSONL seeds are committed (test/fixtures/nodedb/seed_v25_*.jsonl);
# compiled .proto outputs are ephemeral build artifacts.
build/fixtures/
bin/_generated/
+7
View File
@@ -34,6 +34,13 @@ lint:
- linters: [ALL]
paths:
- bin/**
# Fake-NodeDB fixture JSONL files contain deterministic synthetic
# public_key_hex (64-char hex) values that gitleaks misidentifies as
# generic-api-key. These are not secrets — they're test fixtures
# produced by bin/gen-fake-nodedb-seed.py with a fixed RNG seed.
- linters: [gitleaks]
paths:
- test/fixtures/nodedb/seed_v25_*.jsonl
runtimes:
enabled:
- python@3.14.4
+2 -1
View File
@@ -10,5 +10,6 @@
},
"[powershell]": {
"editor.defaultFormatter": "ms-vscode.powershell"
}
},
"idf.currentSetup": "C:/Espressif/frameworks/esp-idf-v5.3.1/"
}
+14 -14
View File
@@ -10,18 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
## Quick command reference
| Action | Command |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
| Run firmware unit tests (native) | `pio test -e native` |
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
| Format before commit | `trunk fmt` |
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
| Action | Command |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
| Run firmware unit tests (native) | `~/.platformio/penv/bin/python -m platformio test -e native > /tmp/test_out.txt 2>&1` then `grep -E 'error:\|PASS\|FAIL\|succeeded\|failed' /tmp/test_out.txt` (redirect first — piping causes line-buffering) |
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
| Format before commit | `trunk fmt` |
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
## MCP server (device + test automation)
@@ -64,7 +64,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test.
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI — see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
- **`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.
@@ -108,7 +108,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 (12 suites; `pio test -e native`) |
| `test/` | Firmware unit tests (17 suites; `pio test -e native`) |
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` |
| `.claude/commands/` | Claude Code slash command bodies |
+2 -2
View File
@@ -14,7 +14,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1
RUN apt-get update && apt-get install --no-install-recommends -y \
curl wget g++ zip git ca-certificates pkg-config \
python3-pip python3-grpc-tools \
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
@@ -53,7 +53,7 @@ ENV TZ=Etc/UTC
USER root
RUN apt-get update && apt-get --no-install-recommends -y install \
libc-bin libc6 libgpiod3 libyaml-cpp0.8 libi2c0 libuv1t64 libusb-1.0-0-dev \
libc-bin libc6 libgpiod3 libyaml-cpp0.8 libjsoncpp26 libi2c0 libuv1t64 libusb-1.0-0-dev \
liborcania2.3 libulfius2.7t64 libssl3t64 \
libx11-6 libinput10 libxkbcommon-x11-0 libsdl2-2.0-0 \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
+1 -1
View File
@@ -7,7 +7,7 @@ ENV PIP_ROOT_USER_ACTION=ignore
# hadolint ignore=DL3008
RUN apt-get update && apt-get install --no-install-recommends -y \
g++ git ca-certificates pkg-config \
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
+32
View File
@@ -10,3 +10,35 @@
## Reporting a Vulnerability
We support the private reporting of potential security vulnerabilities. Please go to the Security tab to file a report with a description of the potential vulnerability and reproduction scripts (preferred) or steps, and our developers will review.
Before filing, please read the Security Model below. Behavior whose only precondition is local API access to a node, or possession of a channel's pre-shared key, is intended by design and is not considered a vulnerability.
## Security Model
Meshtastic is an off-grid mesh protocol that runs on constrained microcontrollers within a 256 byte LoRa packet limit. These constraints shape its security design and rule out the heavier schemes used by IP-based protocols. This section summarizes what the firmware protects, the assumptions it rests on, and its known limits. Fuller write-ups are in the documentation:
- Encryption overview: https://meshtastic.org/docs/overview/encryption/
- Technical reference: https://meshtastic.org/docs/development/reference/encryption-technical/
- Known limitations and future work: https://meshtastic.org/docs/about/overview/encryption/limitations/
### Cryptographic mechanisms
- Channels are encrypted with a pre-shared key (PSK) using AES256-CTR. Channel traffic is encrypted but not authenticated, so anyone holding the PSK can read channel messages and can send messages as any node on that channel.
- Direct messages and admin messages use public key cryptography (x25519 key exchange with AES-CCM), providing confidentiality, authentication, and integrity between nodes on 2.5.0 or newer that have exchanged keys.
- Admin sessions use short-lived session IDs to limit replay of control messages.
### Local trust boundary
A client connected to a node over Bluetooth, USB serial, WiFi, or Ethernet has full local API access. From that connection it can read decrypted traffic, send messages as the node, change configuration (subject to managed mode), and read the node's private key for backup. This is intended behavior. The firmware trusts the local link the same way a phone or laptop trusts a directly attached device, and anything within reach of that connection (a shared LAN, a USB cable to an untrusted host, a paired phone) should be treated as part of the node itself.
### Node identity (Trust On First Use)
There is no central authority to sign node keys. The first public key a node hears for a given node number is the one it binds to that node number, a Trust On First Use (TOFU) model that is a hard requirement of a decentralized mesh. Clients and firmware reduce the impact of this by keeping favorited nodes from rolling out of the node database and by flagging public-key changes in the client UI.
Firmware 2.8.X adds XEdDSA packet signing to further secure node identity claims and the authenticity of subsequent messages. It reuses each node's existing x25519 key pair to produce signatures, so a receiver can verify that a packet came from the holder of the bound key. Once a node has been seen signing, unsigned packets claiming that identity can be rejected.
### Known limitations
- No perfect forward secrecy. Traffic captured today can be decrypted later if a key is compromised, for example through a lost node or a mishandled channel key.
- Channel messages are not authenticated, as noted above. Although as of 2.8, channel messages will be xedDSA signed as a means of verification that is non-breaking.
- Setting WiFi credentials, or performing any other local administration, on an ESP32 over an untrusted network exposes that traffic, including the credentials, to the network. Provision and administer nodes over a trusted channel instead: Bluetooth, USB serial, or remote admin over the mesh. There is no current roadmap item to secure local administration over untrusted WiFi, though it may be addressed in a future release.
+2 -2
View File
@@ -16,7 +16,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1
RUN apk --no-cache add \
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
py3-pip py3-grpcio-tools \
libgpiod-dev yaml-cpp-dev bluez-dev \
libgpiod-dev yaml-cpp-dev jsoncpp-dev bluez-dev \
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
&& rm -rf /var/cache/apk/* \
@@ -48,7 +48,7 @@ LABEL org.opencontainers.image.title="Meshtastic" \
USER root
RUN apk --no-cache add \
shadow libstdc++ libbsd libgpiod yaml-cpp libusb \
shadow libstdc++ libbsd libgpiod yaml-cpp jsoncpp libusb \
i2c-tools libuv libx11 libinput libxkbcommon sdl2 \
&& rm -rf /var/cache/apk/* \
&& mkdir -p /var/lib/meshtasticd \
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Post-process protoc-generated Python files to live under a local namespace.
Called by bin/regen-py-protos.sh. Walks the generated *_pb2.py files in the
target directory and rewrites every `meshtastic` reference (imports, dotted
attribute access) to use the new namespace (e.g., `meshtastic_v25`).
Why: the .proto files declare `package meshtastic;`, so protoc emits
`from meshtastic import mesh_pb2 as ...` lines. That would shadow the PyPI
`meshtastic` package which other parts of the mcp-server depend on. Renaming
to a local namespace keeps both available.
Usage:
_rewrite_proto_namespace.py <generated_dir> <new_namespace>
"""
from __future__ import annotations
import pathlib
import re
import sys
def rewrite(dir_path: pathlib.Path, new_ns: str) -> int:
# Standard protoc import forms:
# from meshtastic.X_pb2 import ... (rare, for direct symbol pulls)
# from meshtastic import X_pb2 as ... (common, the cross-file ref)
# import meshtastic.X_pb2 (also possible)
pattern_dotted_from = re.compile(r"^from meshtastic\.", re.MULTILINE)
pattern_bare_from = re.compile(r"^from meshtastic import ", re.MULTILINE)
pattern_dotted_import = re.compile(r"^import meshtastic\.", re.MULTILINE)
count = 0
for p in dir_path.glob("*.py"):
text = p.read_text(encoding="utf-8")
new = pattern_dotted_from.sub(f"from {new_ns}.", text)
new = pattern_bare_from.sub(f"from {new_ns} import ", new)
new = pattern_dotted_import.sub(f"import {new_ns}.", new)
# NOTE: we deliberately leave `meshtastic/X.proto` source-filename
# references inside descriptor strings alone. The descriptor pool is
# keyed by source filename (independent of Python package layout), so
# those don't collide with the PyPI package's descriptors.
if new != text:
p.write_text(new, encoding="utf-8")
count += 1
return count
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("usage: _rewrite_proto_namespace.py <generated_dir> <new_namespace>", file=sys.stderr)
return 2
dir_path = pathlib.Path(argv[0])
new_ns = argv[1]
if not dir_path.is_dir():
print(f"directory not found: {dir_path}", file=sys.stderr)
return 2
n = rewrite(dir_path, new_ns)
print(f"rewrote {n} file(s) in {dir_path} → namespace {new_ns}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+1 -1
View File
@@ -38,4 +38,4 @@ cp bin/device-install.* $OUTDIR/
cp bin/device-update.* $OUTDIR/
echo "Copying manifest"
cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json || true
cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Collect firmware binary sizes from manifest (.mt.json) files into a single report."""
import json
import os
import sys
def collect_sizes(manifest_dir):
"""Scan manifest_dir for .mt.json files and return {board: size_bytes} dict."""
sizes = {}
for fname in sorted(os.listdir(manifest_dir)):
if not fname.endswith(".mt.json"):
continue
path = os.path.join(manifest_dir, fname)
with open(path) as f:
data = json.load(f)
board = data.get("platformioTarget", fname.replace(".mt.json", ""))
# Find the main firmware .bin size (largest .bin, excluding OTA/littlefs/bleota)
bin_size = None
for entry in data.get("files", []):
name = entry.get("name", "")
if name.startswith("firmware-") and name.endswith(".bin"):
bin_size = entry["bytes"]
break
# Fallback: any .bin that isn't ota/littlefs/bleota
if bin_size is None:
for entry in data.get("files", []):
name = entry.get("name", "")
if name.endswith(".bin") and not any(
x in name for x in ["littlefs", "bleota", "ota"]
):
bin_size = entry["bytes"]
break
if bin_size is not None:
sizes[board] = bin_size
return sizes
if __name__ == "__main__":
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <manifest_dir> <output.json>", file=sys.stderr)
sys.exit(1)
manifest_dir = sys.argv[1]
output_path = sys.argv[2]
sizes = collect_sizes(manifest_dir)
with open(output_path, "w") as f:
json.dump(sizes, f, indent=2, sort_keys=True)
print(f"Collected sizes for {len(sizes)} targets -> {output_path}")
@@ -0,0 +1,30 @@
---
Lora:
## Ebyte E80-900M22S
## This is a bit experimental
##
##
Module: lr1121
gpiochip: 1 # subtract 32 from the gpio numbers
DIO3_TCXO_VOLTAGE: 1.8
CS: 16 #pin6 / GPIO48 1C0
IRQ: 23 #pin17 / GPIO55 1C7
Busy: 22 #pin16 / GPIO54 1C6
Reset: 25 #pin13 / GPIO57 1D1
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
spiSpeed: 2000000
rfswitch_table:
pins: [DIO5, DIO6, DIO7]
MODE_STBY: [LOW, LOW, LOW]
MODE_RX: [LOW, HIGH, LOW]
MODE_TX: [HIGH, HIGH, LOW]
MODE_TX_HP: [HIGH, LOW, LOW]
MODE_TX_HF: [LOW, LOW, LOW]
MODE_GNSS: [LOW, LOW, HIGH]
MODE_WIFI: [LOW, LOW, LOW]
General:
MACAddressSource: eth0
@@ -0,0 +1,46 @@
---
Lora:
## Ebyte E80-900M22S
## This is a bit experimental
##
##
Module: lr1121
gpiochip: 1 # subtract 32 from the gpio numbers
DIO3_TCXO_VOLTAGE: 1.8
CS: 16 #pin6 / GPIO48 1C0
IRQ: 23 #pin17 / GPIO55 1C7
Busy: 22 #pin16 / GPIO54 1C6
Reset: 25 #pin13 / GPIO57 1D1
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
spiSpeed: 2000000
rfswitch_table:
pins:
- DIO5
- DIO6
MODE_STBY:
- LOW
- LOW
MODE_RX:
- HIGH
- LOW
MODE_TX:
- HIGH
- HIGH
MODE_TX_HP:
- LOW
- HIGH
MODE_TX_HF:
- LOW
- LOW
MODE_GNSS:
- LOW
- LOW
MODE_WIFI:
- LOW
- LOW
General:
MACAddressSource: eth0
@@ -0,0 +1,30 @@
---
Lora:
## Ebyte E80-900M22S
## This is a bit experimental
##
##
Module: lr1121
gpiochip: 1 # subtract 32 from the gpio numbers
DIO3_TCXO_VOLTAGE: 1.8
CS: 16 #pin6 / GPIO48 1C0
IRQ: 23 #pin17 / GPIO55 1C7
Busy: 22 #pin16 / GPIO54 1C6
Reset: 25 #pin13 / GPIO57 1D1
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
spiSpeed: 2000000
rfswitch_table:
pins: [DIO5, DIO6, DIO7]
MODE_STBY: [LOW, LOW, LOW]
MODE_RX: [LOW, LOW, LOW]
MODE_TX: [LOW, HIGH, LOW]
MODE_TX_HP: [HIGH, LOW, LOW]
# MODE_TX_HF: []
# MODE_GNSS: []
MODE_WIFI: [LOW, LOW, LOW]
General:
MACAddressSource: eth0
+19
View File
@@ -0,0 +1,19 @@
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/NebraHat
# Use for 1 watt hat
Meta:
name: NebraHat 1W
support: community
compatible:
- raspberry-pi
Lora:
Module: sx1262 # Nebra SX1262 Pi Hat - 1W
DIO2_AS_RF_SWITCH: true
DIO3_TCXO_VOLTAGE: true
# CS: 8 # Newer version of MeshtasticD do not need this? If issues uncomment this line
IRQ: 22
Busy: 4
Reset: 18
RXen: 25
I2C:
I2CDevice: /dev/i2c-1
+20
View File
@@ -0,0 +1,20 @@
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/NebraHat
# Use for 2 watt hat
Meta:
name: NebraHat 2W
support: community
compatible:
- raspberry-pi
Lora:
Module: sx1262 # Nebra SX1262 Pi Hat - 2W
DIO2_AS_RF_SWITCH: true
DIO3_TCXO_VOLTAGE: true
SX126X_MAX_POWER: 8
# CS: 8 # Newer version of MeshtasticD do not need this? If issues uncomment this line
IRQ: 22
Busy: 4
Reset: 18
RXen: 25
I2C:
I2CDevice: /dev/i2c-1
+1 -1
View File
@@ -18,4 +18,4 @@ Lora:
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.0
# CS: 8
# CS: 8
+6 -2
View File
@@ -5,7 +5,9 @@ Meta:
- raspberry-pi
Lora:
### RAK13300 in Slot 2 pins
### RAK13300 in Slot 2
Module: sx1262
IRQ: 18 #IO6
Reset: 24 # IO4
Busy: 19 # IO5
@@ -13,5 +15,7 @@ Lora:
Enable_Pins:
- 26
- 23
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.1
# CS: 7
# CS: 7
+6 -2
View File
@@ -5,14 +5,18 @@ Meta:
- raspberry-pi
Lora:
### RAK13302 in Slot 2 pins
### RAK13302 in Slot 2
Module: sx1262
IRQ: 18 #IO6
Reset: 24 # IO4
Busy: 19 # IO5
# Ant_sw: 23 # IO3
# Ant_sw: 23 # IO3
Enable_Pins:
- 26
- 23
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.1
# CS: 7
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
+19
View File
@@ -0,0 +1,19 @@
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/ZebraHAT
# Use for 1 watt hat
Meta:
name: ZebraHat 1W
support: community
compatible:
- raspberry-pi
Lora:
Module: sx1262 # Zebra SX1262 Pi Hat - 1W
DIO2_AS_RF_SWITCH: true
DIO3_TCXO_VOLTAGE: true
SX126X_MAX_POWER: 18
CS: 24
IRQ: 22
Busy: 27
Reset: 17
I2C:
I2CDevice: /dev/i2c-1
+20
View File
@@ -0,0 +1,20 @@
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/ZebraHAT
# Use for 2 watt hat
Meta:
name: ZebraHat 2W
support: community
compatible:
- raspberry-pi
Lora:
Module: sx1262 # Zebra SX1262 Pi Hat - 2W
DIO2_AS_RF_SWITCH: true
DIO3_TCXO_VOLTAGE: true
SX126X_MAX_POWER: 8
CS: 24
IRQ: 22
Busy: 27
Reset: 17
RXen: 25
I2C:
I2CDevice: /dev/i2c-1
@@ -29,6 +29,8 @@ Lora:
- pin: 50 # GPIO1_C2 (physical 16)
gpiochip: 1
line: 18
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.1
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
# pin: 7
@@ -29,6 +29,8 @@ Lora:
- pin: 50 # GPIO1_C2 (physical 16)
gpiochip: 1
line: 18
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.1
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
# pin: 7
@@ -0,0 +1,29 @@
# Station G3 + BQ35LORA900V1M Primary Slot
# Board Doc: https://wiki.bqvoy.com/en/devkits/station-g3
Meta:
name: B&Q Station G3 + BQ35LORA900V1M Primary Slot
support: official
compatible:
- luckfox-lyra-zero-w # Armbian
Lora:
Module: sx1262
IRQ: # GPIO0_A5 (physical 15)
pin: 5
gpiochip: 0
line: 5
Reset: # GPIO1_D1 (physical 36)
pin: 57
gpiochip: 1
line: 25
Busy: # GPIO0_B4 (physical 18)
pin: 12
gpiochip: 0
line: 12
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.0
# CS: # GPIO0_B2 (physical 24)
# pin: 10
# gpiochip: 0
# line: 10
@@ -29,6 +29,8 @@ Lora:
- pin: 13 # GPIO0_B5
gpiochip: 0
line: 13
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.1
# CS: # GPIO0_B1
# pin: 9
@@ -29,6 +29,8 @@ Lora:
- pin: 13 # GPIO0_B5
gpiochip: 0
line: 13
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.1
# CS: # GPIO0_B1
# pin: 9
@@ -31,6 +31,8 @@ Lora:
- pin: 103 # GPIO3_A7 (physical 16)
gpiochip: 3
line: 7
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.1
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
# pin: 15
@@ -31,6 +31,8 @@ Lora:
- pin: 103 # GPIO3_A7 (physical 16)
gpiochip: 3
line: 7
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.1
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
# pin: 15
@@ -0,0 +1,17 @@
# Station G3 + BQ35LORA900V1M Primary Slot
# Board Doc: https://wiki.bqvoy.com/en/devkits/station-g3
Meta:
name: B&Q Station G3 + BQ35LORA900V1M Primary Slot
support: official
compatible:
- raspberry-pi
Lora:
Module: sx1262
IRQ: 22
Reset: 16
Busy: 24
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.0
#CS: 8
+2
View File
@@ -1,3 +1,5 @@
# This config works with all revisions of the Meshtoad USB radio.
Meta:
name: meshtoad-e22
support: official
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""
Meshtastic Ethernet OTA Upload Tool
Uploads firmware to RP2350-based Meshtastic devices via Ethernet (W5500).
Compresses firmware with GZIP and sends it over TCP using the MOTA protocol.
Authenticates using SHA256 challenge-response with a pre-shared key (PSK).
Usage:
python bin/eth-ota-upload.py --host 192.168.1.100 firmware.bin
python bin/eth-ota-upload.py --host 192.168.1.100 --psk mySecretKey firmware.bin
python bin/eth-ota-upload.py --host 192.168.1.100 --psk-hex 6d65736874... firmware.bin
"""
import argparse
import gzip
import hashlib
import socket
import struct
import sys
import time
# Default PSK matching the firmware default: "meshtastic_ota_default_psk_v1!!!"
DEFAULT_PSK = b"meshtastic_ota_default_psk_v1!!!"
def crc32(data: bytes) -> int:
"""Compute CRC32 matching ErriezCRC32 (standard CRC32 with final XOR)."""
import binascii
return binascii.crc32(data) & 0xFFFFFFFF
def load_firmware(path: str) -> bytes:
"""Load firmware file, compressing with GZIP if not already compressed."""
# Reject UF2 files — OTA requires raw .bin firmware
if path.lower().endswith(".uf2"):
bin_path = path.rsplit(".", 1)[0] + ".bin"
print(f"ERROR: UF2 files cannot be used for OTA updates.")
print(f" The Updater/picoOTA expects raw .bin firmware.")
print(f" Try: {bin_path}")
sys.exit(1)
with open(path, "rb") as f:
data = f.read()
# Check if already GZIP compressed (magic bytes 1f 8b)
if data[:2] == b"\x1f\x8b":
print(f"Firmware already GZIP compressed: {len(data):,} bytes")
return data
print(f"Firmware raw size: {len(data):,} bytes")
compressed = gzip.compress(data, compresslevel=9)
ratio = len(compressed) / len(data) * 100
print(f"GZIP compressed: {len(compressed):,} bytes ({ratio:.1f}%)")
return compressed
def authenticate(sock: socket.socket, psk: bytes) -> bool:
"""Perform SHA256 challenge-response authentication with the device."""
# Receive 32-byte nonce from server
nonce = b""
while len(nonce) < 32:
chunk = sock.recv(32 - len(nonce))
if not chunk:
print("ERROR: Connection closed during authentication")
return False
nonce += chunk
# Compute SHA256(nonce || PSK)
h = hashlib.sha256()
h.update(nonce)
h.update(psk)
response = h.digest()
# Send 32-byte response
sock.sendall(response)
# Wait for auth result (1 byte)
result = sock.recv(1)
if not result:
print("ERROR: No authentication response")
return False
if result[0] == 0x06: # ACK
print("Authentication successful.")
return True
elif result[0] == 0x07: # OTA_ERR_AUTH
print("ERROR: Authentication failed — wrong PSK")
return False
else:
print(f"ERROR: Unexpected auth response 0x{result[0]:02X}")
return False
def upload_firmware(host: str, port: int, firmware: bytes, psk: bytes, timeout: float) -> bool:
"""Upload firmware over TCP using the MOTA protocol with PSK authentication."""
fw_crc = crc32(firmware)
fw_size = len(firmware)
print(f"Connecting to {host}:{port}...")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect((host, port))
print("Connected.")
# Step 1: Authenticate
print("Authenticating...")
if not authenticate(sock, psk):
return False
# Step 2: Send 12-byte MOTA header: magic(4) + size(4) + crc32(4)
header = struct.pack("<4sII", b"MOTA", fw_size, fw_crc)
sock.sendall(header)
print(f"Header sent: size={fw_size:,}, CRC32=0x{fw_crc:08X}")
# Wait for ACK (1 byte)
ack = sock.recv(1)
if not ack or ack[0] != 0x06:
error_codes = {
0x02: "Size error",
0x04: "Invalid magic",
0x05: "Update.begin() failed",
}
code = ack[0] if ack else 0xFF
msg = error_codes.get(code, f"Unknown error 0x{code:02X}")
print(f"ERROR: Server rejected header: {msg}")
return False
print("Header accepted. Uploading firmware...")
# Send firmware in 1KB chunks
chunk_size = 1024
sent = 0
start_time = time.time()
while sent < fw_size:
end = min(sent + chunk_size, fw_size)
chunk = firmware[sent:end]
sock.sendall(chunk)
sent = end
# Progress bar
pct = sent * 100 // fw_size
bar_len = 40
filled = bar_len * sent // fw_size
bar = "" * filled + "" * (bar_len - filled)
elapsed = time.time() - start_time
speed = sent / elapsed if elapsed > 0 else 0
sys.stdout.write(f"\r [{bar}] {pct:3d}% {sent:,}/{fw_size:,} ({speed/1024:.1f} KB/s)")
sys.stdout.flush()
elapsed = time.time() - start_time
print(f"\n Transfer complete in {elapsed:.1f}s")
# Wait for final result (1 byte)
print("Waiting for verification...")
result = sock.recv(1)
if not result:
print("ERROR: No response from device")
return False
result_codes = {
0x00: "OK — Update staged, device rebooting",
0x01: "CRC mismatch",
0x02: "Size error",
0x03: "Write error",
0x04: "Magic mismatch",
0x05: "Updater.begin() failed",
0x07: "Auth failed",
0x08: "Timeout",
}
code = result[0]
msg = result_codes.get(code, f"Unknown result 0x{code:02X}")
if code == 0x00:
print(f"SUCCESS: {msg}")
return True
else:
print(f"ERROR: {msg}")
return False
except socket.timeout:
print("ERROR: Connection timed out")
return False
except ConnectionRefusedError:
print(f"ERROR: Connection refused by {host}:{port}")
return False
except OSError as e:
print(f"ERROR: {e}")
return False
finally:
sock.close()
def main():
parser = argparse.ArgumentParser(
description="Upload firmware to Meshtastic RP2350 devices via Ethernet OTA"
)
parser.add_argument("firmware", help="Path to firmware .bin or .bin.gz file")
parser.add_argument("--host", required=True, help="Device IP address")
parser.add_argument(
"--port", type=int, default=4243, help="OTA port (default: 4243)"
)
parser.add_argument(
"--timeout",
type=float,
default=60.0,
help="Socket timeout in seconds (default: 60)",
)
psk_group = parser.add_mutually_exclusive_group()
psk_group.add_argument(
"--psk",
type=str,
help="Pre-shared key as UTF-8 string (default: meshtastic_ota_default_psk_v1!!!)",
)
psk_group.add_argument(
"--psk-hex",
type=str,
help="Pre-shared key as hex string (e.g., 6d65736874...)",
)
args = parser.parse_args()
# Resolve PSK
if args.psk:
psk = args.psk.encode("utf-8")
elif args.psk_hex:
try:
psk = bytes.fromhex(args.psk_hex)
except ValueError:
print("ERROR: Invalid hex string for --psk-hex")
sys.exit(1)
else:
psk = DEFAULT_PSK
print("Meshtastic Ethernet OTA Upload")
print("=" * 40)
firmware = load_firmware(args.firmware)
if upload_firmware(args.host, args.port, firmware, psk, args.timeout):
print("\nDevice is rebooting with new firmware.")
sys.exit(0)
else:
print("\nUpload failed.")
sys.exit(1)
if __name__ == "__main__":
main()
+439
View File
@@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""Deterministic seed-data generator for the fake NodeDB fixture pipeline.
Writes a JSONL file describing N fake-but-realistic Meshtastic peers.
The output is hand-editable and committed; a sibling compile step
(bin/seed-json-to-proto.py) turns it into a binary `meshtastic_NodeDatabase`
v25 protobuf with fresh "now-relative" timestamps.
Determinism contract:
Same --seed -> byte-identical JSONL output, regardless of wall clock.
All timestamps are stored as `*_offset_sec` (seconds before "now"); the
compile step resolves them to absolute epochs at compile time.
Structural fields covered:
* NodeInfoLite header: num, long_name, short_name, hw_model, role,
public_key, snr, channel, hops_away, next_hop, bitfield flags
* PositionLite: lat/long Gaussian around --centroid, altitude, source
* DeviceMetrics: battery/voltage/util/uptime
* EnvironmentMetrics: temp/humidity/pressure/iaq
* StatusMessage: error_code (usually zero)
Active-board allow-list:
hw_model values are restricted to the intersection of
(a) variants with `custom_meshtastic_support_level = 1` in
variants/*/*/platformio.ini, AND
(b) values present in the `HardwareModel` enum in mesh.proto.
See HW_MODEL_WEIGHTS below. Deprecated boards (legacy TLORA / Heltec V1-2 /
classic TBEAM / TBEAM_V0P7 / Nano G1 / etc.) and fuzzer-only sentinels
(PORTDUINO, ANDROID_SIM, DIY_V1, ...) are excluded.
Active-role allow-list:
Excludes ROUTER_CLIENT (deprecated v2.3.15) and REPEATER (deprecated v2.7.11).
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import math
import pathlib
import random
import sys
# --------------------------------------------------------------------------
# Active-board allow-list (intersection of tier-1 variants + HardwareModel enum).
# Refresh by running:
# for f in $(find variants -name 'platformio.ini' | xargs grep -lE 'custom_meshtastic_support_level = 1'); do
# grep custom_meshtastic_hw_model_slug $f | awk -F= '{print $2}' | tr -d ' ';
# done | sort -u | comm -12 - <(python3 -c "from meshtastic.protobuf.mesh_pb2 import HardwareModel; print('\\n'.join(HardwareModel.keys()))" | sort)
# --------------------------------------------------------------------------
HW_MODEL_WEIGHTS: dict[str, float] = {
"HELTEC_V3": 14.0,
"T_DECK": 9.0,
"HELTEC_V4": 8.0,
"RAK4631": 8.0,
"HELTEC_MESH_POCKET": 6.0,
"TRACKER_T1000_E": 5.0,
"HELTEC_MESH_NODE_T114": 5.0,
"T_DECK_PRO": 5.0,
"LILYGO_TBEAM_S3_CORE": 4.0,
"HELTEC_WIRELESS_PAPER": 4.0,
"HELTEC_WSL_V3": 3.0,
"T_ECHO": 3.0,
"HELTEC_WIRELESS_TRACKER": 3.0,
"HELTEC_WIRELESS_TRACKER_V2": 2.0,
"HELTEC_VISION_MASTER_E290": 2.0,
"HELTEC_MESH_SOLAR": 2.0,
"SEEED_WIO_TRACKER_L1": 2.0,
"T_LORA_PAGER": 1.5,
"HELTEC_VISION_MASTER_E213": 1.5,
"T_ECHO_PLUS": 1.0,
"MUZI_BASE": 1.0,
"WISMESH_TAP_V2": 1.0,
"THINKNODE_M2": 1.0,
"THINKNODE_M5": 1.0,
"TLORA_T3_S3": 1.0,
# Long tail (uniform low weight across remaining tier-1 boards):
"HELTEC_V4_R8": 0.3,
"HELTEC_VISION_MASTER_T190": 0.3,
"HELTEC_HT62": 0.3,
"HELTEC_MESH_NODE_T096": 0.3,
"M5STACK_C6L": 0.3,
"MINI_EPAPER_S3": 0.3,
"MUZI_R1_NEO": 0.3,
"NOMADSTAR_METEOR_PRO": 0.3,
"RAK3312": 0.3,
"RAK3401": 0.3,
"SEEED_SOLAR_NODE": 0.3,
"SEEED_WIO_TRACKER_L1_EINK": 0.3,
"SENSECAP_INDICATOR": 0.3,
"TBEAM_1_WATT": 0.3,
"THINKNODE_M1": 0.3,
"THINKNODE_M3": 0.3,
"THINKNODE_M6": 0.3,
"T_ECHO_LITE": 0.3,
"WISMESH_TAG": 0.3,
"WISMESH_TAP": 0.3,
"XIAO_NRF52_KIT": 0.3,
"CROWPANEL": 0.3,
}
# Non-deprecated roles only.
ROLE_WEIGHTS: dict[str, float] = {
"CLIENT": 75.0,
"CLIENT_MUTE": 5.0,
"ROUTER": 7.0,
"TRACKER": 3.0,
"SENSOR": 2.0,
"CLIENT_HIDDEN": 2.0,
"ROUTER_LATE": 2.0,
"CLIENT_BASE": 2.0,
"TAK": 1.0,
"TAK_TRACKER": 0.5,
"LOST_AND_FOUND": 0.5,
}
# Name pools — 60 firsts × 60 lasts = 3600 combinations.
FIRSTS = [
"Quick", "Brave", "Silent", "Wild", "Lone", "Bright", "Red", "Blue",
"Green", "Black", "White", "Iron", "Steel", "Copper", "Silver", "Gold",
"Stone", "River", "Forest", "Mountain", "Canyon", "Desert", "Storm", "Sky",
"Solar", "Lunar", "Dawn", "Dusk", "Misty", "Frosty", "Sunny", "Shady",
"Happy", "Sleepy", "Drowsy", "Sneaky", "Sharp", "Smooth", "Rough", "Loud",
"Soft", "Slow", "Fast", "Tall", "Short", "Old", "New", "Tiny",
"Giant", "Hidden", "Lost", "Found", "Wandering", "Roving", "Drifting", "Floating",
"Burning", "Frozen", "Whispering", "Howling",
]
LASTS = [
"Phoenix", "Lion", "Bear", "Wolf", "Hawk", "Eagle", "Fox", "Lynx",
"Cougar", "Coyote", "Raven", "Owl", "Crow", "Falcon", "Heron", "Crane",
"Otter", "Badger", "Bison", "Elk", "Moose", "Stag", "Doe", "Hare",
"Marmot", "Mole", "Beaver", "Squirrel", "Mustang", "Bronco", "Pony", "Colt",
"Cobra", "Viper", "Mamba", "Adder", "Gecko", "Iguana", "Tortoise", "Turtle",
"Salmon", "Trout", "Bass", "Pike", "Shark", "Whale", "Dolphin", "Seal",
"Cactus", "Yucca", "Sage", "Juniper", "Pine", "Cedar", "Aspen", "Oak",
"Bluff", "Mesa", "Arroyo", "Ridge",
]
# Brief callsign pool for licensed-looking suffixes.
CALLSIGN_PREFIXES = ["KX", "WD", "N5", "KE", "AB", "W5", "K1", "KQ", "AE", "NM"]
# Only emojis that fit in 4 UTF-8 bytes (no variation selectors). short_name's
# nanopb max_size:5 (incl. NUL) limits content to 4 bytes. ❄️ / ☀️ would be
# 6 bytes due to U+FE0F variation selector — explicitly excluded.
EMOJI_SHORTNAMES = ["🦊", "🐺", "🦅", "🐢", "🌵", "🔥", "🌙",
"🌊", "🗻", "🌲", "🦌", "🐝", "🦂", "🦉",
"🦇", "🦋"]
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
NUM_RESERVED = 4 # firmware reserves 0..3 (per NodeDB constants)
NUM_MAX_EXCLUSIVE = 0x80000000 # restrict to positive int32 range for readability
def _weighted_choice(rng: random.Random, weights: dict[str, float]) -> str:
"""Deterministic weighted pick. Uses sorted keys so dict order is fixed."""
keys = sorted(weights.keys())
totals = [weights[k] for k in keys]
return rng.choices(keys, weights=totals, k=1)[0]
def _gen_long_name(rng: random.Random, is_licensed: bool) -> str:
base = f"{rng.choice(FIRSTS)} {rng.choice(LASTS)}"
if is_licensed:
prefix = rng.choice(CALLSIGN_PREFIXES)
# Two trailing alpha chars after the digit; keep within 25 - len(base) - 1
suffix = f" {prefix}{rng.randint(0,9)}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}"
# nanopb max_size:25 means C string fits 24 bytes + NUL.
if len(base) + len(suffix) <= 24:
base = base + suffix
# Hard cap to 24 chars (nanopb max_size:25 minus NUL).
return base[:24]
def _gen_short_name(rng: random.Random, long_name: str) -> str:
# 10% emoji-only short_name
if rng.random() < 0.10:
return rng.choice(EMOJI_SHORTNAMES)
first_char = long_name[0].upper() if long_name else "X"
alphanums = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return first_char + "".join(rng.choices(alphanums, k=3))
def _gen_hops_away(rng: random.Random) -> int:
# Geometric-ish: 0→55%, 1→25%, 2→12%, 3→5%, 4→2%, 5+→1%
r = rng.random()
if r < 0.55:
return 0
if r < 0.80:
return 1
if r < 0.92:
return 2
if r < 0.97:
return 3
if r < 0.99:
return 4
return rng.randint(5, 7)
def _gen_position(
rng: random.Random,
centroid_lat: float,
centroid_lon: float,
spread_km: float,
last_heard_offset_sec: int,
) -> dict:
# 1 deg ≈ 111 km at the equator; we use this as a flat approximation.
lat = centroid_lat + rng.gauss(0.0, spread_km / 111.0)
lon = centroid_lon + rng.gauss(0.0, spread_km / 111.0)
altitude = max(0, round(rng.gauss(1376.0, 250.0))) # T or C valley floor + relief
# Position was reported up to 300s before last_heard.
time_offset_sec = last_heard_offset_sec + rng.randint(0, 300)
return {
"latitude": round(lat, 6),
"longitude": round(lon, 6),
"altitude": altitude,
"time_offset_sec": time_offset_sec,
"location_source": "LOC_INTERNAL",
}
def _gen_telemetry(rng: random.Random) -> dict:
# 5% plugged-in (battery_level == 101); rest uniform [10..100].
if rng.random() < 0.05:
battery_level = 101
voltage = 4.20
else:
battery_level = rng.randint(10, 100)
voltage = round(3.3 + (battery_level / 100.0) * 0.9, 3)
# Beta distributions for low/right-skewed metrics; randomly draw via gammavariate.
def _beta(a: float, b: float) -> float:
x = rng.gammavariate(a, 1.0)
y = rng.gammavariate(b, 1.0)
return x / (x + y)
channel_utilization = round(_beta(2.0, 15.0) * 100.0, 2)
air_util_tx = round(_beta(1.5, 20.0) * 10.0, 3)
uptime_seconds = int(rng.expovariate(1.0 / 86400.0))
return {
"battery_level": battery_level,
"voltage": voltage,
"channel_utilization": channel_utilization,
"air_util_tx": air_util_tx,
"uptime_seconds": uptime_seconds,
}
def _gen_environment(rng: random.Random) -> dict:
return {
"temperature": round(rng.gauss(22.0, 8.0), 2),
"relative_humidity": round(min(100.0, max(0.0, rng.gauss(55.0, 20.0))), 2),
"barometric_pressure": round(rng.gauss(1013.0, 8.0), 2),
"iaq": int(min(500, max(0, round(rng.gauss(50.0, 30.0))))),
}
def _gen_status(rng: random.Random) -> dict:
# `StatusMessage` (mesh.proto:1445) has a single free-form `string status`.
# Most peers report a healthy short status; occasional alert string.
healthy = ["OK", "online", "active", "running", "ready", "nominal"]
alert = ["low-batt", "no-gps", "weak-signal", "rebooted", "offline-soon"]
if rng.random() < 0.92:
return {"status": rng.choice(healthy)}
return {"status": rng.choice(alert)}
def _gen_node(
rng: random.Random,
num: int,
centroid_lat: float,
centroid_lon: float,
spread_km: float,
coverage: dict[str, float],
last_heard_mean_sec: int,
last_heard_max_sec: int,
) -> dict:
is_licensed = rng.random() < 0.05
long_name = _gen_long_name(rng, is_licensed)
short_name = _gen_short_name(rng, long_name)
hw_model = _weighted_choice(rng, HW_MODEL_WEIGHTS)
role = _weighted_choice(rng, ROLE_WEIGHTS)
has_public_key = rng.random() < 0.92
public_key_hex = (
"".join(f"{rng.randint(0,255):02x}" for _ in range(32)) if has_public_key else ""
)
snr = round(max(-20.0, min(12.0, rng.gauss(6.0, 4.0))), 2)
channel = 0 if rng.random() < 0.90 else rng.randint(1, 7)
hops_away = _gen_hops_away(rng)
next_hop = rng.randint(0, 255) if hops_away > 0 else 0
last_heard_offset_sec = int(min(rng.expovariate(1.0 / last_heard_mean_sec), last_heard_max_sec))
bitfield = {
"has_user": True,
"is_favorite": rng.random() < 0.08,
"is_muted": rng.random() < 0.03,
"via_mqtt": rng.random() < 0.12,
"is_ignored": rng.random() < 0.01,
"is_licensed": is_licensed,
"has_is_unmessagable": True,
"is_unmessagable": rng.random() < 0.02,
"is_key_manually_verified": rng.random() < 0.04,
}
node: dict = {
"num": f"0x{num:08x}",
"long_name": long_name,
"short_name": short_name,
"hw_model": hw_model,
"role": role,
"public_key_hex": public_key_hex,
"snr": snr,
"channel": channel,
"hops_away": hops_away,
"next_hop": next_hop,
"last_heard_offset_sec": last_heard_offset_sec,
"bitfield": bitfield,
"position": (
_gen_position(rng, centroid_lat, centroid_lon, spread_km, last_heard_offset_sec)
if rng.random() < coverage["position"]
else None
),
"telemetry": _gen_telemetry(rng) if rng.random() < coverage["telemetry"] else None,
"environment": _gen_environment(rng) if rng.random() < coverage["environment"] else None,
"status": _gen_status(rng) if rng.random() < coverage["status"] else None,
}
return node
def _parse_my_node_num(s: str | None) -> int | None:
if s is None:
return None
s = s.strip()
if s.startswith("0x") or s.startswith("0X"):
return int(s, 16)
return int(s)
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(
description="Deterministic JSONL seed for the fake NodeDB fixture.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--count", type=int, required=True, help="Number of fake nodes to emit.")
p.add_argument("--seed", type=int, required=True, help="Deterministic seed.")
p.add_argument("--out", required=True, help="Output JSONL path.")
p.add_argument(
"--centroid",
default="33.1284,-107.2528",
help="LAT,LON centroid (default: Truth or Consequences, NM).",
)
p.add_argument("--spread-km", type=float, default=60.0, help="Gaussian std-dev in km.")
p.add_argument("--position-coverage", type=float, default=0.85)
p.add_argument("--telemetry-coverage", type=float, default=0.70)
p.add_argument("--environment-coverage", type=float, default=0.25)
p.add_argument("--status-coverage", type=float, default=0.40)
p.add_argument("--my-node-num", default=None, help="Exclude this NodeNum from generated set (hex or dec).")
p.add_argument("--last-heard-mean-sec", type=int, default=3600)
p.add_argument("--last-heard-max-sec", type=int, default=7 * 86400)
args = p.parse_args(argv)
if args.count <= 0:
print("--count must be positive", file=sys.stderr)
return 2
try:
centroid_lat, centroid_lon = (float(s) for s in args.centroid.split(","))
except ValueError:
print(f"--centroid must be LAT,LON; got {args.centroid!r}", file=sys.stderr)
return 2
my_node_num = _parse_my_node_num(args.my_node_num)
rng = random.Random(args.seed)
# 1) Generate a unique deterministic set of NodeNums.
nums: set[int] = set()
while len(nums) < args.count:
n = rng.randrange(NUM_RESERVED, NUM_MAX_EXCLUSIVE)
if my_node_num is not None and n == my_node_num:
continue
nums.add(n)
ordered_nums = sorted(nums) # sort to fix output order independent of set hash
# 2) Per-node generation (in num order, single RNG continues).
coverage = {
"position": args.position_coverage,
"telemetry": args.telemetry_coverage,
"environment": args.environment_coverage,
"status": args.status_coverage,
}
nodes = [
_gen_node(
rng,
n,
centroid_lat,
centroid_lon,
args.spread_km,
coverage,
args.last_heard_mean_sec,
args.last_heard_max_sec,
)
for n in ordered_nums
]
# 3) Write JSONL.
out_path = pathlib.Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
# `generated_at_iso` is informational; it does NOT affect determinism because
# we derive it from the seed, not from wall clock. (Same seed -> same string.)
generated_at = _dt.datetime.fromtimestamp(args.seed, tz=_dt.timezone.utc).isoformat().replace("+00:00", "Z")
meta = {
"_meta": {
"version": 25,
"seed": args.seed,
"count": args.count,
"centroid": [centroid_lat, centroid_lon],
"spread_km": args.spread_km,
"generated_at_iso": generated_at,
"my_node_num_excluded": (None if my_node_num is None else f"0x{my_node_num:08x}"),
"coverage": coverage,
"last_heard_mean_sec": args.last_heard_mean_sec,
"last_heard_max_sec": args.last_heard_max_sec,
}
}
with out_path.open("w", encoding="utf-8") as f:
# `ensure_ascii=False` so emoji short_names survive. `sort_keys=True` for
# determinism (insertion order varies by Python version otherwise).
f.write(json.dumps(meta, ensure_ascii=False, sort_keys=True) + "\n")
for node in nodes:
f.write(json.dumps(node, ensure_ascii=False, sort_keys=True) + "\n")
print(f"wrote {args.count} nodes to {out_path} ({out_path.stat().st_size} bytes)", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+33 -33
View File
@@ -219,41 +219,37 @@ with open(jsonLoc) as f:
jsonStr = re.sub("//.*","", f.read(), flags=re.MULTILINE)
userPrefs = json.loads(jsonStr)
pref_flags = []
# Pre-process the userPrefs
for pref in userPrefs:
if userPrefs[pref].startswith("{"):
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
elif userPrefs[pref].lstrip("-").replace(".", "").isdigit():
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
elif userPrefs[pref] == "true" or userPrefs[pref] == "false":
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
elif userPrefs[pref].startswith("meshtastic_"):
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
# If the value is a string, we need to wrap it in quotes
else:
pref_flags.append("-D" + pref + "=" + env.StringifyMacro(userPrefs[pref]) + "")
# General options that are passed to the C and C++ compilers
# Calculate unix epoch for current day (midnight)
current_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
build_epoch = int(current_date.timestamp())
flags = [
"-DAPP_VERSION=" + verObj["long"],
"-DAPP_VERSION_SHORT=" + verObj["short"],
"-DAPP_ENV=" + env.get("PIOENV"),
"-DAPP_REPO=" + repo_owner,
"-DBUILD_EPOCH=" + str(build_epoch),
] + pref_flags
cpp_defines = [
("APP_VERSION", verObj["long"]),
("APP_VERSION_SHORT", verObj["short"]),
("APP_ENV", env.get("PIOENV")),
("APP_REPO", repo_owner),
("BUILD_EPOCH", str(build_epoch)),
]
print("Using flags:")
for flag in flags:
print(flag)
# Process userPrefs into CPPDEFINES
for pref in userPrefs:
val = userPrefs[pref]
if val.startswith("{"):
cpp_defines.append((pref, val))
elif val.lstrip("-").replace(".", "").isdigit():
cpp_defines.append((pref, val))
elif val == "true" or val == "false":
cpp_defines.append((pref, val))
elif val.startswith("meshtastic_"):
cpp_defines.append((pref, val))
else:
cpp_defines.append((pref, env.StringifyMacro(val)))
projenv.Append(
CCFLAGS=flags,
)
print("Using CPPDEFINES:")
for d in cpp_defines:
print(f" -D{d[0]}={d[1]}")
projenv.Append(CPPDEFINES=cpp_defines)
for lb in env.GetLibBuilders():
if lb.name == "meshtastic-device-ui":
@@ -293,9 +289,12 @@ if ("HAS_TFT", 1) in env.get("CPPDEFINES", []):
board_arch = infer_architecture(env.BoardConfig())
should_skip_manifest = board_arch is None
# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
mtjson_deps = [] if should_skip_manifest else ["buildprog"]
if not should_skip_manifest and platform.name == "espressif32":
# Most platforms can generate the manifest as part of the default 'buildprog' target.
# Typically this passes success/failure properly.
mtjson_deps = ["buildprog"]
if platform.name == "espressif32":
# On ESP32, we need to explicitly depend upon the binary to prevent fake-success upon failure.
mtjson_deps = ["$BUILD_DIR/${PROGNAME}.bin"]
# Build littlefs image as part of mtjson target
# Equivalent to `pio run -t buildfs`
target_lfs = env.DataToBin(
@@ -309,7 +308,8 @@ if should_skip_manifest:
env.AddCustomTarget(
name="mtjson",
dependencies=mtjson_deps,
# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
dependencies=[],
actions=[skip_manifest],
title="Meshtastic Manifest (skipped)",
description="mtjson generation is skipped for native environments",
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Regenerate the fake-NodeDB fixtures: produces 250 / 500 / 1000 / 2000-node
# JSONL seed files + their compiled v25 protobufs.
#
# Layout:
# test/fixtures/nodedb/seed_v25_<N>.jsonl — COMMITTED, hand-editable.
# build/fixtures/nodedb/nodes_v25_<N>.proto — .gitignored, build artifact.
# Drop into /prefs/nodes.proto.
#
# Daily use: ./bin/regen-fake-nodedbs.sh
# - Recompiles protos from committed seeds (fresh wall-clock timestamps).
# Intentional seed bump: REGEN_SEEDS=yes ./bin/regen-fake-nodedbs.sh
# - Overwrites the committed JSONL files with freshly-seeded data.
set -euo pipefail
cd "$(dirname "$0")/.."
# 1) Make sure the Python protobuf bindings exist (in-tree generation; .gitignored).
if [[ ! -d bin/_generated/meshtastic ]]; then
echo "regenerating Python protobuf bindings (one-time)..."
./bin/regen-py-protos.sh
fi
# 2) Pick a Python interpreter that has the meshtastic deps installed.
# Prefer the mcp-server venv (most likely to be set up by the operator).
PY="python3"
for cand in mcp-server/.venv/bin/python3 .venv/bin/python3; do
if [[ -x "$cand" ]]; then
PY="$cand"
break
fi
done
# 3) Pinned seeds per size — bump only when you intentionally want different
# structural data committed. Parallel arrays so the script works on
# macOS bash 3.2 (no `declare -A`).
SIZES=(250 500 1000 2000)
SEEDS=(20260511 20260512 20260513 20260514)
REGEN_SEEDS="${REGEN_SEEDS:-no}"
mkdir -p build/fixtures/nodedb test/fixtures/nodedb
for i in 0 1 2 3; do
n="${SIZES[$i]}"
seed="${SEEDS[$i]}"
jsonl=$(printf "test/fixtures/nodedb/seed_v25_%04d.jsonl" "$n")
proto=$(printf "build/fixtures/nodedb/nodes_v25_%04d.proto" "$n")
if [[ "$REGEN_SEEDS" == "yes" || ! -f "$jsonl" ]]; then
$PY bin/gen-fake-nodedb-seed.py \
--count "$n" \
--seed "$seed" \
--out "$jsonl" \
--centroid 33.1284,-107.2528 \
--spread-km 60 \
--position-coverage 0.85 \
--telemetry-coverage 0.70 \
--environment-coverage 0.25 \
--status-coverage 0.40
echo " seed: $jsonl ($(wc -c < "$jsonl") bytes)"
fi
$PY bin/seed-json-to-proto.py --in "$jsonl" --out "$proto"
echo " proto: $proto ($(wc -c < "$proto") bytes)"
done
echo ""
echo "Done. To load on Portduino native:"
echo " cp build/fixtures/nodedb/nodes_v25_1000.proto ~/.portduino/default/prefs/nodes.proto"
echo ""
echo "To push to a hardware device:"
echo " Use the mcp-server tool: push_fake_nodedb(size=1000, target=\"hardware\", port=\"/dev/cu.usbmodemXXXX\", confirm=True)"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Regenerate Python protobuf bindings from the in-tree `protobufs/` submodule
# into `bin/_generated/`. Called by bin/regen-fake-nodedbs.sh; also useful as
# a standalone refresh after any change to a .proto file.
#
# Output is .gitignored — bindings are a build artifact.
#
# Namespace rewrite:
# The .proto files declare `package meshtastic;`, which makes protoc emit
# imports like `from meshtastic import mesh_pb2`. That conflicts with the
# PyPI `meshtastic` package (which the mcp-server relies on for its
# SerialInterface/BLEInterface transport). We post-process the generated
# files to live under `meshtastic_v25` instead — both the directory layout
# and all internal imports — so they coexist cleanly with the PyPI package.
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v protoc >/dev/null 2>&1; then
echo "ERROR: protoc not found in PATH." >&2
echo " macOS: brew install protobuf" >&2
echo " Ubuntu/Debian: apt install protobuf-compiler" >&2
exit 1
fi
OUT=bin/_generated
LOCAL_NS=meshtastic_v25
rm -rf "$OUT"
mkdir -p "$OUT"
# 1) Generate from the in-tree protos. nanopb.proto first so its descriptor
# is available for the [(nanopb).*] options on other messages.
protoc \
--proto_path=protobufs \
--python_out="$OUT" \
protobufs/nanopb.proto \
protobufs/meshtastic/*.proto
# 2) Move the generated `meshtastic/` directory to `meshtastic_v25/`.
mv "$OUT/meshtastic" "$OUT/$LOCAL_NS"
# 3) Rewrite internal imports: any reference to `meshtastic.X_pb2` or
# `from meshtastic import X_pb2` becomes `meshtastic_v25.*`.
python3 bin/_rewrite_proto_namespace.py "$OUT/$LOCAL_NS" "$LOCAL_NS"
# 4) Make the package importable.
touch "$OUT/__init__.py"
touch "$OUT/$LOCAL_NS/__init__.py"
echo "regenerated Python protobuf bindings -> $OUT/$LOCAL_NS/ (namespace: $LOCAL_NS)" >&2
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env bash
# Run native PlatformIO unit tests and emit a single, unambiguous RED/AMBER/GREEN verdict.
#
# Why this exists: PlatformIO reports failures three different ways ([FAILED], :FAIL:,
# [ERRORED]) and an all-pass run prints "N succeeded" with NO "0 failed" clause — so naive
# greps produce false greens (see .notes/test-passfail-filter.md). This script encodes the
# correct logic once, and cross-checks the number of suites that actually ran against the
# canonical set in test/ so a suite silently going missing shows up as AMBER, not green.
#
# Usage:
# ./bin/run-tests.sh # run all suites, full RAG + count cross-check
# ./bin/run-tests.sh -f test_utf8 # run one suite (no count cross-check)
# ./bin/run-tests.sh -e native # override env (default: coverage)
# ./bin/run-tests.sh --quiet # only print the final RESULT line
#
# Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER.
#
# The final line is machine-readable, e.g.:
# RESULT: GREEN 19/19 suites passed
# RESULT: AMBER 17/19 suites ran (missing: test_radio test_serial) — all that ran passed
# 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
# as [ERRORED]/SIGHUP. The script names it and points at running the binary bare.)
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR"
ENV="coverage"
FILTER=""
QUIET=false
PASSTHRU=()
while [[ $# -gt 0 ]]; do
case "$1" in
-f)
FILTER="$2"
PASSTHRU+=("-f" "$2")
shift 2
;;
-e)
ENV="$2"
shift 2
;;
--quiet)
QUIET=true
shift
;;
*)
PASSTHRU+=("$1")
shift
;;
esac
done
# Locate pio (PATH, then the standard PlatformIO venv).
PIO="$(command -v pio || command -v platformio || echo "$HOME/.platformio/penv/bin/pio")"
if [[ ! -x $PIO ]] && ! command -v "$PIO" >/dev/null 2>&1; then
echo "RESULT: RED pio not found (looked in PATH and ~/.platformio/penv/bin)"
exit 1
fi
LOG="$(mktemp -t meshtest.XXXXXX.log)"
MARKER=""
PROGRESS_PID=""
trap 'rm -f "$LOG" "${MARKER:-}"; [[ -n ${PROGRESS_PID:-} ]] && kill "$PROGRESS_PID" 2>/dev/null' EXIT
# 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.
mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
EXPECTED_COUNT=${#ALL_SUITES[@]}
# 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.
BASELINE_FILE=".pio/build/${ENV}/.runtests-objcount"
# Progress trail file (gitignored build dir). ALWAYS written so a backgrounded/piped run can be
# checked mid-build with `tail -f` — that's the whole point: don't fly blind on a 20-min rebuild.
PROGRESS_FILE=".pio/build/${ENV}/.runtests-progress"
# --- Progress heartbeat ------------------------------------------------------
# Emit ONE status line every few seconds: build = objects (re)compiled this run / cached total +
# best-effort ETA; test = suites finished / expected. Appends to $PROGRESS_FILE always (tail it to
# check on a backgrounded run); also live-updates the tty when $5=1 (interactive --quiet). Never
# touches $LOG, which is parsed for the verdict, so piped/CI captures stay clean.
progress_monitor() {
local marker="$1" objtotal="$2" testtotal="$3" pfile="$4" totty="$5" start now el done ran eta line
start=$(date +%s)
while :; do
now=$(date +%s)
el=$((now - start))
if grep -q 'Testing\.\.\.' "$LOG" 2>/dev/null; then
ran=$(grep -cE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" 2>/dev/null)
line=$(printf '[test] %s/%s suites done — %dm%02ds' "$ran" "$testtotal" $((el / 60)) $((el % 60)))
else
done=$(find ".pio/build/${ENV}" -name '*.o' -newer "$marker" 2>/dev/null | wc -l)
if ((objtotal > 0 && done > 0)); then
eta=$((objtotal > done ? (objtotal - done) * el / done : 0))
line=$(printf '[build] %d/%d objs — %dm%02ds — ETA ~%dm%02ds' \
"$done" "$objtotal" $((el / 60)) $((el % 60)) $((eta / 60)) $((eta % 60)))
else
# done==0 (incremental: nothing to rebuild yet) or no cached baseline — no ETA yet.
line=$(printf '[build] %d objs compiled — %dm%02ds' "$done" $((el / 60)) $((el % 60)))
fi
fi
printf '%s\n' "$line" >>"$pfile" 2>/dev/null # file trail (always)
[[ $totty == 1 ]] && printf '\r\033[K%s' "$line" >/dev/tty 2>/dev/null # live line (human)
sleep 4
done
}
# Launch the heartbeat for every run. It writes the progress file unconditionally; the live tty
# line only when interactive AND --quiet (where pio's own output is hidden — otherwise pio's
# streamed compile lines already show progress and a \r line would just fight them).
mkdir -p ".pio/build/${ENV}" 2>/dev/null || true
: >"$PROGRESS_FILE" 2>/dev/null || true
MARKER="$(mktemp -t meshtest-mark.XXXXXX)"
TOTTY=0
{ $QUIET && [[ -t 1 ]]; } && TOTTY=1
progress_monitor "$MARKER" "$(cat "$BASELINE_FILE" 2>/dev/null || echo 0)" \
"$([[ -n $FILTER ]] && echo 1 || echo "$EXPECTED_COUNT")" "$PROGRESS_FILE" "$TOTTY" &
PROGRESS_PID=$!
if ! $QUIET; then
echo "Running: $PIO test -e $ENV ${PASSTHRU[*]-} (expecting $EXPECTED_COUNT suites)"
fi
echo "progress: tail -f $PROGRESS_FILE" >&2
# Run pio, tee to log. PIPESTATUS[0] is pio's real exit (NOT tee's).
if $QUIET; then
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" >"$LOG" 2>&1
else
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" 2>&1 | tee "$LOG"
fi
PIO_RC=${PIPESTATUS[0]}
# Stop the heartbeat, clear its line, and cache this build's object total for next time.
if [[ -n $PROGRESS_PID ]]; then
kill "$PROGRESS_PID" 2>/dev/null
wait "$PROGRESS_PID" 2>/dev/null
PROGRESS_PID=""
# Clear the live line only if we were writing one — opening /dev/tty when there is none is
# itself a redirect-open error the trailing 2>/dev/null cannot suppress.
[[ $TOTTY == 1 ]] && printf '\r\033[K' >/dev/tty 2>/dev/null
fi
[[ -d ".pio/build/${ENV}" ]] && find ".pio/build/${ENV}" -name '*.o' 2>/dev/null | wc -l >"$BASELINE_FILE" 2>/dev/null || true
# --- Outcome detection -------------------------------------------------------
# The SAME outcome is spelled differently depending on which layer emitted the line — this is
# the trap that produces false greens (grepping ":PASS" misses pio's "[PASSED]", grepping
# "[FAILED]" misses Unity's ":FAIL:"). So every regex below matches BOTH spellings:
# pass: Unity per-assertion ":PASS" | pio per-suite "[PASSED]" | summary "N succeeded"
# fail: Unity per-assertion ":FAIL:" | pio per-suite "[FAILED]" | summary "M failed"
# error: pio build/crash "[ERRORED]" | Unity "M Failures" | compiler "error:"
# Match \b after :PASS/:FAIL so ":PASSED"/":FAILED" forms are also caught either way.
FAIL_RE=':FAIL\b|\[FAILED\]|\[ERRORED\]|[1-9][0-9]* failed|[0-9]+ Tests [1-9][0-9]* Failures|error:|undefined reference|Segmentation fault|terminate called|SIGHUP|SIGSEGV|SIGABRT'
# Positive proof tests actually ran & passed (absence != success). Accept any pass spelling:
# the per-test/per-suite tokens OR a success summary line.
PASS_RE=':PASS\b|\[PASSED\]|test cases: *[0-9]+ succeeded|[0-9]+ Tests 0 Failures'
# Sanitizer (ASan/LSan/UBSan/TSan) fault signatures. The coverage build is sanitizer-instrumented
# and aborts NON-ZERO at exit on a fault — most often a LeakSanitizer leak — AFTER every test has
# already printed [PASSED]. pio then reports [ERRORED]/SIGHUP with no :FAIL: anywhere, so it
# masquerades as a phantom "N-1 of N succeeded". See .notes/test-passfail-filter.md.
# Match only real FAULT lines, never the benign "AddressSanitizer: failed to intercept '...'"
# startup noise that prints on every sanitizer run (it'd mislabel a normal [FAILED] as a leak).
# Formats per LLVM/Google sanitizer docs: ASan/LSan emit "==PID==ERROR: <San>: ...", UBSan emits
# "file:line:col: runtime error: ...", TSan emits "WARNING: ThreadSanitizer: ..."; all close with
# a "SUMMARY: <San>: ..." line (LSan-under-ASan reports its SUMMARY as "AddressSanitizer").
SAN_RE='(ERROR|WARNING): (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|SUMMARY: (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|Direct leak of|Indirect leak of|detected memory leaks|heap-use-after-free|heap-buffer-overflow|stack-buffer-overflow|attempting double-free|LeakSanitizer has encountered a fatal error|runtime error:'
# Suites that produced a per-suite verdict. pio emits "coverage:test_x [PASSED|FAILED|ERRORED]";
# a SKIPPED suite (hardware-only on native) is "accounted for" too, so it doesn't read as missing.
mapfile -t RAN_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" |
sed -E "s/^${ENV}:(test_[a-z0-9_]+) .*/\1/" | sort -u)
RAN_COUNT=${#RAN_SUITES[@]}
# Suites pio explicitly skipped (don't count these as "missing" in the canonical cross-check).
mapfile -t SKIPPED_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+.*\bSKIPPED\b" "$LOG" |
grep -oE "test_[a-z0-9_]+" | sort -u)
verdict_red() {
local detail bin
detail="$(grep -nE '\[FAILED\]|:FAIL:|\[ERRORED\]' "$LOG" | head -3 | sed 's/^/ /')"
echo ""
echo "RED — failures detected:"
[[ -n $detail ]] && echo "$detail"
grep -E 'test cases:' "$LOG" | tail -1 | sed 's/^/ /'
# Path to the test binary for the "run it bare" hint. For native/coverage the test program is
# the env executable (e.g. .pio/build/coverage/meshtasticd), NOT a file named 'program'.
bin="$(find ".pio/build/${ENV}" -maxdepth 1 -type f -executable ! -name '*.so' 2>/dev/null | head -1)"
[[ -z $bin ]] && bin=".pio/build/${ENV}/<program> (build it first: $PIO test -e ${ENV} ${FILTER:+-f $FILTER} --without-testing)"
# Sanitizer fault (ASan/LSan/UBSan/TSan): name the real cause instead of "build/crash error".
if grep -qE "$SAN_RE" "$LOG"; then
grep -nE "$SAN_RE" "$LOG" | head -4 | sed 's/^/ /'
echo " -> sanitizer fault: if every test above is PASS, this is an exit-time abort, not a failed assertion."
echo " -> read the full report by running the binary BARE (gdb hides it via ptrace): ./$bin 2>&1 | tail -40"
echo "RESULT: RED sanitizer fault — $(grep -ohE 'SUMMARY: [A-Za-z]+Sanitizer:.*' "$LOG" | tail -1 || echo 'see report above')"
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.
if grep -qE "$PASS_RE" "$LOG" && grep -qE '\[ERRORED\]|SIGHUP|SIGABRT' "$LOG" && ! grep -qE ':FAIL\b|\[FAILED\]' "$LOG"; then
echo " -> all tests passed but the process aborted at EXIT — likely an ASan/LSan fault whose report"
echo " the runner swallowed (commonly shown as SIGHUP). Run the binary BARE to see it: ./$bin 2>&1 | tail -40"
echo "RESULT: RED exit-time abort (tests passed; likely sanitizer — see hint above)"
exit 1
fi
echo "RESULT: RED $(grep -oE '[0-9]+ failed' "$LOG" | tail -1 || echo 'build/crash error')"
exit 1
}
# RED: pio non-zero, any failure marker, or no positive summary at all (build died early).
if [[ $PIO_RC -ne 0 ]] || grep -qE "$FAIL_RE" "$LOG"; then
verdict_red
fi
if ! grep -qE "$PASS_RE" "$LOG"; then
echo ""
echo "RESULT: RED no success summary found (build error / no tests ran?) — see log"
exit 1
fi
# AMBER: everything that ran passed, but (full run only) a canonical suite neither ran NOR was
# explicitly skipped — i.e. it silently went missing. SKIPPED suites are accounted for.
ACCOUNTED_COUNT=$((RAN_COUNT + ${#SKIPPED_SUITES[@]}))
if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then
missing=()
for s in "${ALL_SUITES[@]}"; do
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"
exit 2
fi
# GREEN.
if [[ -n $FILTER ]]; then
echo "RESULT: GREEN ${RAN_COUNT} suite(s) passed (filtered: $FILTER)"
else
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed"
fi
exit 0
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""Compile a committed seed JSONL into a binary meshtastic_NodeDatabase v25 proto.
The input is produced by `bin/gen-fake-nodedb-seed.py`. Timestamps in the JSONL
are stored as `*_offset_sec` (seconds before "now"); this script resolves them
to absolute epochs using `--now-epoch` (default: current wall clock).
Output is a raw `pb_encode`-compatible binary that can be dropped at
`/prefs/nodes.proto` on the device (Portduino prefs dir or hardware via
XModem) and loaded by `NodeDB::loadFromDisk` at boot.
Wire format reference:
protobufs/meshtastic/deviceonly.proto (NodeDatabase, NodeInfoLite, sat entries)
src/mesh/NodeDB.h:467-484 (bitfield bit positions)
src/mesh/NodeDB.cpp:1523-1524 (pb_decode entry point)
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
import time
from typing import Any
# Prefer the in-tree generated Python protobuf bindings (bin/_generated/meshtastic_v25/)
# because the firmware branch's protos (v25 NodeDatabase satellite arrays, slim
# NodeInfoLite) are typically newer than what the PyPI `meshtastic` package
# ships. Run `bin/regen-py-protos.sh` to (re)generate.
#
# Namespace note: the local bindings live under `meshtastic_v25` (NOT `meshtastic`)
# to avoid shadowing the PyPI `meshtastic` package — bin/regen-py-protos.sh
# post-processes the protoc output to rename the package.
_HERE = pathlib.Path(__file__).resolve().parent
_LOCAL_PROTO_DIR = _HERE / "_generated"
if _LOCAL_PROTO_DIR.is_dir():
sys.path.insert(0, str(_LOCAL_PROTO_DIR))
try:
from meshtastic_v25.deviceonly_pb2 import ( # type: ignore[import-not-found]
NodeDatabase,
NodeInfoLite,
NodePositionEntry,
NodeTelemetryEntry,
NodeEnvironmentEntry,
NodeStatusEntry,
PositionLite,
)
from meshtastic_v25.mesh_pb2 import HardwareModel, Position, StatusMessage # type: ignore[import-not-found]
from meshtastic_v25.config_pb2 import Config # type: ignore[import-not-found]
from meshtastic_v25.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics # type: ignore[import-not-found]
except ImportError as local_err:
# Fall back to the PyPI package if in-tree bindings haven't been generated.
# Will fail the v25 assertion below if the PyPI package predates the
# satellite-DB schema, but at least gives a clear "run regen-py-protos.sh"
# error message instead of an opaque ImportError.
try:
from meshtastic.protobuf.deviceonly_pb2 import (
NodeDatabase,
NodeInfoLite,
NodePositionEntry,
NodeTelemetryEntry,
NodeEnvironmentEntry,
NodeStatusEntry,
PositionLite,
)
from meshtastic.protobuf.mesh_pb2 import HardwareModel, Position, StatusMessage
from meshtastic.protobuf.config_pb2 import Config
from meshtastic.protobuf.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics
except ImportError as pypi_err:
print(
"ERROR: could not import meshtastic protobuf bindings.\n"
" In-tree generation: run `bin/regen-py-protos.sh` (requires protoc).\n"
" PyPI fallback: `pip install meshtastic` (may lag firmware branch).\n"
f" local error (meshtastic_v25): {local_err}\n"
f" pypi error (meshtastic.protobuf): {pypi_err}",
file=sys.stderr,
)
sys.exit(1)
# Fail loudly if bindings predate v25 (no satellite arrays).
assert (
hasattr(NodeDatabase, "DESCRIPTOR")
and "positions" in NodeDatabase.DESCRIPTOR.fields_by_name
), (
"Loaded meshtastic bindings are older than v25 (NodeDatabase.positions missing). "
"Run `bin/regen-py-protos.sh` against the in-tree protobufs/ submodule."
)
# ---------------------------------------------------------------------------
# Bitfield bit positions (mirror src/mesh/NodeDB.h:467-484).
# ---------------------------------------------------------------------------
BIT_IS_KEY_MANUALLY_VERIFIED = 0
BIT_IS_MUTED = 1
BIT_VIA_MQTT = 2
BIT_IS_FAVORITE = 3
BIT_IS_IGNORED = 4
BIT_HAS_USER = 5
BIT_IS_LICENSED = 6
BIT_IS_UNMESSAGABLE = 7
BIT_HAS_IS_UNMESSAGABLE = 8
BITFIELD_LAYOUT = (
# JSON key bit position
("is_key_manually_verified", BIT_IS_KEY_MANUALLY_VERIFIED),
("is_muted", BIT_IS_MUTED),
("via_mqtt", BIT_VIA_MQTT),
("is_favorite", BIT_IS_FAVORITE),
("is_ignored", BIT_IS_IGNORED),
("has_user", BIT_HAS_USER),
("is_licensed", BIT_IS_LICENSED),
("is_unmessagable", BIT_IS_UNMESSAGABLE),
("has_is_unmessagable", BIT_HAS_IS_UNMESSAGABLE),
)
def _pack_bitfield(bf: dict[str, bool]) -> int:
out = 0
for key, shift in BITFIELD_LAYOUT:
if bf.get(key, False):
out |= (1 << shift)
return out
def _validate_node(node: dict[str, Any]) -> None:
"""Friendly errors so hand-editors get clear feedback."""
if "num" not in node or not isinstance(node["num"], str):
raise ValueError(f"node missing/invalid 'num' (must be hex string): {node!r}")
if "long_name" not in node:
raise ValueError(f"node {node['num']}: missing 'long_name'")
if len(node["long_name"]) > 24:
raise ValueError(
f"node {node['num']}: long_name {node['long_name']!r} is "
f"{len(node['long_name'])} chars; max 24 (nanopb max_size:25 minus NUL)"
)
if "short_name" in node:
# short_name max_size:5 (incl. NUL) → 4 bytes of content.
# Char count is irrelevant — emojis with variation selectors (e.g. ❄️ = 6 B)
# would slip past a `len(str) > 4` check. Always measure bytes.
b = node["short_name"].encode("utf-8")
if len(b) > 4:
raise ValueError(
f"node {node['num']}: short_name {node['short_name']!r} is "
f"{len(b)} bytes UTF-8; max 4 (nanopb max_size:5 minus NUL)"
)
pk = node.get("public_key_hex", "")
if pk and len(pk) != 64:
raise ValueError(
f"node {node['num']}: public_key_hex must be 64 hex chars or empty; "
f"got {len(pk)} chars"
)
if pk:
try:
bytes.fromhex(pk)
except ValueError as e:
raise ValueError(f"node {node['num']}: public_key_hex is not valid hex: {e}")
def _resolve_time(
node: dict[str, Any],
field_absolute: str,
field_offset: str,
now_epoch: int,
) -> int:
"""If `field_absolute` is set, use it; else compute `now_epoch - offset`."""
if field_absolute in node and node[field_absolute] is not None:
return int(node[field_absolute])
offset = node.get(field_offset, 0)
return max(0, int(now_epoch) - int(offset))
def _build_node_info_lite(node: dict[str, Any], now_epoch: int) -> NodeInfoLite:
_validate_node(node)
info = NodeInfoLite()
info.num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
info.long_name = node.get("long_name", "")
info.short_name = node.get("short_name", "")
# Enum lookups will raise ValueError on unknown names — that's exactly what we want.
info.hw_model = HardwareModel.Value(node.get("hw_model", "UNSET"))
info.role = Config.DeviceConfig.Role.Value(node.get("role", "CLIENT"))
pk_hex = node.get("public_key_hex", "")
if pk_hex:
info.public_key = bytes.fromhex(pk_hex)
info.snr = float(node.get("snr", 0.0))
info.channel = int(node.get("channel", 0))
if "hops_away" in node:
# `optional uint32 hops_away = 9;` — in Python protobuf, assigning the
# field implicitly sets HasField("hops_away") to True. No has_hops_away
# setter exists (unlike the C++ nanopb-generated header).
info.hops_away = int(node["hops_away"])
info.next_hop = int(node.get("next_hop", 0))
info.last_heard = _resolve_time(node, "last_heard", "last_heard_offset_sec", now_epoch)
info.bitfield = _pack_bitfield(node.get("bitfield", {}))
return info
def _build_position_entry(num: int, pos: dict[str, Any], now_epoch: int) -> NodePositionEntry:
entry = NodePositionEntry()
entry.num = num
pl = PositionLite()
# Firmware stores lat/long as int32 in 1e-7 degrees.
pl.latitude_i = int(round(float(pos["latitude"]) * 1e7))
pl.longitude_i = int(round(float(pos["longitude"]) * 1e7))
pl.altitude = int(pos.get("altitude", 0))
pl.time = _resolve_time(pos, "time", "time_offset_sec", now_epoch)
pl.location_source = Position.LocSource.Value(pos.get("location_source", "LOC_UNSET"))
entry.position.CopyFrom(pl)
return entry
def _build_telemetry_entry(num: int, tel: dict[str, Any]) -> NodeTelemetryEntry:
entry = NodeTelemetryEntry()
entry.num = num
dm = DeviceMetrics()
if "battery_level" in tel:
dm.battery_level = int(tel["battery_level"])
if "voltage" in tel:
dm.voltage = float(tel["voltage"])
if "channel_utilization" in tel:
dm.channel_utilization = float(tel["channel_utilization"])
if "air_util_tx" in tel:
dm.air_util_tx = float(tel["air_util_tx"])
if "uptime_seconds" in tel:
dm.uptime_seconds = int(tel["uptime_seconds"])
entry.device_metrics.CopyFrom(dm)
return entry
def _build_environment_entry(num: int, env: dict[str, Any]) -> NodeEnvironmentEntry:
entry = NodeEnvironmentEntry()
entry.num = num
em = EnvironmentMetrics()
if "temperature" in env:
em.temperature = float(env["temperature"])
if "relative_humidity" in env:
em.relative_humidity = float(env["relative_humidity"])
if "barometric_pressure" in env:
em.barometric_pressure = float(env["barometric_pressure"])
if "iaq" in env:
em.iaq = int(env["iaq"])
entry.environment_metrics.CopyFrom(em)
return entry
def _build_status_entry(num: int, status: dict[str, Any]) -> NodeStatusEntry:
# `StatusMessage` (mesh.proto:1445) has a single `string status` field.
entry = NodeStatusEntry()
entry.num = num
sm = StatusMessage()
if "status" in status:
sm.status = str(status["status"])
entry.status.CopyFrom(sm)
return entry
def compile_jsonl_to_proto(jsonl_path: pathlib.Path, now_epoch: int) -> bytes:
"""Read a seed JSONL and return the encoded NodeDatabase bytes."""
lines = jsonl_path.read_text(encoding="utf-8").splitlines()
if not lines:
raise ValueError(f"{jsonl_path} is empty")
meta_line = lines[0]
meta_obj = json.loads(meta_line)
meta = meta_obj.get("_meta", {})
version = meta.get("version")
if version != 25:
raise ValueError(
f"{jsonl_path}: meta version is {version!r}; this compiler "
f"requires version=25. Regenerate the seed with the matching tooling."
)
db = NodeDatabase()
db.version = 25
for ln, raw in enumerate(lines[1:], start=2):
raw = raw.strip()
if not raw:
continue
try:
node = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"{jsonl_path}:{ln} JSON parse error: {e}")
num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
# Header
info = _build_node_info_lite(node, now_epoch)
db.nodes.append(info)
# Satellites (nullable)
if node.get("position"):
db.positions.append(_build_position_entry(num, node["position"], now_epoch))
if node.get("telemetry"):
db.telemetry.append(_build_telemetry_entry(num, node["telemetry"]))
if node.get("environment"):
db.environment.append(_build_environment_entry(num, node["environment"]))
if node.get("status"):
db.status.append(_build_status_entry(num, node["status"]))
return db.SerializeToString()
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(
description="Compile a seed JSONL into a binary v25 NodeDatabase proto.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--in", dest="in_path", required=True, help="Input seed JSONL.")
p.add_argument("--out", required=True, help="Output binary .proto path.")
p.add_argument(
"--now-epoch",
type=int,
default=None,
help="Pin 'now' to this Unix epoch (for byte-identical CI). Default: time.time().",
)
args = p.parse_args(argv)
in_path = pathlib.Path(args.in_path)
if not in_path.is_file():
print(f"input not found: {in_path}", file=sys.stderr)
return 2
now_epoch = args.now_epoch if args.now_epoch is not None else int(time.time())
try:
encoded = compile_jsonl_to_proto(in_path, now_epoch)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 3
out_path = pathlib.Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(encoded)
print(
f"compiled {in_path} -> {out_path} ({len(encoded)} bytes, now_epoch={now_epoch})",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
-95
View File
@@ -1,95 +0,0 @@
import sys
import os
import json
from github import Github
def parseFile(path):
with open(path, "r") as f:
data = json.loads(f)
for file in data["files"]:
if file["name"].endswith(".bin"):
return file["name"], file["bytes"]
if len(sys.argv) != 4:
print(f"expected usage: {sys.argv[0]} <PR number> <path to old-manifests> <path to new-manifests>")
sys.exit(1)
pr_number = int(sys.argv[1])
token = os.getenv("GITHUB_TOKEN")
if not token:
raise EnvironmentError("GITHUB_TOKEN not found in environment.")
repo_name = os.getenv("GITHUB_REPOSITORY") # "owner/repo"
if not repo_name:
raise EnvironmentError("GITHUB_REPOSITORY not found in environment.")
oldFiles = sys.argv[2]
old = set(os.path.join(oldFiles, f) for f in os.listdir(oldFiles) if os.path.isfile(f))
newFiles = sys.argv[3]
new = set(os.path.join(newFiles, f) for f in os.listdir(newFiles) if os.path.isfile(f))
startMarkdown = "# Target Size Changes\n\n"
markdown = ""
newlyIntroduced = new - old
if len(newlyIntroduced) > 0:
markdown += "## Newly Introduced Targets\n\n"
# create a table
markdown += "| File | Size |\n"
markdown += "| ---- | ---- |\n"
for f in newlyIntroduced:
name, size = parseFile(f)
markdown += f"| `{name}` | {size}b |\n"
# do not log removed targets
# PRs only run a small subset of builds, so removed targets are not meaningful
# since they are very likely to just be not ran in PR CI
both = old & new
degradations = []
improvements = []
for f in both:
oldName, oldSize = parseFile(f)
_, newSize = parseFile(f)
if oldSize != newSize:
if newSize < oldSize:
improvements.append((oldName, oldSize, newSize))
else:
degradations.append((oldName, oldSize, newSize))
if len(degradations) > 0:
markdown += "\n## Degradation\n\n"
# create a table
markdown += "| File | Difference | Old Size | New Size |\n"
markdown += "| ---- | ---------- | -------- | -------- |\n"
for oldName, oldSize, newSize in degradations:
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
if len(improvements) > 0:
markdown += "\n## Improvement\n\n"
# create a table
markdown += "| File | Difference | Old Size | New Size |\n"
markdown += "| ---- | ---------- | -------- | -------- |\n"
for oldName, oldSize, newSize in improvements:
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
if len(markdown) == 0:
markdown = "No changes in target sizes detected."
g = Github(token)
repo = g.get_repo(repo_name)
pr = repo.get_pull(pr_number)
existing_comment = None
for comment in pr.get_issue_comments():
if comment.body.startswith(startMarkdown):
existing_comment = comment
break
final_markdown = startMarkdown + markdown
if existing_comment:
existing_comment.edit(body=final_markdown)
else:
pr.create_issue_comment(body=final_markdown)
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Compare firmware size reports and generate a markdown summary.
Usage:
size_report.py <new_sizes.json> [--baseline <label>:<old_sizes.json>]...
Examples:
# Compare PR against develop and master baselines
size_report.py pr.json --baseline develop:develop.json --baseline master:master.json
# Single baseline comparison
size_report.py pr.json --baseline develop:develop.json
# No baselines — shows sizes with blank delta columns
size_report.py pr.json
"""
import argparse
import json
import sys
def load_sizes(path):
with open(path) as f:
return json.load(f)
def format_delta(n):
"""Format byte delta with sign and human-friendly suffix."""
sign = "+" if n > 0 else ""
if abs(n) >= 1024:
return f"{sign}{n:,} ({sign}{n / 1024:.1f} KB)"
return f"{sign}{n:,}"
def generate_markdown(new_sizes, baselines, top_n=5):
"""Generate a single table with current size and delta columns per baseline.
baselines: list of (label, old_sizes_dict), may be empty
"""
labels = [label for label, _ in baselines]
# Build rows: (board, current_size, [(delta, abs_delta) per baseline])
rows = []
for board in sorted(new_sizes):
current = new_sizes[board]
deltas = []
for _, old_sizes in baselines:
old = old_sizes.get(board)
if old is not None:
d = current - old
deltas.append((d, abs(d)))
else:
deltas.append((None, 0))
# Sort key: max abs delta across baselines (biggest changes first)
max_abs = max((ad for _, ad in deltas), default=0)
rows.append((board, current, deltas, max_abs))
rows.sort(key=lambda r: r[3], reverse=True)
# Summary line
sections = []
summary_parts = [f"{len(new_sizes)} targets"]
for i, (label, old_sizes) in enumerate(baselines):
increases = sum(
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] > 0
)
decreases = sum(
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] < 0
)
net = sum(
deltas[i][0] for _, _, deltas, _ in rows if deltas[i][0] is not None
)
parts = []
if increases:
parts.append(f"{increases} increased")
if decreases:
parts.append(f"{decreases} decreased")
if parts:
parts.append(f"net {format_delta(net)}")
summary_parts.append(f"vs `{label}`: {', '.join(parts)}")
else:
summary_parts.append(f"vs `{label}`: no changes")
if not baselines:
summary_parts.append("no baseline available yet")
sections.append(f"**{' | '.join(summary_parts)}**\n")
# Table header
header = "| Target | Size |"
separator = "|--------|-----:|"
for label in labels:
header += f" vs `{label}` |"
separator += "----------:|"
sections.append(header)
sections.append(separator)
def format_row(board, current, deltas):
row = f"| `{board}` | {current:,} |"
for d, _ in deltas:
if d is None:
row += " |"
elif d == 0:
row += " 0 |"
else:
icon = "📈" if d > 0 else "📉"
row += f" {icon} {format_delta(d)} |"
return row
# Top N rows always visible
top = rows[:top_n]
for board, current, deltas, _ in top:
sections.append(format_row(board, current, deltas))
# Remaining rows in expandable section
rest = rows[top_n:]
if rest:
sections.append("")
sections.append(
f"<details><summary>Show {len(rest)} more target(s)</summary>\n"
)
sections.append(header)
sections.append(separator)
for board, current, deltas, _ in rest:
sections.append(format_row(board, current, deltas))
sections.append("\n</details>")
sections.append("")
return "\n".join(sections)
def main():
parser = argparse.ArgumentParser(description="Compare firmware size reports")
parser.add_argument("new_sizes", help="Path to new sizes JSON")
parser.add_argument(
"--baseline",
action="append",
default=[],
metavar="LABEL:PATH",
help="Baseline to compare against (e.g. develop:develop.json)",
)
parser.add_argument(
"--top",
type=int,
default=5,
help="Number of top changes to show before collapsing (default: 5)",
)
args = parser.parse_args()
new_sizes = load_sizes(args.new_sizes)
# Silence output when no targets were built — repo maintainer choice
if not new_sizes:
return
baselines = []
for b in args.baseline:
if ":" not in b:
print(f"Error: baseline must be LABEL:PATH, got '{b}'", file=sys.stderr)
sys.exit(1)
label, path = b.split(":", 1)
baselines.append((label, load_sizes(path)))
md = generate_markdown(new_sizes, baselines, top_n=args.top)
print(md)
if __name__ == "__main__":
main()
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""Tests for bin/collect_sizes.py and bin/size_report.py."""
import json
import os
import subprocess
import sys
import tempfile
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "bin")
def make_manifest(target, firmware_bytes, extra_files=None):
"""Create a minimal .mt.json manifest dict."""
files = [{"name": f"firmware-{target}-2.6.0.bin", "bytes": firmware_bytes}]
if extra_files:
files.extend(extra_files)
return {
"platformioTarget": target,
"version": "2.6.0.test",
"files": files,
}
def write_manifests(tmpdir, manifests):
"""Write manifest dicts as .mt.json files into tmpdir."""
for target, data in manifests.items():
path = os.path.join(tmpdir, f"firmware-{target}.mt.json")
with open(path, "w") as f:
json.dump(data, f)
def run_script(script, args):
"""Run a Python script and return (returncode, stdout, stderr)."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, script)] + args,
capture_output=True,
text=True,
)
return result.returncode, result.stdout, result.stderr
def test_collect_sizes_basic():
"""collect_sizes picks up firmware-*.bin entries from manifests."""
with tempfile.TemporaryDirectory() as tmpdir:
outfile = os.path.join(tmpdir, "sizes.json")
manifests = {
"heltec-v3": make_manifest("heltec-v3", 1048576),
"rak4631": make_manifest("rak4631", 524288),
"tbeam": make_manifest("tbeam", 786432),
}
write_manifests(tmpdir, manifests)
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
assert rc == 0, f"collect_sizes failed: {stderr}"
assert "3 targets" in stdout
with open(outfile) as f:
sizes = json.load(f)
assert sizes == {"heltec-v3": 1048576, "rak4631": 524288, "tbeam": 786432}
def test_collect_sizes_fallback_bin():
"""collect_sizes falls back to non-firmware-prefixed .bin if no firmware-*.bin."""
with tempfile.TemporaryDirectory() as tmpdir:
outfile = os.path.join(tmpdir, "sizes.json")
# Manifest with only a generic .bin (no firmware- prefix)
data = {
"platformioTarget": "custom-board",
"files": [
{"name": "littlefs-custom-board.bin", "bytes": 100000},
{"name": "custom-board.bin", "bytes": 500000},
],
}
path = os.path.join(tmpdir, "firmware-custom-board.mt.json")
with open(path, "w") as f:
json.dump(data, f)
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
assert rc == 0, f"collect_sizes failed: {stderr}"
with open(outfile) as f:
sizes = json.load(f)
assert sizes == {"custom-board": 500000}
def test_collect_sizes_skips_ota_littlefs():
"""collect_sizes ignores ota/littlefs/bleota .bin files in fallback."""
with tempfile.TemporaryDirectory() as tmpdir:
outfile = os.path.join(tmpdir, "sizes.json")
data = {
"platformioTarget": "board-x",
"files": [
{"name": "littlefs-board-x.bin", "bytes": 100000},
{"name": "bleota-board-x.bin", "bytes": 50000},
{"name": "mt-board-x-ota.bin", "bytes": 60000},
],
}
path = os.path.join(tmpdir, "firmware-board-x.mt.json")
with open(path, "w") as f:
json.dump(data, f)
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
assert rc == 0
with open(outfile) as f:
sizes = json.load(f)
# No valid firmware .bin found, board should be absent
assert sizes == {}
def test_collect_sizes_ignores_non_mt_json():
"""collect_sizes skips non .mt.json files."""
with tempfile.TemporaryDirectory() as tmpdir:
outfile = os.path.join(tmpdir, "sizes.json")
# Write a valid manifest
manifests = {"rak4631": make_manifest("rak4631", 500000)}
write_manifests(tmpdir, manifests)
# Write a decoy file
with open(os.path.join(tmpdir, "readme.txt"), "w") as f:
f.write("not a manifest")
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
assert rc == 0
with open(outfile) as f:
sizes = json.load(f)
assert list(sizes.keys()) == ["rak4631"]
def test_size_report_no_baseline():
"""size_report with no baselines shows sizes only."""
with tempfile.TemporaryDirectory() as tmpdir:
sizes_file = os.path.join(tmpdir, "new.json")
with open(sizes_file, "w") as f:
json.dump({"heltec-v3": 1000000, "rak4631": 500000}, f)
rc, stdout, stderr = run_script("size_report.py", [sizes_file])
assert rc == 0, f"size_report failed: {stderr}"
assert "2 targets" in stdout
assert "no baseline available yet" in stdout
assert "`heltec-v3`" in stdout
assert "`rak4631`" in stdout
def test_size_report_with_baseline():
"""size_report shows deltas against a baseline."""
with tempfile.TemporaryDirectory() as tmpdir:
new_file = os.path.join(tmpdir, "new.json")
old_file = os.path.join(tmpdir, "old.json")
with open(new_file, "w") as f:
json.dump({"heltec-v3": 1050000, "rak4631": 500000, "tbeam": 800000}, f)
with open(old_file, "w") as f:
json.dump({"heltec-v3": 1000000, "rak4631": 500000, "tbeam": 810000}, f)
rc, stdout, stderr = run_script(
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
)
assert rc == 0, f"size_report failed: {stderr}"
assert "3 targets" in stdout
assert "1 increased" in stdout
assert "1 decreased" in stdout
# heltec-v3 grew by 50000
assert "📈" in stdout
# tbeam shrank by 10000
assert "📉" in stdout
# rak4631 unchanged
assert "vs `develop`" in stdout
def test_size_report_multiple_baselines():
"""size_report handles multiple baselines."""
with tempfile.TemporaryDirectory() as tmpdir:
new_file = os.path.join(tmpdir, "new.json")
dev_file = os.path.join(tmpdir, "develop.json")
master_file = os.path.join(tmpdir, "master.json")
with open(new_file, "w") as f:
json.dump({"board-a": 100000}, f)
with open(dev_file, "w") as f:
json.dump({"board-a": 95000}, f)
with open(master_file, "w") as f:
json.dump({"board-a": 90000}, f)
rc, stdout, stderr = run_script(
"size_report.py",
[new_file, "--baseline", f"develop:{dev_file}", "--baseline", f"master:{master_file}"],
)
assert rc == 0, f"size_report failed: {stderr}"
assert "vs `develop`" in stdout
assert "vs `master`" in stdout
def test_size_report_new_target_no_baseline_entry():
"""size_report handles targets not present in baseline (new boards)."""
with tempfile.TemporaryDirectory() as tmpdir:
new_file = os.path.join(tmpdir, "new.json")
old_file = os.path.join(tmpdir, "old.json")
with open(new_file, "w") as f:
json.dump({"new-board": 300000, "existing": 500000}, f)
with open(old_file, "w") as f:
json.dump({"existing": 500000}, f)
rc, stdout, stderr = run_script(
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
)
assert rc == 0, f"size_report failed: {stderr}"
assert "`new-board`" in stdout
assert "no changes" in stdout # only existing is compared, delta=0
def test_size_report_all_unchanged():
"""size_report shows 'no changes' when all sizes match."""
with tempfile.TemporaryDirectory() as tmpdir:
sizes_file = os.path.join(tmpdir, "sizes.json")
with open(sizes_file, "w") as f:
json.dump({"board-a": 100000, "board-b": 200000}, f)
rc, stdout, stderr = run_script(
"size_report.py", [sizes_file, "--baseline", f"develop:{sizes_file}"]
)
assert rc == 0, f"size_report failed: {stderr}"
assert "no changes" in stdout
def test_collect_sizes_bad_args():
"""collect_sizes exits with error on wrong arg count."""
rc, stdout, stderr = run_script("collect_sizes.py", [])
assert rc == 1
assert "Usage" in stderr
def test_size_report_bad_baseline_format():
"""size_report exits with error on malformed --baseline."""
with tempfile.TemporaryDirectory() as tmpdir:
sizes_file = os.path.join(tmpdir, "sizes.json")
with open(sizes_file, "w") as f:
json.dump({"x": 1}, f)
rc, stdout, stderr = run_script(
"size_report.py", [sizes_file, "--baseline", "no-colon-here"]
)
assert rc == 1
assert "LABEL:PATH" in stderr
if __name__ == "__main__":
tests = [v for k, v in globals().items() if k.startswith("test_")]
passed = 0
failed = 0
for test in tests:
try:
test()
print(f" PASS: {test.__name__}")
passed += 1
except AssertionError as e:
print(f" FAIL: {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f" ERROR: {test.__name__}: {type(e).__name__}: {e}")
failed += 1
print(f"\n{passed} passed, {failed} failed out of {passed + failed}")
sys.exit(1 if failed else 0)
+1 -1
View File
@@ -7,7 +7,7 @@
"extra_flags": [
"-D CDEBYTE_EORA_S3",
"-D ARDUINO_USB_CDC_ON_BOOT=1",
"-D ARDUINO_USB_MODE=0",
"-D ARDUINO_USB_MODE=1",
"-D ARDUINO_RUNNING_CORE=1",
"-D ARDUINO_EVENT_RUNNING_CORE=1",
"-D BOARD_HAS_PSRAM"
+1 -1
View File
@@ -6,7 +6,7 @@
"core": "esp32",
"extra_flags": [
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1",
"-DBOARD_HAS_PSRAM"
+1 -1
View File
@@ -8,7 +8,7 @@
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
+54
View File
@@ -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_mesh_node_t1",
"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 Mesh Node T1",
"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"
}
+54
View File
@@ -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"],
["0x239A", "0x0071"]
],
"usb_product": "HT-n5262",
"mcu": "nrf52840",
"variant": "heltec_mesh_tower_v2",
"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 MeshTower V2 (Adafruit BSP)",
"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"
}
+1 -1
View File
@@ -9,7 +9,7 @@
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
+1 -1
View File
@@ -9,7 +9,7 @@
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
+1 -1
View File
@@ -9,7 +9,7 @@
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
+1 -1
View File
@@ -8,7 +8,7 @@
"extra_flags": [
"-DHELTEC_WIRELESS_TRACKER",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
+1 -1
View File
@@ -7,7 +7,7 @@
"core": "esp32",
"extra_flags": [
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
+26
View File
@@ -0,0 +1,26 @@
{
"build": {
"cpu": "cortex-m33",
"f_cpu": "128000000L",
"mcu": "nrf54l15",
"zephyr": {
"variant": "nrf54l15dk/nrf54l15/cpuapp"
}
},
"connectivity": ["bluetooth"],
"debug": {
"default_tools": ["jlink"],
"jlink_device": "nRF54L15_M33",
"svd_path": "nrf54l15.svd"
},
"frameworks": ["zephyr"],
"name": "Nordic nRF54L15-DK (PCA10156)",
"upload": {
"maximum_ram_size": 262144,
"maximum_size": 1572864,
"protocol": "jlink",
"protocols": ["jlink"]
},
"url": "https://www.nordicsemi.com/Products/nRF54L15",
"vendor": "Nordic Semiconductor"
}
+1 -1
View File
@@ -8,7 +8,7 @@
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=0"
],
+1 -1
View File
@@ -9,7 +9,7 @@
"-DBOARD_HAS_PSRAM",
"-DLILYGO_TBEAM_1W",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
+1 -1
View File
@@ -8,7 +8,7 @@
"extra_flags": [
"-DBOARD_HAS_PSRAM",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
+53
View File
@@ -0,0 +1,53 @@
{
"build": {
"arduino": {
"ldscript": "nrf52840_s140_v6.ld"
},
"core": "nRF5",
"cpu": "cortex-m4",
"extra_flags": "-DARDUINO_NRF52840_T_IMPULSE_PLUS -DNRF52840_XXAA",
"f_cpu": "64000000L",
"hwids": [["0x239A", "0x8029"]],
"usb_product": "T-Impulse-Plus-nRF52840",
"mcu": "nrf52840",
"variant": "t-impulse-plus",
"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"
},
"frameworks": ["arduino"],
"name": "Lilygo T-Impulse-Plus-nRF52840",
"upload": {
"maximum_ram_size": 248832,
"maximum_size": 815104,
"require_upload_port": true,
"speed": 115200,
"protocol": "nrfutil",
"protocols": [
"jlink",
"nrfjprog",
"nrfutil",
"stlink",
"cmsis-dap",
"blackmagic"
]
},
"url": "https://www.lilygo.cc/",
"vendor": "Lilygo"
}
+1 -1
View File
@@ -8,7 +8,7 @@
"-DBOARD_HAS_PSRAM",
"-DLILYGO_TBEAM_S3_CORE",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
+1 -1
View File
@@ -7,7 +7,7 @@
"extra_flags": [
"-DLILYGO_T3S3_V1",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1",
"-DBOARD_HAS_PSRAM"
+1 -1
View File
@@ -10,7 +10,7 @@
"-DBOARD_HAS_PSRAM",
"-DUNPHONE_SPIN=9",
"-DARDUINO_USB_CDC_ON_BOOT=1",
"-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1"
],
-50
View File
@@ -1,50 +0,0 @@
{
"build": {
"arduino": {
"ldscript": "nrf52832_s132_v6.ld"
},
"core": "nRF5",
"cpu": "cortex-m4",
"extra_flags": "-DNRF52832_XXAA -DNRF52",
"f_cpu": "64000000L",
"hwids": [
["0x239A", "0x8029"],
["0x239A", "0x0029"],
["0x239A", "0x002A"],
["0x239A", "0x802A"]
],
"usb_product": "Feather nRF52832 Express",
"mcu": "nrf52832",
"variant": "WisCore_RAK4600_Board",
"bsp": {
"name": "adafruit"
},
"softdevice": {
"sd_flags": "-DS132",
"sd_name": "s132",
"sd_version": "6.1.1",
"sd_fwid": "0x00B7"
},
"zephyr": {
"variant": "nrf52_adafruit_feather"
}
},
"connectivity": ["bluetooth"],
"debug": {
"jlink_device": "nRF52832_xxAA",
"svd_path": "nrf52.svd",
"openocd_target": "nrf52840-mdk-rs"
},
"frameworks": ["arduino", "zephyr"],
"name": "Adafruit Bluefruit nRF52832 Feather",
"upload": {
"maximum_ram_size": 65536,
"maximum_size": 524288,
"require_upload_port": true,
"speed": 115200,
"protocol": "nrfutil",
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"]
},
"url": "https://www.adafruit.com/product/3406",
"vendor": "Adafruit"
}
+1 -1
View File
@@ -33,5 +33,5 @@ if [[ -n $GPG_KEY_ID ]]; then
debuild -S -nc -k"$GPG_KEY_ID"
else
# Build the source deb without signing (forks)
debuild -S -nc
debuild -S -nc -us -uc
fi
+1
View File
@@ -14,6 +14,7 @@ Build-Depends: debhelper-compat (= 13),
g++,
pkg-config,
libyaml-cpp-dev,
libjsoncpp-dev,
libgpiod-dev,
libbluetooth-dev,
libusb-1.0-0-dev,
+7
View File
@@ -0,0 +1,7 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x640000,
app1, app, ota_1, 0x650000,0x640000,
spiffs, data, spiffs, 0xc90000,0x360000,
coredump, data, coredump,0xFF0000,0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 app0 app ota_0 0x10000 0x640000
5 app1 app ota_1 0x650000 0x640000
6 spiffs data spiffs 0xc90000 0x360000
7 coredump data coredump 0xFF0000 0x10000
+7
View File
@@ -0,0 +1,7 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x330000,
app1, app, ota_1, 0x340000,0x330000,
spiffs, data, spiffs, 0x670000,0x180000,
coredump, data, coredump,0x7F0000,0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 app0 app ota_0 0x10000 0x330000
5 app1 app ota_1 0x340000 0x330000
6 spiffs data spiffs 0x670000 0x180000
7 coredump data coredump 0x7F0000 0x10000
@@ -0,0 +1,277 @@
# 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" 9-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<RegionCode, RegionPresetInfo>`:
```text
struct RegionPresetInfo { Set<ModemPreset> presets; ModemPreset default; bool licensedOnly }
fun decode(map: LoRaRegionPresetMap): Map<RegionCode, RegionPresetInfo> {
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. 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 |
| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE |
| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW |
| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW |
| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW |
| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW |
`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, UA_868, 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 groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups
> because they differ in `licensed_only`. Decoders must key on the group, not on the preset
> list, to preserve 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.
+456
View File
@@ -0,0 +1,456 @@
# 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 50100. 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, M1M4, 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 515), 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 | 12 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)
- `mcp-server/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).
- `mcp-server/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).
- `mcp-server/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 ABC 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:
`mcp-server/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.
+3 -14
View File
@@ -70,17 +70,6 @@ def esp32_create_combined_bin(source, target, env):
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_create_combined_bin)
esp32_kind = env.GetProjectOption("custom_esp32_kind")
if esp32_kind == "esp32":
# Free up some IRAM by removing auxiliary SPI flash chip drivers.
# Wrapped stub symbols are defined in src/platform/esp32/iram-quirk.c.
env.Append(
LINKFLAGS=[
"-Wl,--wrap=esp_flash_chip_gd",
"-Wl,--wrap=esp_flash_chip_issi",
"-Wl,--wrap=esp_flash_chip_winbond",
]
)
else:
# For newer ESP32 targets, using newlib nano works better.
env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"])
# Enable Newlib Nano formatting to save space
# ...but allow printf float support (compromise)
env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"])
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): For SConstruct imports
# force linker response file instead of command line arguments
Import("env")
def wrap_with_tempfile(command_key):
command = env.get(command_key)
if not command or not isinstance(command, str):
return
if "TEMPFILE(" in command:
return
env.Replace(**{command_key: "${TEMPFILE('%s')}" % command})
# Force SCons to spill long commands into response files on this target.
env.Replace(MAXLINELENGTH=8192)
for key in ("LINKCOM", "CXXLINKCOM", "SHLINKCOM", "SHCXXLINKCOM"):
wrap_with_tempfile(key)
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821)
#
# Whole-image LTO for nrf52840 (~-60KB; ~-23KB beyond src-only LTO), EXCEPT the objects
# that own interrupt/exception handlers.
#
# Every ISR is referenced only from the assembly vector table (gcc_startup_nrf52840.S),
# which LTO cannot see -> whole-program LTO judges the handlers dead, removes them, and
# the weak `b .` Default_Handler stubs prevail -> the IRQ lands in an infinite loop and the
# chip hangs (or the peripheral silently stalls). Compiling the handler-bearing objects
# WITHOUT LTO lets ordinary linking keep the strong handlers; everything else stays LTO'd:
# - framework core (/FrameworkArduino/, /cores/nRF5/): every nrfx ISR + the FreeRTOS
# SVC/PendSV port.
# - TinyUSB nrf port (Adafruit_TinyUSB_nrf.cpp): USBD_IRQHandler (USB data path).
# - library .cpp files that own a vector ISR (would otherwise be silently dropped):
# bluefruit.cpp -> SD_EVT/SWI2_EGU2 (SoftDevice BLE-event delivery -- advertising
# hangs without it)
# Wire_nRF52.cpp -> SPIM0/TWIM0 + SPIM1/TWIM1 (interrupt-driven I2C/SPI)
# PDM.cpp -> PDM_IRQHandler (PDM microphone)
# RotaryEncoder.cpp -> QDEC_IRQHandler (hardware quadrature/rotary encoder)
#
# A post-link guard (bottom of this file) fails the build if a critical handler was dropped
# anyway -- so a future deps bump or a new ISR-owning library becomes a red build, not a field
# hang. To hunt a dropped ISR by hand: nm the .elf for `_IRQHandler$` symbols marked `W`, then
# grep the libs/framework for who defines them.
#
# HW-validated: RAK4631 (SX1262) + muzi-base (LR1121).
import glob
import os
Import("env")
env.Append(LINKFLAGS=["-flto", "-flto-partition=1to1"])
# The -fno-lto re-compiles below run with the global env, which lacks the framework's
# bundled-library include dirs -- and those libs cross-include each other (Wire pulls in
# Adafruit_TinyUSB.h, which pulls in SPI.h, ...). Add every bundled-lib dir (+ its src/) so
# the re-compiles resolve without chasing headers one at a time.
_fw = env.PioPlatform().get_package_dir("framework-arduinoadafruitnrf52") or ""
_extra_inc = []
for _d in sorted(glob.glob(os.path.join(_fw, "libraries", "*"))):
if os.path.isdir(_d):
_extra_inc.append(_d)
if os.path.isdir(os.path.join(_d, "src")):
_extra_inc.append(os.path.join(_d, "src"))
FRAMEWORK = ("/FrameworkArduino/", "/cores/nRF5/")
USB_ISR = "Adafruit_TinyUSB_nrf" # USBD_IRQHandler
# Library .cpp files that define vector-table ISRs (the rest of their lib stays LTO'd):
LIB_ISR = ("/bluefruit.cpp", "/Wire_nRF52.cpp", "/PDM.cpp", "/RotaryEncoder.cpp")
def _no_lto(node):
try:
path = node.get_abspath()
except Exception:
path = str(node)
path = path.replace(
"\\", "/"
) # normalize Windows backslashes so matches work cross-platform
if (
USB_ISR in path
or any(s in path for s in FRAMEWORK)
or any(s in path for s in LIB_ISR)
):
return env.Object(
node,
CCFLAGS=env["CCFLAGS"] + ["-fno-lto"],
CPPPATH=env["CPPPATH"] + _extra_inc,
)
return node
env.AddBuildMiddleware(_no_lto)
# --- post-link guard: catch a dropped ISR handler at build time (CI footgun protection) ----
# After every link, fail the build if one of these critical vector-table handlers resolved to
# the weak `b .` Default_Handler stub -- i.e. LTO (or a deps bump, or a new ISR-owning library
# that nobody added to LIB_ISR) silently dropped it. A dropped handler hangs the chip the
# instant that IRQ fires; this turns a field hang into a red build. CI builds every nrf52840
# target, so this runs on every PR automatically. If a board deliberately stops using one of
# these, edit the tuples on purpose.
_REQUIRED_STRONG = (
"SWI2_EGU2_IRQHandler", # SoftDevice BLE event (SD_EVT) -- advertising & connections
"GPIOTE_IRQHandler", # GPIO interrupts: radio DIO + buttons
"RTC1_IRQHandler", # FreeRTOS scheduler tick
)
# Owned by the TinyUSB stack, so only required when the board builds with USB at all.
# Boards without native USB wiring (e.g. wio-sdk-wm1110's CH340 UART) strip TinyUSB via
# disable_adafruit_usb.py / unflagging USE_TINYUSB, leaving these legitimately weak.
_REQUIRED_STRONG_USB = (
"USBD_IRQHandler", # USB CDC (serial console + 1200bps DFU trigger)
"POWER_CLOCK_IRQHandler", # USB power events (VBUS detect/ready) via TinyUSB hal
)
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
if not os.path.isfile(_NM):
_NM = "arm-none-eabi-nm" # fall back to PATH
def _assert_isr_handlers_survived(source, target, env):
import subprocess
import sys
try:
# Resolve the ELF at build time; target[0] is the buildprog alias, not the file.
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
out = subprocess.check_output([_NM, elf], universal_newlines=True)
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
print("nrf52_lto: WARNING - ISR-handler guard skipped (nm failed: %s)" % exc)
return
# nm line: "<addr> <type> <symbol>". type 'T'/'t' = strong (good); 'W'/'w' = weak stub.
kind = {}
for line in out.split("\n"):
f = line.split()
if len(f) >= 3 and f[-1].endswith("_IRQHandler"):
kind[f[-1]] = f[-2]
required = list(_REQUIRED_STRONG)
defines = [
str(d[0] if isinstance(d, tuple) else d) for d in env.get("CPPDEFINES", [])
]
if "USE_TINYUSB" in defines:
required += _REQUIRED_STRONG_USB
dropped = [h for h in required if kind.get(h, "W").upper() != "T"]
if dropped:
sys.stderr.write(
"\n*** nrf52 LTO guard: interrupt handler(s) DROPPED: %s ***\n"
"Each resolved to the weak Default_Handler stub, so the chip hangs when that IRQ\n"
"fires. Compile the .cpp that defines the handler with -fno-lto by adding it to\n"
"LIB_ISR in extra_scripts/nrf52_lto.py. Find the owner of FOO_IRQHandler with:\n"
" grep -rl FOO_IRQHandler <framework-arduinoadafruitnrf52>/{libraries,cores}\n\n"
% ", ".join(dropped)
)
from SCons.Script import Exit
Exit(1) # canonical SCons build-abort -> red build
print(
"nrf52_lto: ISR-handler guard OK -- %d critical handlers strong" % len(required)
)
# Attach to the phony "buildprog" alias, NOT the .elf file node: SCons can skip a post-action
# on a file target during an incremental relink (observed), but the buildprog alias runs every
# build -- so the guard fires on local incremental rebuilds and clean CI builds alike.
env.AddPostAction("buildprog", _assert_isr_handlers_survived)
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): For SConstruct imports
#
# Post-link guard for the warm-node-store raw-flash region on nRF52840.
#
# The 3 app-region pages below LittleFS (0xEA000-0xED000, reclaimed by whole-image
# LTO) are reserved for the WarmNodeStore record-ring (see WarmNodeStore.h). Our
# linker scripts (nrf52840_s140_v6.ld and nrf52840_s140_v7.ld) cap the image at
# 0xEA000, but boards on the framework-default script (FLASH ending at 0xED000) could
# silently place code in those pages — the first warm-store save would then brick the
# device. This turns that into a build failure.
#
# Image flash end = __etext + sizeof(.data) (loaded at LMA __etext); symbols from
# the framework's nrf52_common.ld.
import os
Import("env")
WARM_REGION_BASE = 0xEA000 # keep in sync with WARM_FLASH_REGION_BASE in WarmNodeStore.h (3 x 4 KB record-ring)
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
if not os.path.isfile(_NM):
_NM = "arm-none-eabi-nm" # fall back to PATH
def _assert_warm_region_clear(source, target, env):
import subprocess
import sys
try:
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
out = subprocess.check_output([_NM, elf], universal_newlines=True)
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
print("nrf52_warm_region: WARNING - guard skipped (nm failed: %s)" % exc)
return
syms = {}
for line in out.split("\n"):
f = line.split()
if len(f) >= 3 and f[-1] in ("__etext", "__data_start__", "__data_end__"):
syms[f[-1]] = int(f[0], 16)
if len(syms) != 3:
print("nrf52_warm_region: WARNING - guard skipped (linker symbols not found)")
return
flash_end = syms["__etext"] + (syms["__data_end__"] - syms["__data_start__"])
if flash_end > WARM_REGION_BASE:
sys.stderr.write(
"\n*** nrf52 warm-region guard: image ends at 0x%X, past the reserved "
"warm-store region at 0x%X ***\n"
"The 12 KB region at 0xEA000 holds the WarmNodeStore record-ring; a warm-store\n"
"save would overwrite this firmware's tail. Shrink the image, or shrink/move\n"
"the region (WARM_FLASH_REGION_BASE in src/mesh/WarmNodeStore.h, the FLASH\n"
"LENGTH in src/platform/nrf52/nrf52840_s140_v6.ld and _v7.ld, and this guard).\n\n"
% (flash_end, WARM_REGION_BASE)
)
from SCons.Script import Exit
Exit(1)
print(
"nrf52_warm_region: guard OK -- image ends at 0x%X, %d KB clear of the warm region"
% (flash_end, (WARM_REGION_BASE - flash_end) // 1024)
)
# Attach to the phony "buildprog" alias (not the .elf node) so the guard runs
# on incremental relinks too -- same reasoning as nrf52_lto.py's guard.
env.AddPostAction("buildprog", _assert_warm_region_clear)
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
# trunk-ignore-all(ruff/F821)
# trunk-ignore-all(flake8/F821): For SConstruct imports
#
# post:extra_scripts/nrf54l15_linker.py
#
# Fix for Zephyr two-pass link on nRF54L15:
# platformio-build.py registers env.Depends("$PROG_PATH", final_ld_script) but
# the SCons dependency chain is broken (final_ld_script Command never runs).
# This script adds a PreAction on the final firmware binary that runs the gcc
# preprocessing command directly (extracted from build.ninja) to generate
# zephyr/linker.cmd before the link step.
#
# PlatformIO bundles an old Ninja that can't handle multi-output depslog rules,
# so we parse the COMMAND line from build.ninja and run just the gcc -E part,
# skipping the cmake_transform_depfile step (only needed for Ninja deps tracking).
import os
import re
import subprocess
Import("env")
if env.get("PIOENV") != "nrf54l15dk":
pass # Only for the nrf54l15dk environment
else:
def _extract_gcc_command(ninja_build):
"""Parse build.ninja to find the gcc -E command that generates linker.cmd.
The rule format depends on the host:
Windows (CMake's RunCMake wraps every command):
COMMAND = cmd.exe /C "cd /D DIR && arm-none-eabi-gcc.exe ... -o linker.cmd && cmake.exe -E cmake_transform_depfile ..."
POSIX (Linux/macOS — no wrapper):
COMMAND = cd DIR && arm-none-eabi-gcc ... -o linker.cmd && cmake -E cmake_transform_depfile ...
Returns (gcc_cmd_string, cwd_path) or raises RuntimeError.
"""
in_rule = False
with open(ninja_build, "r", encoding="utf-8", errors="replace") as f:
for line in f:
# Detect start of the linker.cmd custom command rule
if not in_rule:
if "build zephyr/linker.cmd" in line and "CUSTOM_COMMAND" in line:
in_rule = True
continue
stripped = line.strip()
if not stripped.startswith("COMMAND = "):
continue
command_val = stripped[len("COMMAND = ") :]
# On Windows the value is wrapped in `cmd.exe /C "..."` — strip
# the wrapper. On POSIX hosts the inner sequence is the value
# itself (no quoting layer).
m = re.search(r'/C\s+"(.*)"\s*$', command_val)
inner = m.group(1) if m else command_val
parts = inner.split(" && ")
cwd = None
gcc_cmd = None
for part in parts:
part = part.strip()
if part.startswith("cd /D "): # Windows form
cwd = part[len("cd /D ") :]
elif part.startswith("cd "): # POSIX form
cwd = part[len("cd ") :]
elif "arm-none-eabi-gcc" in part:
gcc_cmd = part
if not gcc_cmd:
raise RuntimeError(
"nRF54L15 linker fix: arm-none-eabi-gcc command not found in:\n%s"
% inner[:400]
)
return gcc_cmd, cwd
raise RuntimeError(
"nRF54L15 linker fix: 'build zephyr/linker.cmd' rule not found in build.ninja"
)
def _generate_linker_cmd(target, source, env):
"""Generate zephyr/linker.cmd via direct gcc invocation before the final link."""
build_dir = env.subst("$BUILD_DIR")
zephyr_dir = os.path.join(build_dir, "zephyr")
linker_cmd = os.path.join(zephyr_dir, "linker.cmd")
if os.path.exists(linker_cmd):
return # Already present — nothing to do
ninja_build = os.path.join(build_dir, "build.ninja")
if not os.path.exists(ninja_build):
raise RuntimeError(
"nRF54L15 linker fix: build.ninja not found at %s\n"
"Run a full build first so CMake generates the Ninja files."
% ninja_build
)
gcc_cmd, cwd = _extract_gcc_command(ninja_build)
run_cwd = cwd if cwd else zephyr_dir
print(
"==> nRF54L15: Generating zephyr/linker.cmd (LINKER_ZEPHYR_FINAL) via GCC"
)
# gcc_cmd comes verbatim from our own build.ninja (never user input) and
# contains Windows-style paths with spaces that cannot be safely argv-split
# with shlex, so we run it via the platform shell. nosec/nosemgrep below
# acknowledge this deliberate, scoped use of shell=True.
result = subprocess.run( # nosec B602
gcc_cmd,
shell=True, # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
cwd=run_cwd,
capture_output=True,
text=True,
)
if result.returncode != 0:
print("GCC stdout:", result.stdout[:2000])
print("GCC stderr:", result.stderr[:2000])
raise RuntimeError(
"nRF54L15 linker fix: GCC failed to generate linker.cmd (rc=%d)"
% result.returncode
)
if not os.path.exists(linker_cmd):
raise RuntimeError(
"nRF54L15 linker fix: GCC returned 0 but linker.cmd was not created at %s"
% linker_cmd
)
print("==> linker.cmd generated successfully")
# Use PIOMAINPROG (set by ZephyrBuildProgram) to get the exact SCons node
prog = env.get("PIOMAINPROG")
if prog:
env.AddPreAction(prog, _generate_linker_cmd)
else:
print(
"[nrf54l15_linker] WARNING: PIOMAINPROG not set, falling back to $PROG_PATH"
)
env.AddPreAction(env.subst("$PROG_PATH"), _generate_linker_cmd)
+6 -2
View File
@@ -191,10 +191,12 @@ echo
# PASS/FAIL — every hardware test would SKIP with "role not present". We
# narrow to tests/unit explicitly so the summary reads as "no hardware,
# unit suite only" instead of "big skip count looks suspicious".
# Keep terminal output condensed (`-q -r fE`) so skip-heavy runs do not print
# each skipped test in full; skip counts still appear in pytest's summary.
if [[ -z $DETECTED && $# -eq 0 ]]; then
echo "[pre-flight] no supported devices detected; running unit tier only."
echo
exec "$VENV_PY" -m pytest tests/unit -v --report-log=tests/reportlog.jsonl
exec "$VENV_PY" -m pytest tests/unit -q -r fE --report-log=tests/reportlog.jsonl
fi
# Default pytest args when the user passed none. Power users can invoke
@@ -210,11 +212,13 @@ fi
# skipping half the hardware tests with "not baked with session profile"
# errors. Power users who know their hardware is current and want to shave
# those seconds can pass `--assume-baked` explicitly.
# Defaults also use condensed reporting (`-q -r fE`) to avoid listing every
# skipped test verbatim while still surfacing failures/errors and summary data.
if [[ $# -eq 0 ]]; then
set -- tests/ \
--html=tests/report.html --self-contained-html \
--junitxml=tests/junit.xml \
-v --tb=short
-q -r fE --tb=short
fi
# UI tier requires opencv-python-headless (and ideally easyocr). If it's
+382
View File
@@ -0,0 +1,382 @@
"""Fake NodeDB fixture push — Portduino file copy + hardware XModem upload.
The fixture pipeline is two-stage:
1. `bin/gen-fake-nodedb-seed.py` produces a deterministic JSONL describing N
fake-but-realistic peers. Committed under `test/fixtures/nodedb/`.
2. `bin/seed-json-to-proto.py` compiles JSONL → binary v25 NodeDatabase
protobuf with fresh wall-clock timestamps.
This module exposes `push_fake_nodedb(...)`, the MCP tool that:
- target="portduino": compiles the JSONL into the device's prefs dir on
the local filesystem (`~/.portduino/<config>/prefs/nodes.proto`).
- target="hardware": compiles to a temp file, then streams it over the
XModem protocol (via the meshtastic SerialInterface/BLEInterface +
`meshtastic.xmodempacket` pubsub topic) to `/prefs/nodes.proto` on the
device. Triggers a reboot so the firmware loads the new state on next
boot.
XModem wire details (mirrors firmware impl at src/xmodem.cpp:115-260):
* 128-byte chunks; final chunk padded to 128 B with 0x1A (SUB) bytes.
* CRC16-CCITT (poly 0x1021, init 0x0000).
* SOH/seq=0 carries the destination filename in `buffer.bytes`. ACK if
`FSCom.open(filename, FILE_O_WRITE)` succeeds; NAK otherwise.
* SOH/seq≥1 carries a 128-byte chunk. ACK = advance; NAK = retransmit.
* EOT after the last chunk flushes + closes the file on-device.
Hardware push requires `confirm=True` (mirrors factory_reset / erase_and_flash
in the .github/copilot-instructions.md "never do these without asking" list).
"""
from __future__ import annotations
import dataclasses
import hashlib
import pathlib
import queue
import shutil
import subprocess
import sys
import tempfile
import time
from typing import Any, Literal
from .connection import connect, is_tcp_port
# Resolve repo root so the tool works regardless of mcp-server cwd.
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
_SEED_DIR = _REPO_ROOT / "test" / "fixtures" / "nodedb"
_COMPILE_SCRIPT = _REPO_ROOT / "bin" / "seed-json-to-proto.py"
_DEFAULT_NODES_FILENAME = "/prefs/nodes.proto"
_XMODEM_CHUNK = 128
_XMODEM_SUB = 0x1A
_ACK_TIMEOUT_INIT_S = 5.0
_ACK_TIMEOUT_CHUNK_S = 2.0
_MAX_CHUNK_RETRIES = 5
_VALID_SIZES = (250, 500, 1000, 2000)
class FixtureError(RuntimeError):
"""Raised for any fixture-push failure (compile, transport, ack timeout, …)."""
# ---------------------------------------------------------------------------
# CRC16-CCITT (poly 0x1021, init 0x0000). Matches the firmware's `crc16_ccitt`.
# Hand-rolled to avoid the optional `crcmod` dep.
# ---------------------------------------------------------------------------
def _crc16_ccitt(data: bytes, *, init: int = 0x0000) -> int:
crc = init
for b in data:
crc ^= b << 8
for _ in range(8):
if crc & 0x8000:
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
else:
crc = (crc << 1) & 0xFFFF
return crc
# ---------------------------------------------------------------------------
# Compile step — shells out to bin/seed-json-to-proto.py so the MCP module
# doesn't have to duplicate the proto-encoding logic.
# ---------------------------------------------------------------------------
def _compile_proto(jsonl_path: pathlib.Path, out_path: pathlib.Path) -> None:
if not _COMPILE_SCRIPT.is_file():
raise FixtureError(f"compile script missing at {_COMPILE_SCRIPT}")
cmd = [
sys.executable,
str(_COMPILE_SCRIPT),
"--in",
str(jsonl_path),
"--out",
str(out_path),
]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as exc:
raise FixtureError(
f"seed-json-to-proto.py failed (exit {exc.returncode}):\n"
f" stdout: {exc.stdout}\n stderr: {exc.stderr}"
) from exc
def _resolve_seed_jsonl(size: int, custom: str | None) -> pathlib.Path:
if custom is not None:
p = pathlib.Path(custom).expanduser().resolve()
if not p.is_file():
raise FixtureError(f"custom_seed_jsonl not found: {p}")
return p
p = _SEED_DIR / f"seed_v25_{size:04d}.jsonl"
if not p.is_file():
raise FixtureError(
f"missing committed seed at {p}. "
f"Run `./bin/regen-fake-nodedbs.sh` to generate it."
)
return p
# ---------------------------------------------------------------------------
# Portduino push — file copy into ~/.portduino/<config>/prefs/
# ---------------------------------------------------------------------------
def _portduino_prefs_dir(config_name: str) -> pathlib.Path:
home = pathlib.Path.home()
return home / ".portduino" / config_name / "prefs"
def _push_portduino(
size: int,
jsonl: pathlib.Path,
portduino_config: str,
backup_existing: bool,
) -> dict[str, Any]:
prefs = _portduino_prefs_dir(portduino_config)
prefs.mkdir(parents=True, exist_ok=True)
target = prefs / "nodes.proto"
backed_up_to: str | None = None
if backup_existing and target.is_file():
ts = int(time.time())
backup = prefs / f"nodes.proto.bak.{ts}"
shutil.move(str(target), str(backup))
backed_up_to = str(backup)
_compile_proto(jsonl, target)
raw = target.read_bytes()
return {
"transport": "portduino",
"path": str(target),
"bytes": len(raw),
"sha256": hashlib.sha256(raw).hexdigest(),
"jsonl_source": str(jsonl),
"backed_up_to": backed_up_to,
}
# ---------------------------------------------------------------------------
# Hardware push — XModem over BLE/serial via the meshtastic Python interface.
# ---------------------------------------------------------------------------
@dataclasses.dataclass
class _AckEvent:
control: int
seq: int
def _wait_for_response(q: "queue.Queue[_AckEvent]", timeout_s: float) -> _AckEvent:
try:
return q.get(timeout=timeout_s)
except queue.Empty as exc:
raise FixtureError(
f"XModem response timeout after {timeout_s:.1f}s — device not responding"
) from exc
def _push_hardware(
size: int,
jsonl: pathlib.Path,
port: str | None,
reboot_after: bool,
) -> dict[str, Any]:
# Lazy imports so the module loads even when the meshtastic deps aren't
# available (e.g. CI in a Python env without the package installed).
try:
from meshtastic.protobuf import mesh_pb2, xmodem_pb2
from pubsub import pub
except ImportError as exc: # pragma: no cover — dep missing
raise FixtureError(
f"hardware push requires the meshtastic + pypubsub packages: {exc}"
) from exc
if is_tcp_port(port):
raise FixtureError(
"hardware push over TCP/portduino is not supported — use "
"target='portduino' to drop the fixture directly into the prefs dir."
)
# Compile the fixture to a temp file with fresh timestamps.
with tempfile.NamedTemporaryFile(suffix=".proto", delete=False) as tf:
proto_path = pathlib.Path(tf.name)
try:
_compile_proto(jsonl, proto_path)
payload = proto_path.read_bytes()
finally:
proto_path.unlink(missing_ok=True)
sha256 = hashlib.sha256(payload).hexdigest()
total_bytes = len(payload)
# Subscribe to XModem responses BEFORE we open the interface, so we don't
# race the first ACK that arrives during the SOH/seq=0 handshake.
#
# NB: the signature MUST declare every kwarg pypubsub will see for this
# topic, or pubsub locks the topic spec to a smaller set (whichever
# subscribe arrives first) and then *rejects* the meshtastic library's
# publish call with `SenderUnknownMsgDataError: unknown ... interface`.
# The meshtastic lib publishes both `packet=` and `interface=`
# (mesh_interface.py:1389-1395), so both must appear here.
response_q: "queue.Queue[_AckEvent]" = queue.Queue()
def _on_xmodem(packet: Any = None, interface: Any = None, **_kw: Any) -> None:
if packet is None:
return
response_q.put(_AckEvent(control=int(packet.control), seq=int(packet.seq)))
pub.subscribe(_on_xmodem, "meshtastic.xmodempacket")
chunks_sent = 0
retried = 0
rebooted = False
XMC = xmodem_pb2.XModem.Control
try:
with connect(port=port) as iface:
# 1) Send the filename (SOH, seq=0).
init_pkt = xmodem_pb2.XModem(
control=XMC.Value("SOH"),
seq=0,
buffer=_DEFAULT_NODES_FILENAME.encode("utf-8"),
)
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=init_pkt))
ack = _wait_for_response(response_q, _ACK_TIMEOUT_INIT_S)
if ack.control != XMC.Value("ACK"):
raise FixtureError(
f"device refused filename {_DEFAULT_NODES_FILENAME!r} "
f"(got control={ack.control}, expected ACK). "
f"Filesystem full or permissions issue?"
)
# 2) Stream the payload in 128 B chunks.
for offset in range(0, total_bytes, _XMODEM_CHUNK):
chunk = payload[offset : offset + _XMODEM_CHUNK]
if len(chunk) < _XMODEM_CHUNK:
# Pad final chunk to 128 B with SUB. The trailing 0x1A bytes
# become part of the file on-device, but nanopb ignores
# bytes past the end of the top-level message.
chunk = chunk + bytes([_XMODEM_SUB] * (_XMODEM_CHUNK - len(chunk)))
seq = ((offset // _XMODEM_CHUNK) + 1) % 256
# Retry loop on NAK / timeout.
attempts = 0
while True:
pkt = xmodem_pb2.XModem(
control=XMC.Value("SOH"),
seq=seq,
buffer=chunk,
crc16=_crc16_ccitt(chunk),
)
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=pkt))
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
if ack.control == XMC.Value("ACK"):
chunks_sent += 1
break
if ack.control == XMC.Value("NAK"):
attempts += 1
retried += 1
if attempts >= _MAX_CHUNK_RETRIES:
# Abort: send CAN so the firmware removes the half-
# written file via FSCom.remove(filename).
iface._sendToRadio(
mesh_pb2.ToRadio(
xmodemPacket=xmodem_pb2.XModem(
control=XMC.Value("CAN")
)
)
)
raise FixtureError(
f"chunk seq={seq} NAK'd {attempts} times; "
f"aborted transfer (file removed on-device)."
)
continue # retry the same chunk
raise FixtureError(
f"unexpected XModem control={ack.control} on seq={seq}"
)
# 3) Tell the device we're done.
iface._sendToRadio(
mesh_pb2.ToRadio(
xmodemPacket=xmodem_pb2.XModem(control=XMC.Value("EOT"))
)
)
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
if ack.control != XMC.Value("ACK"):
raise FixtureError(f"EOT not ACKed (got control={ack.control})")
# 4) Reboot so loadFromDisk picks up the new file.
if reboot_after:
iface.localNode.reboot(secs=1)
rebooted = True
finally:
try:
pub.unsubscribe(_on_xmodem, "meshtastic.xmodempacket")
except Exception:
pass
return {
"transport": "hardware",
"port": port,
"filename_on_device": _DEFAULT_NODES_FILENAME,
"bytes": total_bytes,
"chunks_sent": chunks_sent,
"retried": retried,
"sha256": sha256,
"jsonl_source": str(jsonl),
"rebooted": rebooted,
}
# ---------------------------------------------------------------------------
# Public entry point — registered as an MCP tool in server.py.
# ---------------------------------------------------------------------------
def push_fake_nodedb(
size: int,
target: Literal["portduino", "hardware"] = "portduino",
*,
port: str | None = None,
portduino_config: str = "default",
backup_existing: bool = True,
confirm: bool = False,
reboot_after: bool = True,
custom_seed_jsonl: str | None = None,
) -> dict[str, Any]:
"""Compile a fresh-timestamp NodeDatabase fixture and push it to a device.
Args:
size: 250, 500, 1000, or 2000 — selects which committed seed JSONL to use.
target: "portduino" (file copy to ~/.portduino/<config>/prefs/) or
"hardware" (XModem upload to /prefs/nodes.proto + reboot).
port: required for target="hardware". Serial path (e.g. /dev/cu.usbmodemXXXX)
or BLE identifier. TCP endpoints are rejected — use target="portduino"
instead.
portduino_config: which Portduino instance dir under ~/.portduino/. Default "default".
backup_existing: portduino only. Move nodes.proto -> nodes.proto.bak.<ts>
if present, so you can roll back.
confirm: required True for target="hardware" (writes flash + reboots).
reboot_after: hardware only. If True, send a 1-second reboot after the
final ACK so loadFromDisk picks up the new file at next boot.
custom_seed_jsonl: override the committed JSONL. Use to push a hand-edited
test scenario.
Returns:
dict with transport, bytes, sha256, etc. — depends on target.
"""
if size not in _VALID_SIZES:
raise FixtureError(
f"size must be one of {_VALID_SIZES}; got {size!r}. "
f"Add a new committed seed if you need a different cardinality."
)
jsonl = _resolve_seed_jsonl(size, custom_seed_jsonl)
if target == "portduino":
return _push_portduino(size, jsonl, portduino_config, backup_existing)
if target == "hardware":
if not confirm:
raise FixtureError(
"hardware push writes flash and triggers a reboot — pass confirm=True."
)
if not port:
raise FixtureError(
"target='hardware' requires a port (e.g. /dev/cu.usbmodemXXXX)."
)
return _push_hardware(size, jsonl, port, reboot_after)
raise FixtureError(f"unknown target {target!r}; expected 'portduino' or 'hardware'")
+43
View File
@@ -15,6 +15,7 @@ from . import (
admin,
boards,
devices,
fixtures,
flash,
hw_tools,
info,
@@ -963,3 +964,45 @@ def recorder_export(
dest_dir=dest_dir,
streams=streams,
)
# ---------- Fixture / test-data push --------------------------------------
@app.tool()
def push_fake_nodedb(
size: int,
target: str = "portduino",
port: str | None = None,
portduino_config: str = "default",
backup_existing: bool = True,
confirm: bool = False,
reboot_after: bool = True,
custom_seed_jsonl: str | None = None,
) -> dict[str, Any]:
"""Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device.
Two transports:
target="portduino" — file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
Fast, no device connection needed.
target="hardware" — XModem upload over serial/BLE to /prefs/nodes.proto.
Requires `port` + `confirm=True`. Triggers a reboot
so loadFromDisk picks up the new file at next boot.
Compiles a fresh-timestamp proto from the committed JSONL seed under
test/fixtures/nodedb/seed_v25_<N>.jsonl each invocation, so the loaded
NodeDB always looks "recent" to the connecting phone. Structural data
(names, IDs, positions, telemetries) is deterministic per the seed.
Override the JSONL via `custom_seed_jsonl` to push a hand-edited scenario.
"""
return fixtures.push_fake_nodedb(
size=size,
target=target, # type: ignore[arg-type]
port=port,
portduino_config=portduino_config,
backup_existing=backup_existing,
confirm=confirm,
reboot_after=reboot_after,
custom_seed_jsonl=custom_seed_jsonl,
)
@@ -0,0 +1,347 @@
"""Multi-hop NextHop directed-message delivery + relay-recovery (bench test).
This is the hardware/tier-3 validator for the NextHop DM reliability work
(see `docs/nexthop-routing-reliability.md`). The unit suite
`test/test_nexthop_routing` covers the routing *logic* exhaustively; this test
covers the *end-to-end* multi-hop behavior that only a real (or RF-separated)
mesh exercises:
* a directed DM that must traverse a relay is delivered (next_hop routing +
the M1/M2 ambiguity gate + M3 route learning all engage), and
* when the established relay drops and returns, delivery recovers rather than
black-holing (the M3 stale-route decay / re-learn path).
TOPOLOGY REQUIREMENT — why this usually SKIPS:
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
Three co-located radios all hear each other, so A→C is a single direct hop and
next_hop never engages. To run this test the bench must be a *line* — A — B — C
— with the endpoints out of each other's direct RF range (physical distance or
attenuators). The `multihop_topology` fixture detects this automatically: it
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
file is safe to commit and run anywhere — it only *asserts* when the topology
genuinely requires a relay.
REQUIREMENTS:
* ≥3 baked devices. The default hub profile is 2 roles (nrf52, esp32s3); add a
third via `--hub-profile=path/to/hub.yaml` (see conftest `hub_profile`).
* The relay-recovery test additionally needs uhubctl + a power-controllable
relay port (same gate the other power tests use).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
from tests import _power
from tests._port_discovery import resolve_port_by_role
from ._receive import ReceiveCollector, nudge_nodeinfo, nudge_nodeinfo_port
def _hops_away(rec: dict[str, Any]) -> int | None:
"""Read a node's hop distance from a `nodesByNum` entry, tolerating either
the camelCase (`hopsAway`) or snake_case (`hops_away`) spelling depending on
the meshtastic-python version."""
for key in ("hopsAway", "hops_away"):
val = rec.get(key)
if isinstance(val, int):
return val
return None
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
"""Flood a fresh NodeInfo from every node so the whole mesh (including
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
distances. Best-effort — a single node failing to nudge shouldn't abort."""
for _ in range(rounds):
for port in ports:
try:
nudge_nodeinfo_port(port)
except Exception: # noqa: BLE001 — warmup is best-effort
pass
time.sleep(0.5)
time.sleep(settle)
def _wait_for_pubkey(
tx_iface: Any, rx_num: int, rx_port: str, deadline_s: float = 90.0
) -> bool:
"""Block until `tx_iface` holds `rx_num`'s public key (directed PKI sends
NAK without it). Re-nudges both sides periodically; multi-hop warmup is
slower than the 2-device case because NodeInfo must be relayed, hence the
longer default deadline."""
deadline = time.monotonic() + deadline_s
last_nudge = time.monotonic()
while time.monotonic() < deadline:
rec = (tx_iface.nodesByNum or {}).get(rx_num, {})
if rec.get("user", {}).get("publicKey"):
return True
if time.monotonic() - last_nudge > 20.0:
nudge_nodeinfo_port(rx_port)
nudge_nodeinfo(tx_iface)
last_nudge = time.monotonic()
time.sleep(1.0)
return False
def _traceroute_route(tx_port: str, rx_num: int, rx_port: str) -> list[int] | None:
"""Run a traceroute TX→RX and return the forward `route` (list of relay node
numbers), or None if it couldn't be obtained. Mirrors test_traceroute's
request/PKI/retry pattern."""
from meshtastic.mesh_interface import MeshInterface
with ReceiveCollector(tx_port, topic="meshtastic.receive.traceroute") as tx:
nudge_nodeinfo_port(rx_port)
tx.broadcast_nodeinfo_ping()
if not _wait_for_pubkey(tx._iface, rx_num, rx_port, 60.0):
return None
for _attempt in range(2):
try:
tx._iface.sendTraceRoute(dest=rx_num, hopLimit=5)
break
except MeshInterface.MeshInterfaceError:
time.sleep(5.0)
else:
return None
pkt = tx.wait_for(lambda p: p.get("from") == rx_num, timeout=8.0)
if pkt is None:
return None
tr = (pkt.get("decoded", {}) or {}).get("traceroute") or {}
return [int(n) for n in (tr.get("route") or [])]
@pytest.fixture(scope="session")
def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
"""Discover a real multi-hop pier (tx → relay → rx) on the bench, or skip.
Returns {tx_role, tx_port, rx_role, rx_port, rx_num, relay_role, relay_num}.
"""
roles = sorted(baked_mesh)
if len(roles) < 3:
pytest.skip(
"multi-hop NextHop test needs ≥3 baked devices arranged as a line "
"(endpoints out of direct RF range). Add a third role via "
f"--hub-profile. Detected roles: {roles}"
)
by_role = {r: (baked_mesh[r]["port"], baked_mesh[r]["my_node_num"]) for r in roles}
if any(num is None for _, num in by_role.values()):
pytest.skip("a baked device is missing my_node_num; can't map the topology")
_warm_mesh([port for port, _ in by_role.values()])
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
# (cheap — no traceroute yet). On an all-direct bench nothing qualifies.
multihop_pair: tuple[str, str] | None = None
for a_role in roles:
a_port, _ = by_role[a_role]
try:
with connect(port=a_port) as a_iface:
nodes = a_iface.nodesByNum or {}
except Exception: # noqa: BLE001
continue
for c_role in roles:
if c_role == a_role:
continue
_, c_num = by_role[c_role]
hops = _hops_away(nodes.get(c_num, {}))
if hops is not None and hops >= 1:
multihop_pair = (a_role, c_role)
break
if multihop_pair:
break
if not multihop_pair:
pytest.skip(
"no multi-hop pair found — every device appears to be a direct "
"neighbor. Arrange the bench as a line (A — B — C) with the "
"endpoints out of direct RF range (distance or attenuators) so a "
"relay is actually required, then re-run."
)
a_role, c_role = multihop_pair
a_port, _ = by_role[a_role]
c_port, c_num = by_role[c_role]
route = _traceroute_route(a_port, c_num, c_port)
if not route:
pytest.skip(
f"{a_role}{c_role} looked multi-hop but traceroute returned no "
"intermediate relay; can't identify the relay node to drive the "
"recovery test"
)
relay_num = route[0]
relay_role = next((r for r in roles if by_role[r][1] == relay_num), None)
return {
"tx_role": a_role,
"tx_port": a_port,
"rx_role": c_role,
"rx_port": c_port,
"rx_num": c_num,
"relay_role": relay_role,
"relay_num": relay_num,
}
@pytest.mark.timeout(300)
def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
"""A directed wantAck DM that must traverse the relay is delivered.
Exercises the NextHop routing path end-to-end: TX picks a next hop toward
RX (M2 gate), the relay resolves the next_hop byte and forwards (M1), and
the route is learned from the returning ACK (M3). Retries absorb transient
LoRa loss; the assertion is on eventual delivery.
"""
tx_port = multihop_topology["tx_port"]
rx_port = multihop_topology["rx_port"]
rx_num = multihop_topology["rx_num"]
tx_role = multihop_topology["tx_role"]
rx_role = multihop_topology["rx_role"]
relay_role = multihop_topology["relay_role"]
unique = f"nexthop-mh-{tx_role}-to-{rx_role}-{int(time.time())}"
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
pytest.skip(
f"{tx_role} never learned {rx_role}'s pubkey over the relay; "
"multi-hop PKI warmup didn't complete"
)
got = None
for _attempt in range(3):
pkt = tx_iface.sendText(unique, destinationId=rx_num, wantAck=True)
assert pkt is not None
got = rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == unique,
timeout=45,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(5.0)
assert got is not None, (
f"multi-hop directed DM {tx_role}{rx_role} via relay "
f"{relay_role!r} never landed — NextHop multi-hop delivery is broken"
)
@pytest.mark.timeout(600)
def test_multihop_relay_recovery(
multihop_topology: dict[str, Any],
power_cycle, # noqa: ARG001 — forces the uhubctl-availability skip
) -> None:
"""Delivery recovers after the established relay drops and returns.
Establishes a baseline DM (route via relay learned), powers the relay OFF
(confirming TX survives sending across a downed relay), then powers it back
ON and asserts directed delivery resumes — the M3 stale-route decay /
re-learn path. With a strict A — B — C line there is no path while B is down,
so we only assert TX doesn't crash during the outage; the delivery assertion
is after B returns.
"""
relay_role = multihop_topology["relay_role"]
if not relay_role:
pytest.skip(
"relay node isn't one of the baked hub roles, so it can't be "
"power-cycled; recovery test needs a controllable relay"
)
tx_port = multihop_topology["tx_port"]
rx_port = multihop_topology["rx_port"]
rx_num = multihop_topology["rx_num"]
tx_role = multihop_topology["tx_role"]
rx_role = multihop_topology["rx_role"]
base = f"mh-recover-base-{int(time.time())}"
post = f"mh-recover-post-{int(time.time())}"
# Baseline: confirm delivery works (so the route via the relay is learned)
# before we perturb anything — otherwise a later failure is ambiguous.
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
pytest.skip("multi-hop PKI warmup failed; can't run recovery test")
tx_iface.sendText(base, destinationId=rx_num, wantAck=True)
assert (
rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
)
is not None
), "baseline multi-hop delivery failed — skipping recovery to avoid a false result"
# Power the relay OFF.
try:
_power.power_off(relay_role)
_power.wait_for_absence(relay_role, timeout_s=15.0)
except Exception as exc: # noqa: BLE001
try:
_power.power_on(relay_role)
resolve_port_by_role(relay_role, timeout_s=30.0)
except Exception: # noqa: BLE001
pass
pytest.skip(f"can't power-control relay {relay_role!r}: {exc}")
# With the only relay down there's no path; we just confirm TX accepts the
# send and survives its internal retries (it must not crash / wedge).
try:
with connect(port=tx_port) as tx_iface:
pkt = tx_iface.sendText(
f"mh-while-down-{int(time.time())}",
destinationId=rx_num,
wantAck=True,
)
assert pkt is not None
time.sleep(8.0) # let retransmissions + route decay run
except Exception as exc: # noqa: BLE001 — restore bench state before failing
_power.power_on(relay_role)
resolve_port_by_role(relay_role, timeout_s=30.0)
raise AssertionError(
f"TX crashed sending across a downed relay: {exc}"
) from exc
# Power the relay back ON and let it re-enumerate + boot.
_power.power_on(relay_role)
time.sleep(0.5)
try:
resolve_port_by_role(relay_role, timeout_s=30.0)
except Exception: # noqa: BLE001 — relay port isn't one we connect to directly
pass
time.sleep(8.0)
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
# Delivery should resume once the relay is back (M3 re-learn / decay path).
got = None
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
_wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0)
for _attempt in range(4):
pkt = tx_iface.sendText(post, destinationId=rx_num, wantAck=True)
assert pkt is not None
got = rx.wait_for(
lambda p: p.get("decoded", {}).get("text") == post,
timeout=45,
)
if got is not None:
break
rx.broadcast_nodeinfo_ping()
nudge_nodeinfo(tx_iface)
time.sleep(6.0)
assert got is not None, (
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}{rx_role} "
"never resumed — stale-route recovery (M3) may be broken"
)
+1 -1
View File
@@ -44,7 +44,7 @@ _ESP32_ARCHES = {
"esp32-c6",
"esp32c6",
}
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
_NRF52_ARCHES = {"nrf52", "nrf52840"}
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
@@ -0,0 +1,364 @@
"""Tests for the fake-NodeDB fixture pipeline (bin/gen-fake-nodedb-seed.py
+ bin/seed-json-to-proto.py + mcp-server fixtures.push_fake_nodedb).
Lives under tests/unit/ because none of these touch real hardware — they
shell out to the bin/ scripts and decode the resulting protobufs in-process.
"""
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
import time
import pytest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
SEED_GEN = REPO_ROOT / "bin" / "gen-fake-nodedb-seed.py"
COMPILE = REPO_ROOT / "bin" / "seed-json-to-proto.py"
FIXTURES_DIR = REPO_ROOT / "test" / "fixtures" / "nodedb"
# Ensure the locally-generated Python protobuf bindings are importable.
# These live under `meshtastic_v25` (not `meshtastic`) so they don't shadow
# the PyPI `meshtastic` package that the rest of the mcp-server depends on.
_BINDINGS_DIR = REPO_ROOT / "bin" / "_generated"
if _BINDINGS_DIR.is_dir() and str(_BINDINGS_DIR) not in sys.path:
sys.path.insert(0, str(_BINDINGS_DIR))
try:
from meshtastic_v25.deviceonly_pb2 import (
NodeDatabase, # type: ignore[import-not-found]
)
except ImportError:
NodeDatabase = None # type: ignore[assignment]
def _require_v25_bindings() -> None:
if NodeDatabase is None:
pytest.skip(
"v25 Python protobuf bindings missing; run `./bin/regen-py-protos.sh`."
)
if "positions" not in NodeDatabase.DESCRIPTOR.fields_by_name:
pytest.skip(
"Loaded NodeDatabase predates v25 — run `./bin/regen-py-protos.sh`."
)
def _run(cmd: list[str]) -> None:
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
# ---------------------------------------------------------------------------
# Seed generator: deterministic for given --seed (no wall-clock dependence).
# ---------------------------------------------------------------------------
def test_seed_generator_is_deterministic(tmp_path: pathlib.Path) -> None:
a = tmp_path / "a.jsonl"
b = tmp_path / "b.jsonl"
_run(
[
sys.executable,
str(SEED_GEN),
"--count",
"100",
"--seed",
"42",
"--out",
str(a),
]
)
# Sleep so any sneaky wall-clock leak in the generator would surface as
# a byte diff between the two runs.
time.sleep(0.8)
_run(
[
sys.executable,
str(SEED_GEN),
"--count",
"100",
"--seed",
"42",
"--out",
str(b),
]
)
assert a.read_bytes() == b.read_bytes()
def test_seed_generator_meta_line(tmp_path: pathlib.Path) -> None:
out = tmp_path / "seed.jsonl"
_run(
[
sys.executable,
str(SEED_GEN),
"--count",
"50",
"--seed",
"1",
"--out",
str(out),
]
)
lines = out.read_text(encoding="utf-8").splitlines()
assert len(lines) == 51 # 1 meta + 50 nodes
meta = json.loads(lines[0])
assert "_meta" in meta
assert meta["_meta"]["version"] == 25
assert meta["_meta"]["count"] == 50
assert meta["_meta"]["seed"] == 1
def test_seed_only_uses_active_hardware_and_roles(tmp_path: pathlib.Path) -> None:
"""Confirm no deprecated roles + no off-list HW models leak through."""
out = tmp_path / "seed.jsonl"
_run(
[
sys.executable,
str(SEED_GEN),
"--count",
"500",
"--seed",
"7",
"--out",
str(out),
]
)
forbidden_roles = {"ROUTER_CLIENT", "REPEATER"}
forbidden_hw = {
"TLORA_V1",
"TLORA_V2",
"TLORA_V1_1P3",
"TLORA_V2_1_1P6",
"TLORA_V2_1_1P8",
"HELTEC_V1",
"HELTEC_V2_0",
"HELTEC_V2_1",
"TBEAM",
"TBEAM_V0P7",
"NANO_G1",
"NANO_G1_EXPLORER",
"NANO_G2_ULTRA",
"STATION_G1",
"STATION_G2",
"PORTDUINO",
"ANDROID_SIM",
"DIY_V1",
"LORA_RELAY_V1",
"NRF52840_PCA10059",
"NRF52_UNKNOWN",
"DR_DEV",
"GENIEBLOCKS",
"M5STACK",
"RP2040_LORA",
"PPR",
}
for raw in out.read_text(encoding="utf-8").splitlines()[1:]:
node = json.loads(raw)
assert node["role"] not in forbidden_roles, f"deprecated role: {node['role']}"
assert (
node["hw_model"] not in forbidden_hw
), f"non-tier-1 HW: {node['hw_model']}"
# ---------------------------------------------------------------------------
# Compile step + committed seeds.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("size", [250, 500, 1000, 2000])
def test_committed_seed_compiles_and_decodes(size: int, tmp_path: pathlib.Path) -> None:
_require_v25_bindings()
proto = tmp_path / "out.proto"
jsonl = FIXTURES_DIR / f"seed_v25_{size:04d}.jsonl"
if not jsonl.is_file():
pytest.skip(f"{jsonl} not present — run ./bin/regen-fake-nodedbs.sh")
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(proto)])
db = NodeDatabase()
db.ParseFromString(proto.read_bytes())
assert db.version == 25
assert len(db.nodes) == size
nums = {n.num for n in db.nodes}
assert len(nums) == size, "node numbers must be unique"
assert all(n.long_name and n.short_name for n in db.nodes)
assert all(len(n.long_name) <= 24 for n in db.nodes) # max_size:25 - NUL
# Coverage sanity (±10pp tolerance for binomial fluctuation).
def in_range(actual: int, expected_ratio: float, tol_pp: float = 0.10) -> bool:
lo = max(0, int((expected_ratio - tol_pp) * size))
hi = min(size, int((expected_ratio + tol_pp) * size))
return lo <= actual <= hi
assert in_range(len(db.positions), 0.85)
assert in_range(len(db.telemetry), 0.70)
assert in_range(len(db.environment), 0.25)
assert in_range(len(db.status), 0.40)
def test_compile_freshens_timestamps(tmp_path: pathlib.Path) -> None:
"""Same JSONL compiled twice → identical structure, different timestamps."""
_require_v25_bindings()
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
if not jsonl.is_file():
pytest.skip("250-node seed not present — run ./bin/regen-fake-nodedbs.sh")
a = tmp_path / "a.proto"
b = tmp_path / "b.proto"
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(a)])
time.sleep(1.2)
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(b)])
da = NodeDatabase()
db_ = NodeDatabase()
da.ParseFromString(a.read_bytes())
db_.ParseFromString(b.read_bytes())
# Zero out timestamp fields and confirm everything else is byte-identical.
for d in (da, db_):
for n in d.nodes:
n.last_heard = 0
for p in d.positions:
p.position.time = 0
assert da.SerializeToString() == db_.SerializeToString()
# Re-load fresh copies to confirm timestamps actually moved.
aa = NodeDatabase()
bb = NodeDatabase()
aa.ParseFromString(a.read_bytes())
bb.ParseFromString(b.read_bytes())
aa_max = max(n.last_heard for n in aa.nodes if n.last_heard)
bb_max = max(n.last_heard for n in bb.nodes if n.last_heard)
assert bb_max >= aa_max
assert bb_max - aa_max < 5 # within a few seconds
def test_compile_pinned_now_epoch_is_byte_identical(tmp_path: pathlib.Path) -> None:
"""With --now-epoch pinned, two compiles produce identical bytes."""
_require_v25_bindings()
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
if not jsonl.is_file():
pytest.skip("250-node seed not present")
a = tmp_path / "a.proto"
b = tmp_path / "b.proto"
for o in (a, b):
_run(
[
sys.executable,
str(COMPILE),
"--in",
str(jsonl),
"--now-epoch",
"1700000000",
"--out",
str(o),
]
)
assert a.read_bytes() == b.read_bytes()
def test_compile_timestamps_are_recent(tmp_path: pathlib.Path) -> None:
_require_v25_bindings()
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
if not jsonl.is_file():
pytest.skip("250-node seed not present")
out = tmp_path / "out.proto"
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(out)])
db = NodeDatabase()
db.ParseFromString(out.read_bytes())
now = int(time.time())
# No timestamp older than 7 days, none in the future.
for n in db.nodes:
if n.last_heard:
assert now - 7 * 86400 <= n.last_heard <= now
# At least half should be within the last hour
# (matches expovariate(mean=3600s)).
recent = sum(1 for n in db.nodes if n.last_heard and n.last_heard >= now - 3600)
assert recent >= 0.4 * len(db.nodes)
def test_compile_hand_edit_round_trip(tmp_path: pathlib.Path) -> None:
"""Edit one JSONL line, recompile, confirm edit appears in the proto."""
_require_v25_bindings()
src = FIXTURES_DIR / "seed_v25_0250.jsonl"
if not src.is_file():
pytest.skip("250-node seed not present")
dst = tmp_path / "edited.jsonl"
lines = src.read_text(encoding="utf-8").splitlines()
# Find a node that already has telemetry so the index relationship is
# easy to assert on the other side.
edit_idx = None
for i, raw in enumerate(lines[1:], start=1):
node = json.loads(raw)
if node.get("telemetry") is not None:
edit_idx = i
break
assert edit_idx is not None, "expected at least one node with telemetry"
node = json.loads(lines[edit_idx])
target_num = int(node["num"], 16)
node["long_name"] = "Hand Edited Node"
node["telemetry"] = {
"battery_level": 42,
"voltage": 3.71,
"channel_utilization": 0.0,
"air_util_tx": 0.0,
"uptime_seconds": 1,
}
lines[edit_idx] = json.dumps(node, ensure_ascii=False, sort_keys=True)
dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
out = tmp_path / "out.proto"
_run([sys.executable, str(COMPILE), "--in", str(dst), "--out", str(out)])
db = NodeDatabase()
db.ParseFromString(out.read_bytes())
edited = next((n for n in db.nodes if n.num == target_num), None)
assert edited is not None
assert edited.long_name == "Hand Edited Node"
tel = next((t for t in db.telemetry if t.num == target_num), None)
assert tel is not None
assert tel.device_metrics.battery_level == 42
# ---------------------------------------------------------------------------
# Misc smoke checks on the module surface.
# ---------------------------------------------------------------------------
def test_crc16_ccitt_matches_known_vectors() -> None:
"""Sanity-check the hand-rolled CRC16-CCITT matches well-known vectors.
Test vectors from the XModem-CRC spec (init=0, poly=0x1021):
crc16("123456789") = 0x31C3
crc16("") = 0x0000
"""
from meshtastic_mcp.fixtures import _crc16_ccitt
assert _crc16_ccitt(b"") == 0x0000
assert _crc16_ccitt(b"123456789") == 0x31C3
def test_push_fake_nodedb_rejects_invalid_size() -> None:
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
with pytest.raises(FixtureError, match="size must be one of"):
push_fake_nodedb(size=999, target="portduino") # type: ignore[arg-type]
def test_push_fake_nodedb_hardware_requires_confirm() -> None:
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
with pytest.raises(FixtureError, match="confirm=True"):
push_fake_nodedb(size=250, target="hardware", port="/dev/cu.fake")
def test_push_fake_nodedb_hardware_requires_port() -> None:
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
with pytest.raises(FixtureError, match="requires a port"):
push_fake_nodedb(size=250, target="hardware", confirm=True)
def test_push_fake_nodedb_hardware_rejects_tcp_port() -> None:
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
with pytest.raises(FixtureError, match="not supported"):
push_fake_nodedb(
size=250, target="hardware", confirm=True, port="tcp://localhost:4403"
)
+1
View File
@@ -34,6 +34,7 @@ BuildRequires: python3dist(grpcio-tools)
BuildRequires: git-core
BuildRequires: gcc-c++
BuildRequires: pkgconfig(yaml-cpp)
BuildRequires: pkgconfig(jsoncpp)
BuildRequires: pkgconfig(libgpiod)
BuildRequires: pkgconfig(bluez)
BuildRequires: pkgconfig(libusb-1.0)
+33 -31
View File
@@ -2,7 +2,7 @@
; https://docs.platformio.org/page/projectconf.html
[platformio]
default_envs = tbeam
default_envs = heltec-v3
extra_configs =
variants/*/*.ini
@@ -13,10 +13,11 @@ extra_configs =
description = Meshtastic
[env]
test_build_src = true
; test_build_src = true ; disabled for faster incremental builds
extra_scripts =
pre:bin/platformio-pre.py
bin/platformio-custom.py
post:extra_scripts/nrf54l15_linker.py
; note: we add src to our include search path so that lmic_project_config can override
; note: TINYGPS_OPTION_NO_CUSTOM_FIELDS is VERY important. We don't use custom fields and somewhere in that pile
; of code is a heap corruption bug!
@@ -49,6 +50,7 @@ build_flags = -Wno-missing-field-initializers
-DRADIOLIB_EXCLUDE_PAGER=1
-DRADIOLIB_EXCLUDE_FSK4=1
-DRADIOLIB_EXCLUDE_APRS=1
-DRADIOLIB_EXCLUDE_ADSB=1
-DRADIOLIB_EXCLUDE_LORAWAN=1
-DMESHTASTIC_EXCLUDE_DROPZONE=1
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
@@ -57,10 +59,10 @@ build_flags = -Wno-missing-field-initializers
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
-DMESHTASTIC_EXCLUDE_POWERMON=1
-DMESHTASTIC_EXCLUDE_STATUS=1
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
#-D OLED_PL=1
#-D OLED_ZH=1 ; uncomment for compile-time Chinese font (runtime switching works without this on TFT)
#-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs
#-D DEBUG_LOOP_TIMING=1 ; uncomment to add main loop timing logs
@@ -102,7 +104,7 @@ build_unflags =
-std=gnu++11
build_flags = ${env.build_flags} -Os
-std=gnu++17
build_src_filter = ${env.build_src_filter} -<platform/portduino/> -<graphics/niche/>
build_src_filter = ${env.build_src_filter} -<platform/> +<platform/extra_variants/> -<graphics/niche/>
; Common libs for communicating over TCP/IP networks such as MQTT
[networking_base]
@@ -121,7 +123,7 @@ lib_deps =
[radiolib_base]
lib_deps =
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
[device-ui_base]
lib_deps =
@@ -171,8 +173,8 @@ lib_deps =
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
# renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow
https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
@@ -185,40 +187,20 @@ lib_deps =
https://github.com/sparkfun/SparkFun_MAX3010x_Sensor_Library/archive/refs/tags/v1.1.2.zip
# renovate: datasource=github-tags depName=SparkFun 9DoF IMU Breakout ICM 20948 packageName=sparkfun/SparkFun_ICM-20948_ArduinoLibrary
https://github.com/sparkfun/SparkFun_ICM-20948_ArduinoLibrary/archive/refs/tags/v1.3.2.zip
# renovate: datasource=github-tags depName=TDK InvenSense ICM42670P packageName=tdk-invn-oss/motion.arduino.ICM42670P
https://github.com/tdk-invn-oss/motion.arduino.ICM42670P/archive/refs/tags/1.0.8.zip
# renovate: datasource=github-tags depName=Adafruit LTR390 Library packageName=adafruit/Adafruit_LTR390
https://github.com/adafruit/Adafruit_LTR390/archive/refs/tags/1.1.2.zip
# renovate: datasource=github-tags depName=Adafruit PCT2075 packageName=adafruit/Adafruit_PCT2075
https://github.com/adafruit/Adafruit_PCT2075/archive/refs/tags/1.0.6.zip
# renovate: datasource=github-tags depName=DFRobot_BMM150 packageName=dfrobot/DFRobot_BMM150
https://github.com/DFRobot/DFRobot_BMM150/archive/refs/tags/V1.0.0.zip
# renovate: datasource=github-tags depName=SparkFun MMC5983MA Magnetometer packageName=sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library
https://github.com/sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library/archive/refs/tags/v1.1.4.zip
# renovate: datasource=github-tags depName=Adafruit_TSL2561 packageName=adafruit/Adafruit_TSL2561
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
; Common environmental sensor libraries (not included in native / portduino)
[environmental_extra_common]
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
# renovate: datasource=github-tags depName=Adafruit MAX1704X packageName=adafruit/Adafruit_MAX1704X
https://github.com/adafruit/Adafruit_MAX1704X/archive/refs/tags/1.0.3.zip
# renovate: datasource=github-tags depName=Adafruit SHTC3 packageName=adafruit/Adafruit_SHTC3
https://github.com/adafruit/Adafruit_SHTC3/archive/refs/tags/1.0.2.zip
# renovate: datasource=github-tags depName=Adafruit LPS2X packageName=adafruit/Adafruit_LPS2X
https://github.com/adafruit/Adafruit_LPS2X/archive/refs/tags/2.0.6.zip
# renovate: datasource=github-tags depName=Adafruit SHT31 packageName=adafruit/Adafruit_SHT31
https://github.com/adafruit/Adafruit_SHT31/archive/refs/tags/2.2.2.zip
# renovate: datasource=github-tags depName=Adafruit VEML7700 packageName=adafruit/Adafruit_VEML7700
https://github.com/adafruit/Adafruit_VEML7700/archive/refs/tags/2.1.6.zip
# renovate: datasource=github-tags depName=Adafruit SHT4x packageName=adafruit/Adafruit_SHT4X
https://github.com/adafruit/Adafruit_SHT4X/archive/refs/tags/1.0.5.zip
# renovate: datasource=github-tags depName=SparkFun Qwiic Scale NAU7802 packageName=sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library
https://github.com/sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library/archive/refs/tags/v1.0.6.zip
# renovate: datasource=custom.pio depName=ClosedCube OPT3001 packageName=closedcube/library/ClosedCube OPT3001
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
@@ -227,6 +209,26 @@ lib_deps =
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/refs/tags/1.0.0.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
; Common environmental sensor libraries (not included in native / portduino)
[environmental_extra_common]
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
# renovate: datasource=github-tags depName=Adafruit MAX1704X packageName=adafruit/Adafruit_MAX1704X
https://github.com/adafruit/Adafruit_MAX1704X/archive/refs/tags/1.0.3.zip
# renovate: datasource=github-tags depName=Adafruit LPS2X packageName=adafruit/Adafruit_LPS2X
https://github.com/adafruit/Adafruit_LPS2X/archive/refs/tags/2.0.6.zip
# renovate: datasource=github-tags depName=Adafruit VEML7700 packageName=adafruit/Adafruit_VEML7700
https://github.com/adafruit/Adafruit_VEML7700/archive/refs/tags/2.1.6.zip
# renovate: datasource=github-tags depName=SparkFun Qwiic Scale NAU7802 packageName=sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library
https://github.com/sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library/archive/refs/tags/v1.0.6.zip
# renovate: datasource=custom.pio depName=ClosedCube OPT3001 packageName=closedcube/library/ClosedCube OPT3001
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
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
[environmental_extra]
+5 -4
View File
@@ -152,15 +152,16 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
// Default Bluetooth PIN
#define defaultBLEPin 123456
#if HAS_ETHERNET && defined(USE_CH390D)
#if HAS_ETHERNET && defined(USE_ARDUINO_ETHERNET)
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5500 auto-detect
#elif HAS_ETHERNET && defined(USE_CH390D)
#include <ESP32_CH390.h>
#elif HAS_ETHERNET && !defined(USE_WS5500)
#include <RAK13800_W5100S.h>
#endif // HAS_ETHERNET
#if HAS_ETHERNET && defined(USE_WS5500)
#include <ETHClass2.h>
#define ETH ETH2
#if HAS_ETHERNET && defined(ARCH_ESP32)
#include <ETH.h>
#endif // HAS_ETHERNET
#if HAS_WIFI
+30 -10
View File
@@ -1,42 +1,62 @@
#include "DisplayFormatters.h"
#include "MeshRadio.h"
const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName,
bool usePreset)
{
// If use_preset is false, always return "Custom"
// If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
// rely on this being a stable literal for channel-name hashing and default-channel detection.
if (!usePreset) {
return "Custom";
}
switch (preset) {
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
case PRESET(SHORT_TURBO):
return useShortName ? "ShortT" : "ShortTurbo";
break;
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
case PRESET(SHORT_SLOW):
return useShortName ? "ShortS" : "ShortSlow";
break;
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
case PRESET(SHORT_FAST):
return useShortName ? "ShortF" : "ShortFast";
break;
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
case PRESET(MEDIUM_SLOW):
return useShortName ? "MedS" : "MediumSlow";
break;
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
case PRESET(MEDIUM_FAST):
return useShortName ? "MedF" : "MediumFast";
break;
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
case PRESET(LONG_SLOW):
return useShortName ? "LongS" : "LongSlow";
break;
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST:
case PRESET(LONG_FAST):
return useShortName ? "LongF" : "LongFast";
break;
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO:
case PRESET(LONG_TURBO):
return useShortName ? "LongT" : "LongTurbo";
break;
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
case PRESET(LONG_MODERATE):
return useShortName ? "LongM" : "LongMod";
break;
case PRESET(LITE_FAST):
return useShortName ? "LiteF" : "LiteFast";
break;
case PRESET(LITE_SLOW):
return useShortName ? "LiteS" : "LiteSlow";
break;
case PRESET(NARROW_FAST):
return useShortName ? "NarF" : "NarrowFast";
break;
case PRESET(NARROW_SLOW):
return useShortName ? "NarS" : "NarrowSlow";
break;
case PRESET(TINY_FAST):
return useShortName ? "TinyF" : "TinyFast";
break;
case PRESET(TINY_SLOW):
return useShortName ? "TinyS" : "TinySlow";
break;
default:
return useShortName ? "Custom" : "Invalid";
break;
+175 -118
View File
@@ -79,28 +79,136 @@ bool copyFile(const char *from, const char *to)
bool renameFile(const char *pathFrom, const char *pathTo)
{
#ifdef FSCom
#ifdef ARCH_ESP32
// take SPI Lock
spiLock->lock();
// rename was fixed for ESP32 IDF LittleFS in April
bool result = FSCom.rename(pathFrom, pathTo);
spiLock->unlock();
return result;
#else
// copyFile does its own locking.
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
return true;
} else {
return false;
}
#endif
return false;
#endif
}
#include <cstring>
#include <new>
#include <stdexcept>
#include <vector>
/**
* @brief Platform-agnostic filesystem format / wipe.
*
* On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the
* native FSCom.format() which erases and reinitialises the LittleFS
* partition.
*
* On Portduino the fs::FS backend has no format() method. We instead
* delete /prefs (the only meshtastic data directory written at runtime)
* and return. rmDir("/prefs") is already called unconditionally by
* factoryReset() so this is a proven primitive on Portduino.
* FSBegin() is a no-op (#define FSBegin() true) on Portduino.
*
* @return true on success, false on failure or if no filesystem is configured.
*/
bool fsFormat()
{
#ifdef FSCom
#if defined(ARCH_PORTDUINO)
rmDir("/prefs");
return FSBegin();
#else
return FSCom.format();
#endif
#else
return false;
#endif
}
#ifdef FSCom
namespace
{
bool pathEndsWithDot(const char *path)
{
if (!path)
return false;
size_t length = strlen(path);
return length > 0 && path[length - 1] == '.';
}
bool copyFilePath(char *dest, size_t destSize, const char *path, bool *wasLimited)
{
if (!path || destSize == 0) {
if (wasLimited)
*wasLimited = true;
return false;
}
if (strlcpy(dest, path, destSize) >= destSize) {
if (wasLimited)
*wasLimited = true;
return false;
}
return true;
}
void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vector<meshtastic_FileInfo> &filenames,
bool *wasLimited)
{
if (!dirname)
return;
File root = FSCom.open(dirname, FILE_O_READ);
if (!root)
return;
if (!root.isDirectory()) {
root.close();
return;
}
File file = root.openNextFile();
// file.name()[0] check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
while (file && file.name()[0]) {
if (filenames.size() >= maxCount) {
if (wasLimited)
*wasLimited = true;
file.close();
break;
}
const char *fileName = file.name();
if (file.isDirectory() && !pathEndsWithDot(fileName)) {
char pathBuffer[sizeof(((meshtastic_FileInfo *)nullptr)->file_name)] = {};
#ifdef ARCH_ESP32
const char *subDirPath = file.path();
#else
const char *subDirPath = fileName;
#endif
bool hasSubDirPath = copyFilePath(pathBuffer, sizeof(pathBuffer), subDirPath, wasLimited);
file.close();
if (levels && hasSubDirPath) {
collectFiles(pathBuffer, levels - 1, maxCount, filenames, wasLimited);
} else if (wasLimited) {
*wasLimited = true;
}
} else {
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
#ifdef ARCH_ESP32
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.path(), wasLimited);
#else
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.name(), wasLimited);
#endif
if (hasFilePath && !pathEndsWithDot(fileInfo.file_name)) {
filenames.push_back(fileInfo);
}
file.close();
}
file = root.openNextFile();
}
root.close();
}
} // namespace
#endif
/**
* @brief Get the list of files in a directory.
*
@@ -109,45 +217,40 @@ bool renameFile(const char *pathFrom, const char *pathTo)
*
* @param dirname The name of the directory.
* @param levels The number of levels of subdirectories to list.
* @return A vector of strings containing the full path of each file in the directory.
* @param maxCount The maximum number of files to collect before truncating the walk.
* @param wasLimited Optional out-param, set to true if the listing was truncated (by maxCount or low memory).
* @return A vector of meshtastic_FileInfo for each file in the directory.
*/
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount, bool *wasLimited)
{
std::vector<meshtastic_FileInfo> filenames = {};
if (wasLimited)
*wasLimited = false;
#ifdef FSCom
File root = FSCom.open(dirname, FILE_O_READ);
if (!root)
return filenames;
if (!root.isDirectory())
return filenames;
File file = root.openNextFile();
while (file) {
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
if (levels) {
#ifdef ARCH_ESP32
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.path(), levels - 1);
#else
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.name(), levels - 1);
#endif
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
file.close();
}
} else {
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
#ifdef ARCH_ESP32
strcpy(fileInfo.file_name, file.path());
#else
strcpy(fileInfo.file_name, file.name());
#endif
if (!String(fileInfo.file_name).endsWith(".")) {
filenames.push_back(fileInfo);
}
file.close();
#if defined(__cpp_exceptions) || defined(__EXCEPTIONS)
size_t reservedCount = maxCount;
while (reservedCount > 0) {
try {
filenames.reserve(reservedCount);
break;
} catch (const std::bad_alloc &) {
reservedCount /= 2;
} catch (const std::length_error &) {
reservedCount /= 2;
}
file = root.openNextFile();
}
root.close();
if (reservedCount == 0) {
if (wasLimited)
*wasLimited = true;
return filenames;
}
if (reservedCount < maxCount) {
if (wasLimited)
*wasLimited = true;
maxCount = reservedCount;
}
#endif
collectFiles(dirname, levels, maxCount, filenames, wasLimited);
#endif
return filenames;
}
@@ -163,98 +266,59 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
void listDir(const char *dirname, uint8_t levels, bool del)
{
#ifdef FSCom
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
char buffer[255];
#endif
File root = FSCom.open(dirname, FILE_O_READ);
if (!root) {
if (!root || !root.isDirectory())
return;
}
if (!root.isDirectory()) {
return;
}
File file = root.openNextFile();
while (
file &&
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
#ifdef ARCH_ESP32
const char *filepath = file.path();
#else
const char *filepath = file.name();
#endif
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
if (levels) {
#ifdef ARCH_ESP32
listDir(file.path(), levels - 1, del);
listDir(filepath, levels - 1, del);
if (del) {
LOG_DEBUG("Remove %s", file.path());
strncpy(buffer, file.path(), sizeof(buffer));
LOG_DEBUG("Remove %s", filepath);
strncpy(buffer, filepath, sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
file.close();
FSCom.rmdir(buffer);
} else {
file.close();
}
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
listDir(file.name(), levels - 1, del);
if (del) {
LOG_DEBUG("Remove %s", file.name());
strncpy(buffer, file.name(), sizeof(buffer));
file.close();
FSCom.rmdir(buffer);
} else {
file.close();
}
#else
LOG_DEBUG(" %s (directory)", file.name());
listDir(file.name(), levels - 1, del);
file.close();
#endif
}
} else {
#ifdef ARCH_ESP32
if (del) {
LOG_DEBUG("Delete %s", file.path());
strncpy(buffer, file.path(), sizeof(buffer));
LOG_DEBUG("Delete %s", filepath);
strncpy(buffer, filepath, sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
file.close();
FSCom.remove(buffer);
} else {
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
file.close();
}
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
if (del) {
LOG_DEBUG("Delete %s", file.name());
strncpy(buffer, file.name(), sizeof(buffer));
file.close();
FSCom.remove(buffer);
} else {
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
file.close();
}
#else
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
file.close();
#endif
}
file = root.openNextFile();
}
#ifdef ARCH_ESP32
if (del) {
LOG_DEBUG("Remove %s", root.path());
strncpy(buffer, root.path(), sizeof(buffer));
root.close();
FSCom.rmdir(buffer);
} else {
root.close();
}
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
if (del) {
LOG_DEBUG("Remove %s", root.name());
strncpy(buffer, root.name(), sizeof(buffer));
root.close();
FSCom.rmdir(buffer);
} else {
root.close();
}
const char *rootpath = root.path();
#else
root.close();
const char *rootpath = root.name();
#endif
if (del) {
LOG_DEBUG("Remove %s", rootpath);
strncpy(buffer, rootpath, sizeof(buffer) - 1);
buffer[sizeof(buffer) - 1] = '\0';
root.close();
FSCom.rmdir(buffer);
} else {
root.close();
}
#endif
}
@@ -268,14 +332,7 @@ void listDir(const char *dirname, uint8_t levels, bool del)
void rmDir(const char *dirname)
{
#ifdef FSCom
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
listDir(dirname, 10, true);
#elif defined(ARCH_NRF52)
// nRF52 implementation of LittleFS has a recursive delete function
FSCom.rmdir_r(dirname);
#endif
#endif
}
@@ -307,7 +364,7 @@ void fsInit()
*/
void setupSDCard()
{
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI)
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) && !defined(HAS_SD_MMC)
concurrency::LockGuard g(spiLock);
SDHandler.begin(SPI_SCK, SPI_MISO, SPI_MOSI);
if (!SD.begin(SDCARD_CS, SDHandler, SD_SPI_FREQUENCY)) {
+12 -3
View File
@@ -14,8 +14,8 @@
#define FILE_O_READ "r"
#endif
#if defined(ARCH_STM32WL)
// STM32WL
#if defined(ARCH_STM32)
// STM32
#include "LittleFS.h"
#define FSCom InternalFS
#define FSBegin() FSCom.begin()
@@ -48,11 +48,20 @@ using namespace STM32_LittleFS_Namespace;
using namespace Adafruit_LittleFS_Namespace;
#endif
#if defined(ARCH_NRF54L15)
// nRF54L15 — Zephyr LittleFS on 36 KB storage_partition (internal RRAM)
#include "InternalFileSystem.h"
#define FSCom InternalFS
#define FSBegin() FSCom.begin()
using namespace Adafruit_LittleFS_Namespace;
#endif
void fsInit();
void fsListFiles();
bool copyFile(const char *from, const char *to);
bool renameFile(const char *pathFrom, const char *pathTo);
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
bool fsFormat();
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr);
void listDir(const char *dirname, uint8_t levels, bool del = false);
void rmDir(const char *dirname);
void setupSDCard();
+3 -6
View File
@@ -53,8 +53,7 @@ class GPSStatus : public Status
int32_t getLatitude() const
{
if (config.position.fixed_position) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
return node->position.latitude_i;
return localPosition.latitude_i;
} else {
return p.latitude_i;
}
@@ -63,8 +62,7 @@ class GPSStatus : public Status
int32_t getLongitude() const
{
if (config.position.fixed_position) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
return node->position.longitude_i;
return localPosition.longitude_i;
} else {
return p.longitude_i;
}
@@ -73,8 +71,7 @@ class GPSStatus : public Status
int32_t getAltitude() const
{
if (config.position.fixed_position) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
return node->position.altitude;
return localPosition.altitude;
} else {
return p.altitude;
}
+28 -32
View File
@@ -1,12 +1,11 @@
#include "configuration.h"
#if HAS_SCREEN
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
#include "FSCommon.h"
#include "MessageStore.h"
#include "NodeDB.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "gps/RTC.h"
#include "graphics/draw/MessageRenderer.h"
#include <cstring> // memcpy
#ifndef MESSAGE_TEXT_POOL_SIZE
@@ -181,13 +180,8 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa
bool isDM = (sm.dest != 0 && sm.dest != NODENUM_BROADCAST);
if (packet.from == 0) {
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
sm.ackStatus = AckStatus::NONE;
} else {
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
sm.ackStatus = AckStatus::ACKED;
}
sm.type = isDM ? MessageType::DM_TO_US : MessageType::BROADCAST;
sm.ackStatus = (packet.from == 0) ? AckStatus::NONE : AckStatus::ACKED;
addLiveMessage(sm);
@@ -360,6 +354,7 @@ void MessageStore::clearAllMessages()
resetMessagePool();
#ifdef FSCom
concurrency::LockGuard guard(spiLock);
SafeFile f(filename.c_str(), false);
uint8_t count = 0;
f.write(&count, 1); // write "0 messages"
@@ -372,26 +367,25 @@ void MessageStore::clearAllMessages()
#endif
}
// Internal helper: erase first or last message matching a predicate
template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deque, Predicate pred, bool fromBack = false)
// Internal helpers for targeted erasure.
template <typename Predicate> static bool eraseFirstMatch(std::deque<StoredMessage> &deque, Predicate pred)
{
if (fromBack) {
// Iterate from the back and erase all matches from the end
for (auto it = deque.rbegin(); it != deque.rend();) {
if (pred(*it)) {
it = std::deque<StoredMessage>::reverse_iterator(deque.erase(std::next(it).base()));
} else {
++it;
}
for (auto it = deque.begin(); it != deque.end(); ++it) {
if (pred(*it)) {
deque.erase(it);
return true;
}
} else {
// Manual forward search to erase all matches
for (auto it = deque.begin(); it != deque.end();) {
if (pred(*it)) {
it = deque.erase(it);
} else {
++it;
}
}
return false;
}
template <typename Predicate> static void eraseAllMatches(std::deque<StoredMessage> &deque, Predicate pred)
{
for (auto it = deque.begin(); it != deque.end();) {
if (pred(*it)) {
it = deque.erase(it);
} else {
++it;
}
}
}
@@ -399,7 +393,9 @@ template <typename Predicate> static void eraseIf(std::deque<StoredMessage> &deq
// Delete oldest message (RAM + persisted queue)
void MessageStore::deleteOldestMessage()
{
eraseIf(liveMessages, [](StoredMessage &) { return true; });
if (!liveMessages.empty()) {
liveMessages.pop_front();
}
saveToFlash();
}
@@ -407,14 +403,14 @@ void MessageStore::deleteOldestMessage()
void MessageStore::deleteOldestMessageInChannel(uint8_t channel)
{
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
eraseIf(liveMessages, pred);
eraseFirstMatch(liveMessages, pred);
saveToFlash();
}
void MessageStore::deleteAllMessagesInChannel(uint8_t channel)
{
auto pred = [channel](const StoredMessage &m) { return m.type == MessageType::BROADCAST && m.channelIndex == channel; };
eraseIf(liveMessages, pred, false /* delete ALL, not just first */);
eraseAllMatches(liveMessages, pred);
saveToFlash();
}
@@ -427,7 +423,7 @@ void MessageStore::deleteAllMessagesWithPeer(uint32_t peer)
uint32_t other = (m.sender == local) ? m.dest : m.sender;
return other == peer;
};
eraseIf(liveMessages, pred, false);
eraseAllMatches(liveMessages, pred);
saveToFlash();
}
@@ -440,7 +436,7 @@ void MessageStore::deleteOldestMessageWithPeer(uint32_t peer)
uint32_t other = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender;
return other == peer;
};
eraseIf(liveMessages, pred);
eraseFirstMatch(liveMessages, pred);
saveToFlash();
}
+1 -4
View File
@@ -1,6 +1,6 @@
#pragma once
#if HAS_SCREEN
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
// Disable debug logging entirely on release builds of HELTEC_MESH_SOLAR for space constraints
#if defined(HELTEC_MESH_SOLAR)
@@ -124,9 +124,6 @@ class MessageStore
// Allocate text into pool (used by sender-side code)
static uint16_t storeText(const char *src, size_t len);
// Used when loading from flash to rebuild the text pool
static uint16_t rebuildTextFromFlash(const char *src, size_t len);
private:
std::deque<StoredMessage> liveMessages; // Single in-RAM message buffer (also used for persistence)
std::string filename; // Flash filename for persistence

Some files were not shown because too many files have changed in this diff Show More