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
192 changed files with 12358 additions and 2345 deletions
+18 -1
View File
@@ -191,7 +191,24 @@ Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which d
### 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, `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.
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)
+29 -1
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
@@ -141,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:
+2 -1
View File
@@ -10,5 +10,6 @@
},
"[powershell]": {
"editor.defaultFormatter": "ms-vscode.powershell"
}
},
"idf.currentSetup": "C:/Espressif/frameworks/esp-idf-v5.3.1/"
}
+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.
+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
+1 -1
View File
@@ -18,4 +18,4 @@ Lora:
DIO3_TCXO_VOLTAGE: true
DIO2_AS_RF_SWITCH: true
spidev: spidev0.1
# CS: 7
# CS: 7
+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()
+25 -29
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":
+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
+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"
}
+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"
}
-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
@@ -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.
+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)
@@ -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:
+13 -12
View File
@@ -13,7 +13,7 @@ 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
@@ -62,6 +62,7 @@ build_flags = -Wno-missing-field-initializers
-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
@@ -103,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]
@@ -200,6 +201,16 @@ lib_deps =
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
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
https://github.com/Sensirion/arduino-i2c-scd30/archive/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]
@@ -218,16 +229,6 @@ lib_deps =
closedcube/ClosedCube OPT3001@1.1.2
# renovate: datasource=git-refs depName=meshtastic-DFRobot_LarkWeatherStation packageName=https://github.com/meshtastic/DFRobot_LarkWeatherStation gitBranch=master
https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
https://github.com/Sensirion/arduino-i2c-scd30/archive/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
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
[environmental_extra]
+118 -31
View File
@@ -88,6 +88,9 @@ bool renameFile(const char *pathFrom, const char *pathTo)
#endif
}
#include <cstring>
#include <new>
#include <stdexcept>
#include <vector>
/**
@@ -119,6 +122,93 @@ bool fsFormat()
#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.
*
@@ -127,43 +217,40 @@ bool fsFormat()
*
* @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) {
#ifdef ARCH_ESP32
const char *filepath = file.path();
#else
const char *filepath = file.name();
#endif
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
if (levels) {
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
file.close();
}
} else {
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
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;
}
+3 -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()
@@ -61,7 +61,7 @@ void fsListFiles();
bool copyFile(const char *from, const char *to);
bool renameFile(const char *pathFrom, const char *pathTo);
bool fsFormat();
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 = 64, bool *wasLimited = nullptr);
void listDir(const char *dirname, uint8_t levels, bool del = false);
void rmDir(const char *dirname);
void setupSDCard();
+1
View File
@@ -354,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"
+21 -6
View File
@@ -24,6 +24,7 @@
#include "main.h"
#include "meshUtils.h"
#include "power/PowerHAL.h"
#include "power/SGM41562.h"
#include "sleep.h"
#ifdef ARCH_ESP32
// #include <driver/adc.h>
@@ -48,7 +49,7 @@
#include "concurrency/LockGuard.h"
#endif
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
#if defined(ARCH_STM32) && defined(BATTERY_PIN)
#include "stm32yyxx_ll_adc.h"
/* Analog read resolution */
@@ -431,7 +432,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
float scaled = 0;
battery_adcEnable();
#ifdef ARCH_STM32WL
#ifdef ARCH_STM32
// STM32 ADC with VREFINT runtime calibration
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
raw = analogRead(BATTERY_PIN);
@@ -545,6 +546,10 @@ class AnalogBatteryLevel : public HasBatteryLevel
// lastly provide a fallback to indicate external power when fully charged.
virtual bool isVbusIn() override
{
#ifdef HAS_SGM41562
if (sgm41562 && sgm41562->refresh())
return sgm41562->isInputPowerGood();
#endif
#ifdef EXT_PWR_DETECT
return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE;
@@ -561,6 +566,10 @@ class AnalogBatteryLevel : public HasBatteryLevel
/// we can't be smart enough to say 'full'?
virtual bool isCharging() override
{
#ifdef HAS_SGM41562
if (sgm41562 && sgm41562->refresh())
return sgm41562->isCharging();
#endif
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT) && !defined(HAS_PMU)
if (hasRAK()) {
return (rak9154Sensor.isCharging()) ? OptTrue : OptFalse;
@@ -608,7 +617,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
bool initial_read_done = false;
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
uint32_t last_read_time_ms = 0;
#ifdef ARCH_STM32WL
#ifdef ARCH_STM32
// 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing
// (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset")
uint32_t Vref = 3300;
@@ -718,7 +727,7 @@ bool Power::analogInit()
#define BATTERY_SENSE_RESOLUTION_BITS 10
#endif
#ifdef ARCH_STM32WL
#ifdef ARCH_STM32
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
adc_oneshot_unit_init_cfg_t init_config = {
@@ -749,7 +758,7 @@ bool Power::analogInit()
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32)
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
#endif
@@ -767,6 +776,12 @@ bool Power::analogInit()
*/
bool Power::setup()
{
#ifdef HAS_SGM41562
// Initialize the charger early so AnalogBatteryLevel can read charging
// state from it. The charger does not provide battery voltage / percent —
// those still come from the platform ADC via analogInit() below.
initSGM41562(SGM41562_WIRE);
#endif
bool found = false;
if (axpChipInit()) {
found = true;
@@ -838,7 +853,7 @@ void Power::reboot()
}
LOG_DEBUG("final reboot!");
::reboot();
#elif defined(ARCH_STM32WL)
#elif defined(ARCH_STM32)
HAL_NVIC_SystemReset();
#else
rebootAtMsec = -1;
+32 -9
View File
@@ -179,6 +179,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#endif
#endif
#ifdef USE_KCT8103L_PA_ONLY
#if defined(HELTEC_MESH_TOWER_V2)
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 8, 7
#endif
#endif
#ifdef RAK13302
#define NUM_PA_POINTS 22
#define TX_GAIN_LORA 7, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8
@@ -576,9 +583,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// -----------------------------------------------------------------------------
// MESHTASTIC_LOCKDOWN — runtime, client-toggleable hardening (nRF52 only)
//
// There is NO build flag to turn lockdown on or off. On nRF52 (CC310 hardware
// crypto) the lockdown machinery is ALWAYS compiled in; whether it is ACTIVE
// is decided entirely at runtime by EncryptedStorage::isLockdownActive()
// Lockdown/protect support is opt-in at build time. Builds that need it pass
// -DMESHTASTIC_ENABLE_LOCKDOWN=1. When enabled on nRF52 (CC310 hardware
// crypto), whether it is ACTIVE is decided entirely at runtime by
// EncryptedStorage::isLockdownActive()
// (== a passphrase has been provisioned, i.e. /prefs/.dek exists). A device
// that has never been provisioned — or that the operator disabled from the
// client app — behaves exactly like stock firmware: plaintext storage, no
@@ -594,11 +602,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// reboots into normal mode. APPROTECT is the one thing that
// does NOT revert (see below).
//
// MESHTASTIC_LOCKDOWN here is an INTERNAL capability marker, auto-defined for
// nRF52. It gates the UI bits (lock screen, pairing-PIN handling). It is NOT
// something a variant sets. Flash-constrained nRF52 variants that genuinely
// cannot afford the ~tens-of-KB of crypto + access-control code may opt OUT
// with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
// MESHTASTIC_LOCKDOWN here is an INTERNAL capability marker. It gates the UI
// bits (lock screen, pairing-PIN handling). Flash-constrained nRF52 variants
// that genuinely cannot afford the ~tens-of-KB of crypto + access-control code
// may also opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
//
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — per-connection auth + redaction,
// gated at runtime on isLockdownActive()
@@ -615,7 +622,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// -DMESHTASTIC_LOCKDOWN_DEBUG=1 keeps the irreversible APPROTECT burn disabled
// even when provisioned — for development so dev boards never lose SWD.
// -----------------------------------------------------------------------------
#if defined(ARCH_NRF52) && !defined(MESHTASTIC_EXCLUDE_LOCKDOWN)
#if defined(ARCH_NRF52)
#ifndef MESHTASTIC_ENABLE_LOCKDOWN
#define MESHTASTIC_ENABLE_LOCKDOWN 0
#endif
#if !MESHTASTIC_ENABLE_LOCKDOWN
#undef MESHTASTIC_LOCKDOWN
#undef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
#undef MESHTASTIC_ENCRYPTED_STORAGE
#undef MESHTASTIC_ENABLE_APPROTECT
#ifndef MESHTASTIC_EXCLUDE_LOCKDOWN
#define MESHTASTIC_EXCLUDE_LOCKDOWN 1
#endif
#endif
#if MESHTASTIC_ENABLE_LOCKDOWN && !defined(MESHTASTIC_EXCLUDE_LOCKDOWN)
#define MESHTASTIC_LOCKDOWN 1
#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
#define MESHTASTIC_ENCRYPTED_STORAGE 1
@@ -623,6 +645,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#define MESHTASTIC_ENABLE_APPROTECT 1
#endif
#endif
#endif
#ifdef MESHTASTIC_LOCKDOWN
+5 -5
View File
@@ -37,15 +37,15 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
{
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P};
return firstOfOrNONE(11, types);
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P, ISM330DHCX};
return firstOfOrNONE(12, types);
}
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
{
ScanI2C::DeviceType types[] = {MMC5983MA};
return firstOfOrNONE(1, types);
ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR};
return firstOfOrNONE(2, types);
}
ScanI2C::FoundDevice ScanI2C::firstAQI() const
+2
View File
@@ -99,6 +99,8 @@ class ScanI2C
CW2015,
SCD30,
ADS1115,
IIS2MDCTR,
ISM330DHCX,
} DeviceType;
// typedef uint8_t DeviceAddress;
+15 -2
View File
@@ -8,7 +8,7 @@
#if defined(ARCH_PORTDUINO)
#include "linux/LinuxHardwareI2C.h"
#endif
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL)
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32)
#include "meshUtils.h" // vformat
#endif
@@ -584,6 +584,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
if (registerValue == 0x6A) {
type = LSM6DS3;
logFoundDevice("LSM6DS3", (uint8_t)addr.address);
} else if (registerValue == 0x6B) {
type = ISM330DHCX;
logFoundDevice("ISM330DHCX", (uint8_t)addr.address);
} else {
type = QMI8658;
logFoundDevice("QMI8658", (uint8_t)addr.address);
@@ -591,7 +594,17 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
SCAN_SIMPLE_CASE(QMC5883L_ADDR, QMC5883L, "QMC5883L", (uint8_t)addr.address)
SCAN_SIMPLE_CASE(HMC5883L_ADDR, HMC5883L, "HMC5883L", (uint8_t)addr.address)
case HMC5883L_ADDR:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x4FU), 1); // get ID
if (registerValue == 0x40) {
type = IIS2MDCTR;
logFoundDevice("IIS2MDCTR", (uint8_t)addr.address);
break;
} else {
type = HMC5883L;
logFoundDevice("HMC5883L", (uint8_t)addr.address);
break;
}
#ifdef HAS_QMA6100P
SCAN_SIMPLE_CASE(QMA6100P_ADDR, QMA6100P, "QMA6100P", (uint8_t)addr.address)
#else
+80 -24
View File
@@ -47,7 +47,7 @@ template <typename T, std::size_t N> std::size_t array_count(const T (&)[N])
#if defined(ARCH_NRF52)
Uart *GPS::_serial_gps = &GPS_SERIAL_PORT;
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
HardwareSerial *GPS::_serial_gps = &GPS_SERIAL_PORT;
#elif defined(ARCH_RP2040)
SerialUART *GPS::_serial_gps = &GPS_SERIAL_PORT;
@@ -80,6 +80,12 @@ namespace
constexpr uint32_t GPS_PROBE_CACHE_MAGIC = 0x47504348UL; // "GPCH"
constexpr uint16_t GPS_PROBE_CACHE_VERSION = 1;
constexpr const char *GPS_PROBE_CACHE_FILE = "/prefs/gps_probe_cache.dat";
constexpr int MIN_PLAUSIBLE_GPS_YEAR = 2020;
constexpr int MAX_PLAUSIBLE_GPS_YEAR = 2100;
#ifdef TRACKER_T1000_E
constexpr uint32_t T1000_E_AIROHA_WAKE_MS = 1000;
constexpr uint32_t T1000_E_AIROHA_WAKE_INTERVAL_MS = 40;
#endif
struct GPSProbeCacheRecord {
uint32_t magic;
@@ -101,6 +107,45 @@ bool isValidProbeBaud(uint32_t baud)
return baud >= 1200 && baud <= 921600;
}
template <typename T> void wakeAirohaForActiveProbe(T *serialGps)
{
#ifdef TRACKER_T1000_E
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
digitalWrite(GPS_RTC_INT, HIGH);
delay(3);
digitalWrite(GPS_RTC_INT, LOW);
delay(50);
const uint32_t start = millis();
do {
serialGps->write("$PAIR382,1*2E\r\n");
delay(T1000_E_AIROHA_WAKE_INTERVAL_MS);
} while (Throttle::isWithinTimespanMs(start, T1000_E_AIROHA_WAKE_MS));
#elif defined(GNSS_AIROHA)
serialGps->write("$PAIR382,1*2E\r\n");
delay(20);
#else
(void)serialGps;
#endif
}
bool isPlausibleNmeaTime(const struct tm &t)
{
const int year = t.tm_year + 1900;
if (year < MIN_PLAUSIBLE_GPS_YEAR || year > MAX_PLAUSIBLE_GPS_YEAR) {
return false;
}
#ifdef BUILD_EPOCH
const int64_t candidate = static_cast<int64_t>(gm_mktime(&t));
const int64_t minEpoch = static_cast<int64_t>(BUILD_EPOCH);
const int64_t maxEpoch = minEpoch + static_cast<int64_t>(FORTY_YEARS);
return candidate >= minEpoch && candidate <= maxEpoch;
#else
return true;
#endif
}
template <typename T> bool sawNmeaSentenceAtBaud(T *serialGps, uint32_t timeoutMs)
{
// Lightweight passive check: look for at least one complete
@@ -642,7 +687,7 @@ bool GPS::verifyCachedProbePresence()
return false;
}
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
_serial_gps->end();
_serial_gps->begin(cachedProbeBaud);
#elif defined(ARCH_RP2040)
@@ -689,6 +734,7 @@ bool GPS::verifyCachedProbePresence()
case GNSS_MODEL_AG3352:
if (cachedProbeModel == GNSS_MODEL_AG3352)
cachedProbeModelName = "AG3352";
wakeAirohaForActiveProbe(_serial_gps);
_serial_gps->write("$PAIR021*39\r\n");
present = (getACK("$PAIR021,", 900) == GNSS_RESPONSE_OK);
break;
@@ -741,7 +787,6 @@ bool GPS::verifyCachedProbePresence()
if (!present) {
LOG_WARN("Cached GPS probe is stale (%s @ %d), clearing cache", cachedProbeModelName, cachedProbeBaud);
clearProbeCache();
cachedProbeFailedThisBoot = true;
return false;
}
@@ -762,13 +807,6 @@ bool GPS::setup()
{
if (!didSerialInit) {
int msglen = 0;
if (cachedProbeFailedThisBoot) {
// If cached verification failed, suppress further probing until
// reboot.
didSerialInit = true;
return true;
}
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
if (!hasProbeCache && !triedProbeCache) {
(void)loadProbeCache();
@@ -777,12 +815,13 @@ bool GPS::setup()
if (hasProbeCache && !triedProbeCache) {
triedProbeCache = true;
if (!verifyCachedProbePresence()) {
// Cache was stale and got wiped; skip scanning this boot
// and let next boot do a full probe.
didSerialInit = true;
return true;
currentStep = 0;
speedSelect = 0;
probeTries = 0;
}
} else if (probeTries < GPS_PROBETRIES) {
}
if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries < GPS_PROBETRIES) {
// No usable cache: walk common baud rates first.
gnssModel = probe(serialSpeeds[speedSelect]);
if (gnssModel != GNSS_MODEL_UNKNOWN) {
@@ -794,7 +833,7 @@ bool GPS::setup()
}
// Rare Serial Speeds
#ifndef CONFIG_IDF_TARGET_ESP32C6
else if (probeTries == GPS_PROBETRIES) {
else if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries == GPS_PROBETRIES) {
// Then try less common baud rates before giving up.
gnssModel = probe(rareSerialSpeeds[speedSelect]);
if (gnssModel != GNSS_MODEL_UNKNOWN) {
@@ -1121,10 +1160,22 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
switch (newState) {
case GPS_ACTIVE:
case GPS_IDLE:
if (oldState == GPS_ACTIVE || oldState == GPS_IDLE) // If hardware already awake, no changes needed
if (oldState == GPS_ACTIVE)
break;
gotTime = false;
if (oldState == GPS_IDLE) // If hardware already awake, no changes needed
break;
if (oldState != GPS_ACTIVE && oldState != GPS_IDLE) // If hardware just waking now, clear buffer
clearBuffer();
#ifdef TRACKER_T1000_E
pinMode(GPS_VRTC_EN, OUTPUT);
digitalWrite(GPS_VRTC_EN, HIGH);
pinMode(GPS_SLEEP_INT, OUTPUT);
digitalWrite(GPS_SLEEP_INT, HIGH);
pinMode(GPS_RTC_INT, OUTPUT);
digitalWrite(GPS_RTC_INT, LOW);
pinMode(GPS_RESETB_OUT, INPUT_PULLUP);
#endif
powerMon->setState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
writePinEN(true); // Power (EN pin): on
setPowerPMU(true); // Power (PMU): on
@@ -1383,9 +1434,8 @@ int32_t GPS::runOnce()
if (!setup())
return currentDelay; // Setup failed, re-run in two seconds
if (cachedProbeFailedThisBoot || gnssModel == GNSS_MODEL_UNKNOWN) {
LOG_WARN("GPS not detected at cached settings; marked not present "
"for this boot");
if (gnssModel == GNSS_MODEL_UNKNOWN) {
LOG_WARN("GPS not detected; marked not present for this boot");
return disable();
}
@@ -1437,8 +1487,7 @@ int32_t GPS::runOnce()
// if gps_update_interval is <=10s, GPS never goes off, so we treat that differently
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
// 1. Got a time for the first time
bool gotTime = (getRTCQuality() >= RTCQualityGPS);
// 1. Got a time for the first time this cycle
if (!gotTime && lookForTime()) { // Note: we count on this && short-circuiting and not resetting the RTC time
gotTime = true;
}
@@ -1564,7 +1613,7 @@ GnssModel_t GPS::probe(int serialSpeed)
switch (currentStep) {
case 0: {
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
_serial_gps->end();
_serial_gps->begin(serialSpeed);
#elif defined(ARCH_RP2040)
@@ -1585,6 +1634,9 @@ GnssModel_t GPS::probe(int serialSpeed)
digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); // assert for 10ms
delay(10);
digitalWrite(PIN_GPS_RESET, !GPS_RESET_MODE);
#ifdef TRACKER_T1000_E
delay(100);
#endif
// attempt to detect the chip based on boot messages
std::vector<ChipInfo> passive_detect = {
@@ -1637,6 +1689,7 @@ GnssModel_t GPS::probe(int serialSpeed)
}
case 3: {
/* Airoha (Mediatek) AG3335A/M/S, A3352Q, Quectel L89 2.0, SimCom SIM65M */
wakeAirohaForActiveProbe(_serial_gps);
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF to reduce volume
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF to reduce volume
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
@@ -1921,7 +1974,7 @@ std::unique_ptr<GPS> GPS::createGps()
#elif defined(ARCH_NRF52)
_serial_gps->setPins(new_gps->rx_gpio, new_gps->tx_gpio);
_serial_gps->begin(GPS_BAUDRATE);
#elif defined(ARCH_STM32WL)
#elif defined(ARCH_STM32)
_serial_gps->setTx(new_gps->tx_gpio);
_serial_gps->setRx(new_gps->rx_gpio);
_serial_gps->begin(GPS_BAUDRATE);
@@ -1968,6 +2021,9 @@ The Unix epoch (or Unix time or POSIX time or Unix timestamp) is the number of s
t.tm_year = d.year() - 1900;
t.tm_isdst = false;
if (t.tm_mon > -1) {
if (!isPlausibleNmeaTime(t)) {
return false;
}
if (perhapsSetRTC(RTCQualityGPS, t) == RTCSetResultSuccess) {
LOG_DEBUG("NMEA GPS time set %02d-%02d-%02d %02d:%02d:%02d age %d", d.year(), d.month(), t.tm_mday, t.tm_hour,
t.tm_min, t.tm_sec, ti.age());
+1 -2
View File
@@ -174,6 +174,7 @@ class GPS : private concurrency::OSThread
uint32_t lastChecksumFailCount = 0;
uint8_t currentStep = 0;
int32_t currentDelay = 2000;
bool gotTime = false;
#ifndef TINYGPS_OPTION_NO_CUSTOM_FIELDS
// (20210908) TinyGps++ can only read the GPGSA "FIX TYPE" field
@@ -193,8 +194,6 @@ class GPS : private concurrency::OSThread
bool hasProbeCache = false;
// Ensures cached probe is attempted once per boot.
bool triedProbeCache = false;
// Latched when cached presence check fails
bool cachedProbeFailedThisBoot = false;
/**
* hasValidLocation - indicates that the position variables contain a complete
+89 -14
View File
@@ -31,13 +31,63 @@ static uint32_t
timeStartMsec; // Once we have a GPS lock, this is where we hold the initial msec clock that corresponds to that time
static uint64_t zeroOffsetSecs; // GPS based time in secs since 1970 - only updated once on initial lock
#ifdef PIO_UNIT_TESTING
// Test seam: unit tests can inject a fake system clock (e.g. the uptime seconds that
// gettimeofday() returns on boards without a real RTC, like RP2040) and force readFromRTC()
// down the no-hardware-RTC fallback even when a hardware-RTC branch is compiled in.
static bool hasMockSystemTime = false;
static bool forceSystemTimeFallback = false;
static struct timeval mockSystemTime = {};
#endif
// Reads the platform system clock (or the injected mock during unit tests). Used only by the
// no-hardware-RTC fallback below, so it may be unused on builds with a hardware RTC.
[[maybe_unused]] static bool readSystemTime(struct timeval *tv)
{
#ifdef PIO_UNIT_TESTING
if (hasMockSystemTime) {
*tv = mockSystemTime;
return true;
}
#endif
return gettimeofday(tv, NULL) == 0;
}
// Seeds the clock from the system time on boards without a hardware RTC. gettimeofday() can
// return uptime rather than wall-clock time there (e.g. RP2040), so only adopt it when we have
// nothing better yet -- never clobber a higher-quality GPS/NTP/phone source (issue #9828).
[[maybe_unused]] static RTCSetResult readFromSystemTimeFallback()
{
struct timeval tv;
if (readSystemTime(&tv)) {
uint32_t now = millis();
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
if (currentQuality == RTCQualityNone) {
LOG_DEBUG("Seed time from system clock: %lu", (unsigned long)printableEpoch);
timeStartMsec = now;
zeroOffsetSecs = tv.tv_sec;
} else {
LOG_DEBUG("Ignore system clock fallback (%lu); current RTC quality is %s", (unsigned long)printableEpoch,
RtcName(currentQuality));
}
return RTCSetResultSuccess;
}
return RTCSetResultNotSet;
}
/**
* Reads the current date and time from the RTC module and updates the system time.
* @return True if the RTC was successfully read and the system time was updated, false otherwise.
* Reads date/time from the RTC module (or system-time fallback) and seeds internal timekeeping.
* @return RTCSetResultSuccess if a time source was read successfully (even if an existing higher-quality time is retained).
*/
RTCSetResult readFromRTC()
{
struct timeval tv; /* btw settimeofday() is helpful here too*/
#ifdef PIO_UNIT_TESTING
if (forceSystemTimeFallback) {
return readFromSystemTimeFallback();
}
#endif
[[maybe_unused]] struct timeval tv; /* btw settimeofday() is helpful here too*/
#ifdef RV3028_RTC
if (rtc_found.address == RV3028_RTC) {
uint32_t now = millis();
@@ -162,14 +212,7 @@ RTCSetResult readFromRTC()
}
}
#else
if (!gettimeofday(&tv, NULL)) {
uint32_t now = millis();
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
LOG_DEBUG("Read RTC time as %ld", printableEpoch);
timeStartMsec = now;
zeroOffsetSecs = tv.tv_sec;
return RTCSetResultSuccess;
}
return readFromSystemTimeFallback();
#endif
return RTCSetResultNotSet;
}
@@ -219,8 +262,8 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
} else if (q == RTCQualityGPS) {
shouldSet = true;
LOG_DEBUG("Reapply GPS time: %ld secs", printableEpoch);
} else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (12 * 60 * 60 * 1000UL))) {
// Every 12 hrs we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift
} else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (30 * 60 * 1000UL))) {
// Every 30 minutes we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift
shouldSet = true;
LOG_DEBUG("Reapply external time to correct clock drift %ld secs", printableEpoch);
} else {
@@ -292,7 +335,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
LOG_WARN("Failed to set time for RX8130CE");
}
}
#elif defined(ARCH_ESP32)
#elif defined(ARCH_ESP32) || defined(ARCH_RP2040)
settimeofday(tv, NULL);
#endif
@@ -423,6 +466,38 @@ void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot)
lastSetFromPhoneNtpOrGps = 0;
lastTimeValidationWarning = 0;
}
void clearRTCSystemTimeForTests()
{
hasMockSystemTime = false;
mockSystemTime = {};
}
void setRTCSystemTimeForTests(const struct timeval *tv)
{
if (tv == NULL) {
clearRTCSystemTimeForTests();
return;
}
mockSystemTime = *tv;
hasMockSystemTime = true;
}
void setReadFromRTCUseSystemTimeForTests(bool enabled)
{
forceSystemTimeFallback = enabled;
}
void resetRTCStateForTests()
{
currentQuality = RTCQualityNone;
timeStartMsec = 0;
zeroOffsetSecs = 0;
lastSetFromPhoneNtpOrGps = 0;
lastTimeValidationWarning = 0;
setReadFromRTCUseSystemTimeForTests(false);
clearRTCSystemTimeForTests();
}
#endif
time_t gm_mktime(const struct tm *tm)
+4
View File
@@ -56,6 +56,10 @@ RTCSetResult readFromRTC();
#ifdef PIO_UNIT_TESTING
void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot);
void resetRTCStateForTests();
void setRTCSystemTimeForTests(const struct timeval *tv);
void clearRTCSystemTimeForTests();
void setReadFromRTCUseSystemTimeForTests(bool enabled);
#endif
time_t gm_mktime(const struct tm *tm);
+312 -28
View File
@@ -39,6 +39,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "draw/NodeListRenderer.h"
#include "draw/NotificationRenderer.h"
#include "draw/UIRenderer.h"
#include "graphics/fonts/OLEDDisplayFontsZH.h"
#include "graphics/TFTColorRegions.h"
#include "modules/CannedMessageModule.h"
#include "security/LockdownDisplay.h"
@@ -60,6 +61,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "graphics/TFTPalette.h"
#include "graphics/emotes.h"
#include "graphics/images.h"
#include "graphics/l10n/Strings.h"
#include "input/TouchScreenImpl1.h"
#include "main.h"
#include "mesh-pb-constants.h"
@@ -140,10 +142,11 @@ static void drawLockdownLockScreenIntoBuffer(OLEDDisplay *display)
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->setFont(FONT_LARGE);
display->drawString(w / 2, h / 2 - FONT_HEIGHT_LARGE, "LOCKED");
display->drawString(w / 2, h / 2 - FONT_HEIGHT_LARGE, meshtastic::l10n::getString(meshtastic::l10n::StringKey::LOCKED));
display->setFont(FONT_SMALL);
char status[32] = "Connect to unlock";
char status[32];
strncpy(status, meshtastic::l10n::getString(meshtastic::l10n::StringKey::ConnectToUnlock), 31);
if (powerStatus && powerStatus->getHasBattery()) {
int pct = powerStatus->getBatteryChargePercent();
snprintf(status, sizeof(status), "Battery %d%%", pct);
@@ -233,6 +236,16 @@ static inline float wrapHeading360(float heading)
return heading;
}
static inline float wrapDelta180(float delta)
{
if (delta > 180.0f) {
delta -= 360.0f;
} else if (delta < -180.0f) {
delta += 360.0f;
}
return delta;
}
void Screen::setHeading(float heading)
{
const float wrappedHeading = wrapHeading360(heading);
@@ -244,37 +257,30 @@ void Screen::setHeading(float heading)
}
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
float delta = wrappedHeading - compassHeading;
if (delta > 180.0f) {
delta -= 360.0f;
} else if (delta < -180.0f) {
delta += 360.0f;
}
float delta = wrapDelta180(wrappedHeading - compassHeading);
// Adaptive filtering:
// - Strong damping for tiny deltas (jitter)
// - Faster response for larger turns
const float absDelta = (delta >= 0.0f) ? delta : -delta;
if (absDelta < 1.0f) {
return;
}
if (absDelta >= 1.0f) {
float alpha = 0.35f;
if (absDelta > 25.0f) {
alpha = 0.85f;
} else if (absDelta > 10.0f) {
alpha = 0.65f;
}
float alpha = 0.35f;
if (absDelta > 25.0f) {
alpha = 0.85f;
} else if (absDelta > 10.0f) {
alpha = 0.65f;
}
float step = delta * alpha;
const float maxStep = 12.0f;
if (step > maxStep) {
step = maxStep;
} else if (step < -maxStep) {
step = -maxStep;
}
float step = delta * alpha;
const float maxStep = 12.0f;
if (step > maxStep) {
step = maxStep;
} else if (step < -maxStep) {
step = -maxStep;
compassHeading = wrapHeading360(compassHeading + step);
}
compassHeading = wrapHeading360(compassHeading + step);
}
// ==============================
@@ -728,7 +734,10 @@ void Screen::setup()
// Enable display rendering
useDisplay = true;
// Load saved brightness from UI config
// Set the runtime display language from persisted config
meshtastic::l10n::currentDisplayLanguage = uiconfig.language;
// Load brightness from UI config
// For OLED displays (SSD1306), default brightness is 255 if not set
if (uiconfig.screen_brightness == 0) {
#if defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107)
@@ -816,6 +825,9 @@ void Screen::setup()
// Enable UTF-8 to display mapping
dispdev->setFontTableLookupFunction(customFontTableLookup);
// Load CJK (Chinese) font if language requires it
loadCJKFontIfNeeded();
#ifdef USERPREFS_OEM_TEXT
logo_timeout *= 2; // Give more time for branded boot logos
#endif
@@ -825,7 +837,7 @@ void Screen::setup()
alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) {
#ifdef ARCH_ESP32
if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1)
graphics::UIRenderer::drawFrameText(display, state, x, y, "Resuming...");
graphics::UIRenderer::drawFrameText(display, state, x, y, meshtastic::l10n::getString(meshtastic::l10n::StringKey::Resuming));
else
#endif
{
@@ -982,6 +994,13 @@ int32_t Screen::runOnce()
return RUN_SAME;
}
// Detect language changes from remote config (phone app)
if (uiconfig.language != meshtastic::l10n::currentDisplayLanguage) {
meshtastic::l10n::currentDisplayLanguage = uiconfig.language;
loadCJKFontIfNeeded();
setFrames(FOCUS_PRESERVE);
}
if (displayHeight == 0) {
displayHeight = dispdev->getHeight();
}
@@ -1049,7 +1068,7 @@ int32_t Screen::runOnce()
}
#endif
if (!NotificationRenderer::isOverlayBannerShowing() && rebootAtMsec != 0 && !suppressRebootBanner) {
showSimpleBanner("Rebooting...", 0);
showSimpleBanner(meshtastic::l10n::getString(meshtastic::l10n::StringKey::Rebooting), 0);
}
// Process incoming commands.
@@ -2207,6 +2226,271 @@ bool Screen::isOverlayBannerShowing()
return NotificationRenderer::isOverlayBannerShowing();
}
char Screen::customFontTableLookup(const uint8_t ch)
{
static uint8_t LASTCHAR;
static bool SKIPREST;
static int8_t cjkRemaining = 0;
if (cjkRemaining > 0) {
cjkRemaining--;
LASTCHAR = ch;
return (uint8_t)0;
}
if (ch < 128) {
LASTCHAR = 0;
SKIPREST = false;
cjkRemaining = 0;
return ch;
}
uint8_t last = LASTCHAR;
LASTCHAR = ch;
switch (last) {
case 0xC2: {
SKIPREST = false;
return (uint8_t)ch;
}
case 0xC3: {
SKIPREST = false;
return (uint8_t)(ch | 0xC0);
}
}
if (ch == 0xC2 || ch == 0xC3)
return (uint8_t)0;
#if defined(OLED_PL)
switch (last) {
case 0xC3: {
if (ch == 147)
return (uint8_t)(ch);
else if (ch == 179)
return (uint8_t)(148);
else
return (uint8_t)(ch | 0xC0);
break;
}
case 0xC4: {
SKIPREST = false;
return (uint8_t)(ch);
}
case 0xC5: {
SKIPREST = false;
if (ch == 132)
return (uint8_t)(136);
else if (ch == 186)
return (uint8_t)(137);
else
return (uint8_t)(ch);
break;
}
}
if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5)
return (uint8_t)0;
#endif
#if defined(OLED_UA) || defined(OLED_RU)
switch (last) {
case 0xC3: {
SKIPREST = false;
return (uint8_t)(ch | 0xC0);
}
case 0xD0: {
SKIPREST = false;
if (ch == 132)
return (uint8_t)(170);
if (ch == 134)
return (uint8_t)(178);
if (ch == 135)
return (uint8_t)(175);
if (ch == 129)
return (uint8_t)(168);
if (ch > 143 && ch < 192)
return (uint8_t)(ch + 48);
break;
}
case 0xD1: {
SKIPREST = false;
if (ch == 148)
return (uint8_t)(186);
if (ch == 150)
return (uint8_t)(179);
if (ch == 151)
return (uint8_t)(191);
if (ch == 145)
return (uint8_t)(184);
if (ch > 127 && ch < 144)
return (uint8_t)(ch + 112);
break;
}
case 0xD2: {
SKIPREST = false;
if (ch == 144)
return (uint8_t)(165);
if (ch == 145)
return (uint8_t)(180);
break;
}
}
if (ch == 0xC2 || ch == 0xC3 || ch == 0x82 || ch == 0xD0 || ch == 0xD1)
return (uint8_t)0;
#endif
#if defined(OLED_CS)
switch (last) {
case 0xC2: {
SKIPREST = false;
return (uint8_t)ch;
}
case 0xC3: {
SKIPREST = false;
return (uint8_t)(ch | 0xC0);
}
case 0xC4: {
SKIPREST = false;
if (ch == 140)
return (uint8_t)(129);
if (ch == 141)
return (uint8_t)(138);
if (ch == 142)
return (uint8_t)(130);
if (ch == 143)
return (uint8_t)(139);
if (ch == 154)
return (uint8_t)(131);
if (ch == 155)
return (uint8_t)(140);
if (ch == 185)
return (uint8_t)(147);
if (ch == 186)
return (uint8_t)(148);
if (ch == 189)
return (uint8_t)(149);
if (ch == 190)
return (uint8_t)(150);
break;
}
case 0xC5: {
SKIPREST = false;
if (ch == 135)
return (uint8_t)(132);
if (ch == 136)
return (uint8_t)(141);
if (ch == 152)
return (uint8_t)(133);
if (ch == 153)
return (uint8_t)(142);
if (ch == 160)
return (uint8_t)(134);
if (ch == 161)
return (uint8_t)(143);
if (ch == 164)
return (uint8_t)(135);
if (ch == 165)
return (uint8_t)(144);
if (ch == 174)
return (uint8_t)(136);
if (ch == 175)
return (uint8_t)(145);
if (ch == 189)
return (uint8_t)(137);
if (ch == 190)
return (uint8_t)(146);
if (ch == 148)
return (uint8_t)(151);
if (ch == 149)
return (uint8_t)(152);
break;
}
}
if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5)
return (uint8_t)0;
#endif
#if defined(OLED_GR)
switch (last) {
case 0xC3: {
SKIPREST = false;
return (uint8_t)(ch | 0xC0);
}
case 0xCE: {
SKIPREST = false;
if (ch >= 145 && ch <= 161)
return (uint8_t)(ch + 48);
else if (ch >= 163 && ch <= 169)
return (uint8_t)(ch + 48);
else if (ch >= 177 && ch <= 193)
return (uint8_t)(ch + 48);
break;
}
case 0xCF: {
SKIPREST = false;
if (ch >= 130 && ch <= 137)
return (uint8_t)(ch + 112);
break;
}
}
if (ch == 0xC2 || ch == 0xC3 || ch == 0xCE || ch == 0xCF)
return (uint8_t)0;
#endif
if (ch >= 0xE2 && ch <= 0xEF) {
cjkRemaining = 2;
return (uint8_t)0;
}
if (SKIPREST)
return (uint8_t)0;
SKIPREST = true;
return (uint8_t)191;
}
void Screen::drawTextL10n(int16_t x, int16_t y, const char *text, OLEDDISPLAY_TEXT_ALIGNMENT alignment)
{
if (!dispdev || !text)
return;
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014)
if (meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_SIMPLIFIED_CHINESE ||
meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_TRADITIONAL_CHINESE) {
TFTDisplay *tftDisp = static_cast<TFTDisplay *>(dispdev);
if (tftDisp->hasCJKFont()) {
tftDisp->drawStringCJK(x, y, text);
return;
}
}
#endif
dispdev->setTextAlignment(alignment);
dispdev->drawString(x, y, text);
}
void Screen::setFontL10n(const uint8_t *fontData)
{
if (!dispdev)
return;
dispdev->setFont(fontData);
}
void Screen::loadCJKFontIfNeeded()
{
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014)
if (meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_SIMPLIFIED_CHINESE ||
meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_TRADITIONAL_CHINESE) {
TFTDisplay *tftDisp = static_cast<TFTDisplay *>(dispdev);
if (!tftDisp->hasCJKFont()) {
tftDisp->loadCJKFont(FontZH_CN_16, FontZH_CN_16_size);
}
}
#endif
}
} // namespace graphics
#else
+5 -247
View File
@@ -366,255 +366,13 @@ class Screen : public concurrency::OSThread
enqueueCmd(ScreenCmd{.cmd = Cmd::NOOP});
}
/// Overrides the default utf8 character conversion, to replace empty space with question marks
static char customFontTableLookup(const uint8_t ch)
{
// UTF-8 to font table index converter
// Code from http://playground.arduino.cc/Main/Utf8ascii
static uint8_t LASTCHAR;
static bool SKIPREST; // Only display a single unconvertable-character symbol per sequence of unconvertable characters
static char customFontTableLookup(const uint8_t ch);
if (ch < 128) { // Standard ASCII-set 0..0x7F handling
LASTCHAR = 0;
SKIPREST = false;
return ch;
}
void drawTextL10n(int16_t x, int16_t y, const char *text, OLEDDISPLAY_TEXT_ALIGNMENT alignment = TEXT_ALIGN_LEFT);
void setFontL10n(const uint8_t *fontData);
uint8_t last = LASTCHAR; // get last char
LASTCHAR = ch;
switch (last) {
case 0xC2: {
SKIPREST = false;
return (uint8_t)ch;
}
case 0xC3: {
SKIPREST = false;
return (uint8_t)(ch | 0xC0);
}
}
// We want to strip out prefix chars for two-byte char formats
if (ch == 0xC2 || ch == 0xC3)
return (uint8_t)0;
#if defined(OLED_PL)
switch (last) {
case 0xC3: {
if (ch == 147)
return (uint8_t)(ch); // Ó
else if (ch == 179)
return (uint8_t)(148); // ó
else
return (uint8_t)(ch | 0xC0);
break;
}
case 0xC4: {
SKIPREST = false;
return (uint8_t)(ch);
}
case 0xC5: {
SKIPREST = false;
if (ch == 132)
return (uint8_t)(136); // ń
else if (ch == 186)
return (uint8_t)(137); // ź
else
return (uint8_t)(ch);
break;
}
}
// We want to strip out prefix chars for two-byte char formats
if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5)
return (uint8_t)0;
#endif
#if defined(OLED_UA) || defined(OLED_RU)
switch (last) {
case 0xC3: {
SKIPREST = false;
return (uint8_t)(ch | 0xC0);
}
// map UTF-8 cyrillic chars to it Windows-1251 (CP-1251) ASCII codes
// note: in this case we must use compatible font - provided ArialMT_Plain_10/16/24 by 'ThingPulse/esp8266-oled-ssd1306'
// library have empty chars for non-latin ASCII symbols
case 0xD0: {
SKIPREST = false;
if (ch == 132)
return (uint8_t)(170); // Є
if (ch == 134)
return (uint8_t)(178); // І
if (ch == 135)
return (uint8_t)(175); // Ї
if (ch == 129)
return (uint8_t)(168); // Ё
if (ch > 143 && ch < 192)
return (uint8_t)(ch + 48);
break;
}
case 0xD1: {
SKIPREST = false;
if (ch == 148)
return (uint8_t)(186); // є
if (ch == 150)
return (uint8_t)(179); // і
if (ch == 151)
return (uint8_t)(191); // ї
if (ch == 145)
return (uint8_t)(184); // ё
if (ch > 127 && ch < 144)
return (uint8_t)(ch + 112);
break;
}
case 0xD2: {
SKIPREST = false;
if (ch == 144)
return (uint8_t)(165); // Ґ
if (ch == 145)
return (uint8_t)(180); // ґ
break;
}
}
// We want to strip out prefix chars for two-byte char formats
if (ch == 0xC2 || ch == 0xC3 || ch == 0x82 || ch == 0xD0 || ch == 0xD1)
return (uint8_t)0;
#endif
#if defined(OLED_CS)
switch (last) {
case 0xC2: {
SKIPREST = false;
return (uint8_t)ch;
}
case 0xC3: {
SKIPREST = false;
return (uint8_t)(ch | 0xC0);
}
case 0xC4: {
SKIPREST = false;
if (ch == 140)
return (uint8_t)(129); // Č
if (ch == 141)
return (uint8_t)(138); // č
if (ch == 142)
return (uint8_t)(130); // Ď
if (ch == 143)
return (uint8_t)(139); // ď
if (ch == 154)
return (uint8_t)(131); // Ě
if (ch == 155)
return (uint8_t)(140); // ě
// Slovak specific glyphs
if (ch == 185)
return (uint8_t)(147); // Ĺ
if (ch == 186)
return (uint8_t)(148); // ĺ
if (ch == 189)
return (uint8_t)(149); // Ľ
if (ch == 190)
return (uint8_t)(150); // ľ
break;
}
case 0xC5: {
SKIPREST = false;
if (ch == 135)
return (uint8_t)(132); // Ň
if (ch == 136)
return (uint8_t)(141); // ň
if (ch == 152)
return (uint8_t)(133); // Ř
if (ch == 153)
return (uint8_t)(142); // ř
if (ch == 160)
return (uint8_t)(134); // Š
if (ch == 161)
return (uint8_t)(143); // š
if (ch == 164)
return (uint8_t)(135); // Ť
if (ch == 165)
return (uint8_t)(144); // ť
if (ch == 174)
return (uint8_t)(136); // Ů
if (ch == 175)
return (uint8_t)(145); // ů
if (ch == 189)
return (uint8_t)(137); // Ž
if (ch == 190)
return (uint8_t)(146); // ž
// Slovak specific glyphs
if (ch == 148)
return (uint8_t)(151); // Ŕ
if (ch == 149)
return (uint8_t)(152); // ŕ
break;
}
}
// We want to strip out prefix chars for two-byte char formats
if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5)
return (uint8_t)0;
#endif
#if defined(OLED_GR)
switch (last) {
case 0xC3: {
SKIPREST = false;
return (uint8_t)(ch | 0xC0);
}
// Map UTF-8 Greek chars to Windows-1253 (CP-1253) ASCII codes
case 0xCE: {
SKIPREST = false;
// Uppercase Greek: Α-Ρ (U+0391-U+03A1) -> CP-1253 193-209
if (ch >= 145 && ch <= 161)
return (uint8_t)(ch + 48);
// Uppercase Greek: Σ-Ω (U+03A3-U+03A9) -> CP-1253 211-217
else if (ch >= 163 && ch <= 169)
return (uint8_t)(ch + 48);
// Lowercase Greek: α-ρ (U+03B1-U+03C1) -> CP-1253 225-241
else if (ch >= 177 && ch <= 193)
return (uint8_t)(ch + 48);
break;
}
case 0xCF: {
SKIPREST = false;
// Lowercase Greek: ς-ω (U+03C2-U+03C9) -> CP-1253 242-249
if (ch >= 130 && ch <= 137)
return (uint8_t)(ch + 112);
break;
}
}
// We want to strip out prefix chars for two-byte Greek char formats
if (ch == 0xC2 || ch == 0xC3 || ch == 0xCE || ch == 0xCF)
return (uint8_t)0;
#endif
// If we already returned an unconvertable-character symbol for this unconvertable-character sequence, return NULs for the
// rest of it
if (SKIPREST)
return (uint8_t)0;
SKIPREST = true;
return (uint8_t)191; // otherwise: return ¿ if character can't be converted (note that the font map we're using doesn't
// stick to standard EASCII codes)
}
// Load CJK font if language requires it (called from setup & on language change)
void loadCJKFontIfNeeded();
/// Returns a handle to the DebugInfo screen.
//
+4
View File
@@ -20,6 +20,10 @@
#include "graphics/fonts/OLEDDisplayFontsGR.h"
#endif
#ifdef OLED_ZH
#include "graphics/fonts/OLEDDisplayFontsZH.h"
#endif
#if (defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(T5_S3_EPAPER_PRO)) && defined(USE_EINK)
#include "graphics/fonts/EinkDisplayFonts.h"
#endif
+33 -4
View File
@@ -1533,8 +1533,7 @@ bool TFTDisplay::hasTouch(void)
{
#ifdef RAK14014
return true;
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
!defined(HELTEC_MESH_NODE_T1)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
return tft->touch() != nullptr;
#else
return false;
@@ -1553,8 +1552,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y)
} else {
return false;
}
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
!defined(HELTEC_MESH_NODE_T1)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
return tft->getTouch(x, y);
#else
return false;
@@ -1638,4 +1636,35 @@ bool TFTDisplay::connect()
return true;
}
bool TFTDisplay::loadCJKFont(const uint8_t *vlwData, uint32_t vlwDataLen)
{
if (!tft || !vlwData || vlwDataLen == 0)
return false;
cjkFontLoaded = tft->loadFont(vlwData);
if (cjkFontLoaded) {
LOG_INFO("CJK font loaded (%u bytes)", vlwDataLen);
} else {
LOG_WARN("Failed to load CJK font");
}
return cjkFontLoaded;
}
void TFTDisplay::drawStringCJK(int16_t x, int16_t y, const char *text)
{
if (!tft || !text || !cjkFontLoaded)
return;
concurrency::LockGuard g(spiLock);
uint16_t fgColor = getThemeDefaultOnColor();
tft->setTextColor(fgColor, getThemeDefaultOffColor());
tft->setCursor(x, y);
tft->print(text);
}
int16_t TFTDisplay::getStringWidthCJK(const char *text)
{
if (!tft || !text || !cjkFontLoaded)
return 0;
return tft->textWidth(text);
}
#endif // USE_TFTDISPLAY
+7
View File
@@ -38,6 +38,12 @@ class TFTDisplay : public OLEDDisplay
// Functions for changing display brightness
void setDisplayBrightness(uint8_t);
// CJK (Chinese/Japanese/Korean) text rendering via LovyanGFX
bool loadCJKFont(const uint8_t *vlwData, uint32_t vlwDataLen);
void drawStringCJK(int16_t x, int16_t y, const char *text);
int16_t getStringWidthCJK(const char *text);
bool hasCJKFont() const { return cjkFontLoaded; }
/**
* shim to make the abstraction happy
*
@@ -64,4 +70,5 @@ class TFTDisplay : public OLEDDisplay
uint16_t *linePixelBuffer = nullptr;
uint16_t *repaintChunkBuffer = nullptr;
bool cjkFontLoaded = false;
};
File diff suppressed because it is too large Load Diff
+2
View File
@@ -56,6 +56,7 @@ class menuHandler
DisplayUnits,
MessageBubblesMenu,
ThemeMenu,
LanguagePicker,
HamModeConfirm,
LicensedToNormalConfirm
};
@@ -107,6 +108,7 @@ class menuHandler
static void wifiBaseMenu();
static void wifiToggleMenu();
static void screenOptionsMenu();
static void languagePickerMenu();
static void powerMenu();
static void nodeNameLengthMenu();
static void frameTogglesMenu();
+11 -1
View File
@@ -13,6 +13,7 @@
#include "graphics/SharedUIDisplay.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/l10n/Strings.h"
#include "graphics/TimeFormatters.h"
#include "graphics/emotes.h"
#include "main.h"
@@ -75,6 +76,15 @@ void scrollDown()
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
{
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014) || defined(USE_ST7789) || \
defined(USE_ST7796)
if (meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_SIMPLIFIED_CHINESE ||
meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_TRADITIONAL_CHINESE) {
screen->drawTextL10n(x, y, line.c_str());
return;
}
#endif
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount);
}
@@ -482,7 +492,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
// Still in ALL mode and no messages at all → show placeholder
graphics::drawCommonHeader(display, x, y, titleStr);
didReset = false;
const char *messageString = "No messages";
const char *messageString = meshtastic::l10n::getString(meshtastic::l10n::StringKey::NoMessages);
int center_text = (SCREEN_WIDTH / 2) - (display->getStringWidth(messageString) / 2);
display->drawString(center_text, getTextPositions(display)[2], messageString);
graphics::drawCommonFooter(display, x, y);
+46 -14
View File
@@ -10,6 +10,8 @@
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "graphics/l10n/Strings.h"
#include "graphics/Screen.h"
#include "input/RotaryEncoderInterruptImpl1.h"
#include "input/UpDownInterruptImpl1.h"
#if HAS_BUTTON
@@ -39,6 +41,27 @@ extern std::vector<std::string> functionSymbol;
extern std::string functionSymbolString;
extern bool hasUnreadMessage;
namespace
{
inline bool isCJKLanguage()
{
return meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_SIMPLIFIED_CHINESE ||
meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_TRADITIONAL_CHINESE;
}
inline void drawBannerText(OLEDDisplay *display, int16_t x, int16_t y, const char *text)
{
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014)
if (isCJKLanguage() && ::screen) {
::screen->drawTextL10n(x, y, text);
return;
}
#endif
display->drawString(x, y, text);
}
} // namespace
namespace graphics
{
int bannerSignalBars = -1;
@@ -184,7 +207,7 @@ void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiStat
{
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->setFont(FONT_SMALL);
display->drawString(64 + x, y, "Creating SSL certificate");
display->drawString(64 + x, y, meshtastic::l10n::getString(meshtastic::l10n::StringKey::CreatingSSLCert));
#ifdef ARCH_ESP32
yield();
@@ -193,9 +216,9 @@ void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiStat
display->setFont(FONT_SMALL);
if ((millis() / 1000) % 2) {
display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . .");
display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, meshtastic::l10n::getString(meshtastic::l10n::StringKey::PleaseWait));
} else {
display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ");
display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, meshtastic::l10n::getString(meshtastic::l10n::StringKey::PleaseWait));
}
}
@@ -919,9 +942,12 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
display->setColor(BLACK);
int yOffset = 3;
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
if (isCJKLanguage())
drawBannerText(display, textX, lineY - yOffset, lineBuffer);
else
UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
display->drawString(textX, lineY - yOffset, lineBuffer);
drawBannerText(display, textX, lineY - yOffset, lineBuffer);
}
display->setColor(WHITE);
lineY += (thisLineHeight - 2 - background_yOffset);
@@ -943,9 +969,12 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
int groupStartX = boxLeft + (boxWidth - totalWidth) / 2;
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
if (isCJKLanguage())
drawBannerText(display, groupStartX, lineY, lineBuffer);
else
UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
display->drawString(groupStartX, lineY, lineBuffer);
drawBannerText(display, groupStartX, lineY, lineBuffer);
}
int baseX = groupStartX + textWidth + gap;
@@ -977,9 +1006,12 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
}
} else {
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
if (isCJKLanguage())
drawBannerText(display, textX, lineY, lineBuffer);
else
UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
display->drawString(textX, lineY, lineBuffer);
drawBannerText(display, textX, lineY, lineBuffer);
}
}
lineY += thisLineHeight;
@@ -1009,24 +1041,24 @@ void NotificationRenderer::drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisp
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_MEDIUM);
char tempBuf[24];
snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code);
char tempBuf[40];
snprintf(tempBuf, sizeof(tempBuf), "%s #%d", meshtastic::l10n::getString(meshtastic::l10n::StringKey::CriticalFault), error_code);
display->drawString(0 + x, 0 + y, tempBuf);
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org");
display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, meshtastic::l10n::getString(meshtastic::l10n::StringKey::ForHelpVisit));
}
void NotificationRenderer::drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->setFont(FONT_MEDIUM);
display->drawString(64 + x, y, "Updating");
display->drawString(64 + x, y, meshtastic::l10n::getString(meshtastic::l10n::StringKey::Updating));
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(),
"Please be patient and do not power off.");
meshtastic::l10n::getString(meshtastic::l10n::StringKey::PleaseBePatient));
}
void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state)
+46
View File
@@ -0,0 +1,46 @@
// Chinese font data (VLW format) for OLEDDisplay-based TFT rendering via LovyanGFX.
//
// HOW TO GENERATE FONT DATA:
//
// Option 1: Adafruit GFX fontconvert (simpler, fewer glyphs)
// brew install adafruitnrf52-gfx-fontconvert # macOS
// fontconvert NotoSansSC-Regular.ttf 16 > zh_cn_16.h
// The output is a GFXfont struct. LovyanGFX can use this via ft_gfx type.
//
// Option 2: LovyanGFX VLW format (recommended, more features)
// Use the LovyanGFX font converter script to generate a .vlw file from TTF
// Then embed the .vlw binary as a byte array using xxd:
// xxd -i zh_font_16.vlw > zh_font_16_data.h
//
// Option 3: Use BDF fonts (free, text-based)
// WenQuanYi bitmap fonts: http://wenq.org/
// Convert BDF to VLW using LovyanGFX tools
//
// FONT SIZE GUIDELINES:
// - 16px: Minimum readable Chinese on 320x240 TFT (~20 chars per line)
// - 20px: Better readability, ~16 chars per line
// - 24px: Title/header text, ~13 chars per line
//
// CHARACTER SET RECOMMENDATIONS:
// - GB2312 Level 1: 3755 commonly used simplified Chinese characters
// - Plus ASCII 0x20-0x7E for punctuation and Latin characters
//
// PLACEHOLDER DATA - Replace with generated font binary:
#include "OLEDDisplayFontsZH.h"
// Placeholder font data.
// Replace with actual generated VLW/Gfx font binary from a Chinese TTF file.
// The array below is intentionally minimal - it won't render any characters
// until real font data is provided.
const uint8_t FontZH_CN_16[] = {
// VLW font file magic header would go here
// Format: 0x00 0x00 ...
// Replace with output from: xxd -i your_chinese_font.vlw
};
const uint32_t FontZH_CN_16_size = sizeof(FontZH_CN_16);
const uint8_t FontZH_TW_16[] = {
// Traditional Chinese VLW font data placeholder
};
const uint32_t FontZH_TW_16_size = sizeof(FontZH_TW_16);
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
// Chinese Simplified font - VLW format (to be generated from TTF)
//
// HOW TO GENERATE:
// 1. Download NotoSansSC-Regular.ttf (or any open-source Chinese font)
// 2. Use Adafruit fontconvert tool:
// ./fontconvert NotoSansSC-Regular.ttf 16 > zh_cn_16_pt.h
// 3. Copy the resulting binary data (GFXfont struct bytes) into the array below
//
// For LovyanGFX VLW font format, use:
// - The binary VLW file can be embedded as a const uint8_t[] array
// - Loaded with tft->loadFont(zh_font_data_array)
extern const uint8_t FontZH_CN_16[];
extern const uint32_t FontZH_CN_16_size;
// Chinese Traditional font (VLW format)
extern const uint8_t FontZH_TW_16[];
extern const uint32_t FontZH_TW_16_size;
+20
View File
@@ -0,0 +1,20 @@
#include "Strings.h"
#include "Strings_EN.h"
#include "Strings_ZH.h"
namespace meshtastic
{
namespace l10n
{
meshtastic_Language currentDisplayLanguage = meshtastic_Language_ENGLISH;
const char *getString(StringKey key, meshtastic_Language lang)
{
if (lang == meshtastic_Language_SIMPLIFIED_CHINESE || lang == meshtastic_Language_TRADITIONAL_CHINESE)
return getStringZH(key);
return getStringEN(key);
}
} // namespace l10n
} // namespace meshtastic
+170
View File
@@ -0,0 +1,170 @@
#pragma once
#include "mesh/generated/meshtastic/device_ui.pb.h"
namespace meshtastic
{
namespace l10n
{
enum class StringKey : uint8_t {
Back,
OK,
GotIt,
WelcomeToMeshtastic,
SwipeToNavigate,
ClickToNavigate,
UseSelectButton,
DeviceRole,
RadioPreset,
FrequencySlot,
LoRaRegion,
Client,
ClientMute,
LostAndFound,
Tracker,
Hour12,
Hour24,
ClockAction,
ClockFace,
TimeFormat,
Timezone,
MessageAction,
Reply,
ViewChats,
UnmuteChannel,
MuteChannel,
Delete,
ReadAloud,
Brightness,
FrameVisibility,
DisplayUnits,
MessageBubbles,
Theme,
Reboot,
Shutdown,
SwitchToMUI,
DisplayOptions,
Language,
Chinese,
English,
NoGPS,
GPSoff,
GPSnotPresent,
GPSisDisabled,
Node,
Hop,
USB,
BT_off,
WiFi,
WiFiNotConnected,
WiFiConnected,
SSID,
LoRaInfo,
LoRa,
Role,
Freq,
ChUtil,
System,
Heap,
PSRAM,
Flash,
SD,
Ver,
Up,
ClientApp,
NoConnected,
LOCKED,
ConnectToUnlock,
Battery,
Resuming,
LastHeard,
HopsSig,
HopsSignal,
Nodes,
Distance,
Bearings,
CreatingSSLCert,
PleaseWait,
CriticalFault,
ForHelpVisit,
Updating,
PleaseBePatient,
RebootInProgress,
PowerMenu,
SystemMenu,
ScreenOptions,
WifiToggle,
BluetoothToggle,
NodeDbReset,
ManageNode,
RemoveFavorite,
AddFavorite,
TestMenu,
TraceRoute,
ThrottleMessage,
MessageViewMode,
DeleteMessages,
NodeNameLength,
GPSMenu,
GPSToggle,
GPSFormat,
GPSSmartPosition,
GPSUpdateInterval,
GPSPositionBroadcast,
CompassNorth,
BuzzerMode,
HamModeConfirm,
LicensedToNormal,
KeyVerification,
FinalPrompt,
RegionUnset,
SelectRegionMessage,
Rebooting,
LoRaActions,
Slot0,
WithPreset,
WithFreetext,
DeleteOldest,
DeleteThisChat,
DeleteAll,
DeleteAllChats,
TemporarilyMute,
Unmute,
ToggleBacklight,
SleepScreen,
SendPosition,
SendNodeInfo,
NewPresetMsg,
NewFreetextMsg,
Notifications,
Power,
RebootShutdown,
GoToChat,
NewPreset,
Favorite,
UnmuteNotifications,
MuteNotifications,
UnignoreNode,
IgnoreNode,
NumberPicker,
TestAnnounce,
NodeActionsSettings,
ShowNameLength,
Bluetooth,
NoMessages,
COUNT
};
extern meshtastic_Language currentDisplayLanguage;
const char *getString(StringKey key, meshtastic_Language lang);
inline const char *getString(StringKey key)
{
extern meshtastic_Language currentDisplayLanguage;
return getString(key, currentDisplayLanguage);
}
} // namespace l10n
} // namespace meshtastic
+456
View File
@@ -0,0 +1,456 @@
#pragma once
#include "Strings.h"
namespace meshtastic
{
namespace l10n
{
// English string table
// The strings are stored as const arrays to save RAM on constrained devices.
// Each locale file defines the same set of strings in its own language.
static const char STR_BACK_EN[] = "Back";
static const char STR_OK_EN[] = "OK";
static const char STR_GOT_IT_EN[] = "Got it!";
static const char STR_WELCOME_TO_MESHTASTIC_EN[] = "Welcome to Meshtastic!";
static const char STR_SWIPE_TO_NAVIGATE_EN[] = "Swipe to navigate.";
static const char STR_CLICK_TO_NAVIGATE_EN[] = "Click to navigate.";
static const char STR_USE_SELECT_BUTTON_EN[] = "Use the Select button.";
static const char STR_DEVICE_ROLE_EN[] = "Device Role";
static const char STR_RADIO_PRESET_EN[] = "Radio Preset";
static const char STR_FREQUENCY_SLOT_EN[] = "Frequency Slot";
static const char STR_LORA_REGION_EN[] = "LoRa Region";
static const char STR_CLIENT_EN[] = "Client";
static const char STR_CLIENT_MUTE_EN[] = "Client Mute";
static const char STR_LOST_AND_FOUND_EN[] = "Lost and Found";
static const char STR_TRACKER_EN[] = "Tracker";
static const char STR_12HOUR_EN[] = "12-hour";
static const char STR_24HOUR_EN[] = "24-hour";
static const char STR_CLOCK_ACTION_EN[] = "Clock Action";
static const char STR_CLOCK_FACE_EN[] = "Clock Face";
static const char STR_TIME_FORMAT_EN[] = "Time Format";
static const char STR_TIMEZONE_EN[] = "Timezone";
static const char STR_MESSAGE_ACTION_EN[] = "Message Action";
static const char STR_REPLY_EN[] = "Reply";
static const char STR_VIEW_CHATS_EN[] = "View Chats";
static const char STR_UNMUTE_CHANNEL_EN[] = "Unmute Channel";
static const char STR_MUTE_CHANNEL_EN[] = "Mute Channel";
static const char STR_DELETE_EN[] = "Delete";
static const char STR_READ_ALOUD_EN[] = "Read Aloud";
static const char STR_BRIGHTNESS_EN[] = "Brightness";
static const char STR_FRAME_VISIBILITY_EN[] = "Frame Visibility";
static const char STR_DISPLAY_UNITS_EN[] = "Display Units";
static const char STR_MESSAGE_BUBBLES_EN[] = "Message Bubbles";
static const char STR_THEME_EN[] = "Theme";
static const char STR_REBOOT_EN[] = "Reboot";
static const char STR_SHUTDOWN_EN[] = "Shutdown";
static const char STR_SWITCH_TO_MUI_EN[] = "Switch to MUI";
static const char STR_DISPLAY_OPTIONS_EN[] = "Display Options";
static const char STR_LANGUAGE_EN[] = "Language";
static const char STR_CHINESE_EN[] = "Chinese";
static const char STR_ENGLISH_EN[] = "English";
static const char STR_NO_GPS_EN[] = "No GPS";
static const char STR_GPS_OFF_EN[] = "GPS off";
static const char STR_GPS_NOT_PRESENT_EN[] = "GPS not present";
static const char STR_GPS_IS_DISABLED_EN[] = "GPS is disabled";
static const char STR_NODE_EN[] = "Node";
static const char STR_HOP_EN[] = "Hop:";
static const char STR_USB_EN[] = "USB";
static const char STR_BT_OFF_EN[] = "BT off";
static const char STR_WIFI_EN[] = "WiFi";
static const char STR_WIFI_NOT_CONNECTED_EN[] = "WiFi: Not Connected";
static const char STR_WIFI_CONNECTED_EN[] = "WiFi: Connected";
static const char STR_SSID_EN[] = "SSID:";
static const char STR_LORA_INFO_EN[] = "LoRa Info";
static const char STR_LORA_EN[] = "LoRa";
static const char STR_ROLE_EN[] = "Role:";
static const char STR_FREQ_EN[] = "Freq:";
static const char STR_CHUTIL_EN[] = "ChUtil";
static const char STR_SYSTEM_EN[] = "System";
static const char STR_HEAP_EN[] = "Heap:";
static const char STR_PSRAM_EN[] = "PSRAM:";
static const char STR_FLASH_EN[] = "Flash:";
static const char STR_SD_EN[] = "SD:";
static const char STR_VER_EN[] = "Ver:";
static const char STR_UP_EN[] = "Up:";
static const char STR_CLIENT_APP_EN[] = "Client";
static const char STR_NO_CONNECTED_EN[] = "No Connected";
static const char STR_LOCKED_EN[] = "LOCKED";
static const char STR_CONNECT_TO_UNLOCK_EN[] = "Connect to unlock";
static const char STR_BATTERY_EN[] = "Battery";
static const char STR_RESUMING_EN[] = "Resuming...";
static const char STR_LAST_HEARD_EN[] = "Last Heard";
static const char STR_HOPS_SIG_EN[] = "Hops/Sig";
static const char STR_HOPS_SIGNAL_EN[] = "Hops/Signal";
static const char STR_NODES_EN[] = "Nodes";
static const char STR_DISTANCE_EN[] = "Distance";
static const char STR_BEARINGS_EN[] = "Bearings";
static const char STR_CREATING_SSL_EN[] = "Creating SSL certificate";
static const char STR_PLEASE_WAIT_EN[] = "Please wait . . .";
static const char STR_CRITICAL_FAULT_EN[] = "Critical fault";
static const char STR_FOR_HELP_VISIT_EN[] = "For help, please visit";
static const char STR_UPDATING_EN[] = "Updating";
static const char STR_PLEASE_BE_PATIENT_EN[] = "Please be patient and do not power off.";
static const char STR_REBOOT_IN_PROGRESS_EN[] = "Reboot in progress...";
static const char STR_POWER_MENU_EN[] = "Power";
static const char STR_SYSTEM_MENU_EN[] = "System";
static const char STR_SCREEN_OPTIONS_EN[] = "Screen";
static const char STR_WIFI_TOGGLE_EN[] = "WiFi";
static const char STR_BLUETOOTH_TOGGLE_EN[] = "Bluetooth";
static const char STR_NODE_DB_RESET_EN[] = "Reset NodeDB";
static const char STR_MANAGE_NODE_EN[] = "Manage Node";
static const char STR_REMOVE_FAVORITE_EN[] = "Remove Favorite";
static const char STR_ADD_FAVORITE_EN[] = "Add Favorite";
static const char STR_TEST_MENU_EN[] = "Test";
static const char STR_TRACE_ROUTE_EN[] = "Trace Route";
static const char STR_THROTTLE_MSG_EN[] = "Message Throttled";
static const char STR_MSG_VIEW_MODE_EN[] = "View Mode";
static const char STR_DELETE_MESSAGES_EN[] = "Delete Messages";
static const char STR_NODE_NAME_LENGTH_EN[] = "Name Length";
static const char STR_GPS_MENU_EN[] = "GPS";
static const char STR_GPS_TOGGLE_EN[] = "GPS Toggle";
static const char STR_GPS_FORMAT_EN[] = "GPS Format";
static const char STR_GPS_SMART_POS_EN[] = "Smart Position";
static const char STR_GPS_UPDATE_INTERVAL_EN[] = "Update Interval";
static const char STR_GPS_POS_BCAST_EN[] = "Position Broadcast";
static const char STR_COMPASS_NORTH_EN[] = "Compass North";
static const char STR_BUZZER_MODE_EN[] = "Buzzer";
static const char STR_HAM_MODE_EN[] = "Ham Mode";
static const char STR_LICENSED_NORMAL_EN[] = "Normal Mode";
static const char STR_KEY_VERIFY_EN[] = "Verify Key";
static const char STR_REGION_UNSET_EN[] = "Select a Region";
static const char STR_SELECT_REGION_EN[] = "Please select your LoRa region:";
static const char STR_REBOOTING_EN[] = "Rebooting...";
static const char STR_LORA_ACTIONS_EN[] = "LoRa Actions";
static const char STR_SLOT0_EN[] = "Slot 0 (Auto)";
static const char STR_WITH_PRESET_EN[] = "With Preset";
static const char STR_WITH_FREETEXT_EN[] = "With Freetext";
static const char STR_DELETE_OLDEST_EN[] = "Delete Oldest";
static const char STR_DELETE_THIS_CHAT_EN[] = "Delete This Chat";
static const char STR_DELETE_ALL_EN[] = "Delete All";
static const char STR_DELETE_ALL_CHATS_EN[] = "Delete All Chats";
static const char STR_TEMPORARILY_MUTE_EN[] = "Temporarily Mute";
static const char STR_UNMUTE_EN[] = "Unmute";
static const char STR_TOGGLE_BACKLIGHT_EN[] = "Toggle Backlight";
static const char STR_SLEEP_SCREEN_EN[] = "Sleep Screen";
static const char STR_SEND_POSITION_EN[] = "Send Position";
static const char STR_SEND_NODE_INFO_EN[] = "Send Node Info";
static const char STR_NEW_PRESET_MSG_EN[] = "New Preset Msg";
static const char STR_NEW_FREETEXT_MSG_EN[] = "New Freetext Msg";
static const char STR_NOTIFICATIONS_EN[] = "Notifications";
static const char STR_POWER_EN[] = "Power";
static const char STR_REBOOT_SHUTDOWN_EN[] = "Reboot/Shutdown";
static const char STR_GO_TO_CHAT_EN[] = "Go To Chat";
static const char STR_NEW_PRESET_EN[] = "New Preset";
static const char STR_FAVORITE_EN[] = "Favorite";
static const char STR_UNMUTE_NOTIFICATIONS_EN[] = "Unmute Notifications";
static const char STR_MUTE_NOTIFICATIONS_EN[] = "Mute Notifications";
static const char STR_UNIGNORE_NODE_EN[] = "Unignore Node";
static const char STR_IGNORE_NODE_EN[] = "Ignore Node";
static const char STR_NUMBER_PICKER_EN[] = "Number Picker";
static const char STR_TEST_ANNOUNCE_EN[] = "Test Announce";
static const char STR_NODE_ACTIONS_EN[] = "Node Actions / Settings";
static const char STR_SHOW_NAME_LENGTH_EN[] = "Show Long/Short Name";
static const char STR_BLUETOOTH_EN[] = "Bluetooth";
static const char STR_NO_MESSAGES_EN[] = "No messages";
inline const char *getStringEN(StringKey key)
{
switch (key) {
case StringKey::Back:
return STR_BACK_EN;
case StringKey::OK:
return STR_OK_EN;
case StringKey::GotIt:
return STR_GOT_IT_EN;
case StringKey::WelcomeToMeshtastic:
return STR_WELCOME_TO_MESHTASTIC_EN;
case StringKey::SwipeToNavigate:
return STR_SWIPE_TO_NAVIGATE_EN;
case StringKey::ClickToNavigate:
return STR_CLICK_TO_NAVIGATE_EN;
case StringKey::UseSelectButton:
return STR_USE_SELECT_BUTTON_EN;
case StringKey::DeviceRole:
return STR_DEVICE_ROLE_EN;
case StringKey::RadioPreset:
return STR_RADIO_PRESET_EN;
case StringKey::FrequencySlot:
return STR_FREQUENCY_SLOT_EN;
case StringKey::LoRaRegion:
return STR_LORA_REGION_EN;
case StringKey::Client:
return STR_CLIENT_EN;
case StringKey::ClientMute:
return STR_CLIENT_MUTE_EN;
case StringKey::LostAndFound:
return STR_LOST_AND_FOUND_EN;
case StringKey::Tracker:
return STR_TRACKER_EN;
case StringKey::Hour12:
return STR_12HOUR_EN;
case StringKey::Hour24:
return STR_24HOUR_EN;
case StringKey::ClockAction:
return STR_CLOCK_ACTION_EN;
case StringKey::ClockFace:
return STR_CLOCK_FACE_EN;
case StringKey::TimeFormat:
return STR_TIME_FORMAT_EN;
case StringKey::Timezone:
return STR_TIMEZONE_EN;
case StringKey::MessageAction:
return STR_MESSAGE_ACTION_EN;
case StringKey::Reply:
return STR_REPLY_EN;
case StringKey::ViewChats:
return STR_VIEW_CHATS_EN;
case StringKey::UnmuteChannel:
return STR_UNMUTE_CHANNEL_EN;
case StringKey::MuteChannel:
return STR_MUTE_CHANNEL_EN;
case StringKey::Delete:
return STR_DELETE_EN;
case StringKey::ReadAloud:
return STR_READ_ALOUD_EN;
case StringKey::Brightness:
return STR_BRIGHTNESS_EN;
case StringKey::FrameVisibility:
return STR_FRAME_VISIBILITY_EN;
case StringKey::DisplayUnits:
return STR_DISPLAY_UNITS_EN;
case StringKey::MessageBubbles:
return STR_MESSAGE_BUBBLES_EN;
case StringKey::Theme:
return STR_THEME_EN;
case StringKey::Reboot:
return STR_REBOOT_EN;
case StringKey::Shutdown:
return STR_SHUTDOWN_EN;
case StringKey::SwitchToMUI:
return STR_SWITCH_TO_MUI_EN;
case StringKey::DisplayOptions:
return STR_DISPLAY_OPTIONS_EN;
case StringKey::Language:
return STR_LANGUAGE_EN;
case StringKey::Chinese:
return STR_CHINESE_EN;
case StringKey::English:
return STR_ENGLISH_EN;
case StringKey::NoGPS:
return STR_NO_GPS_EN;
case StringKey::GPSoff:
return STR_GPS_OFF_EN;
case StringKey::GPSnotPresent:
return STR_GPS_NOT_PRESENT_EN;
case StringKey::GPSisDisabled:
return STR_GPS_IS_DISABLED_EN;
case StringKey::Node:
return STR_NODE_EN;
case StringKey::Hop:
return STR_HOP_EN;
case StringKey::USB:
return STR_USB_EN;
case StringKey::BT_off:
return STR_BT_OFF_EN;
case StringKey::WiFi:
return STR_WIFI_EN;
case StringKey::WiFiNotConnected:
return STR_WIFI_NOT_CONNECTED_EN;
case StringKey::WiFiConnected:
return STR_WIFI_CONNECTED_EN;
case StringKey::SSID:
return STR_SSID_EN;
case StringKey::LoRaInfo:
return STR_LORA_INFO_EN;
case StringKey::LoRa:
return STR_LORA_EN;
case StringKey::Role:
return STR_ROLE_EN;
case StringKey::Freq:
return STR_FREQ_EN;
case StringKey::ChUtil:
return STR_CHUTIL_EN;
case StringKey::System:
return STR_SYSTEM_EN;
case StringKey::Heap:
return STR_HEAP_EN;
case StringKey::PSRAM:
return STR_PSRAM_EN;
case StringKey::Flash:
return STR_FLASH_EN;
case StringKey::SD:
return STR_SD_EN;
case StringKey::Ver:
return STR_VER_EN;
case StringKey::Up:
return STR_UP_EN;
case StringKey::ClientApp:
return STR_CLIENT_APP_EN;
case StringKey::NoConnected:
return STR_NO_CONNECTED_EN;
case StringKey::LOCKED:
return STR_LOCKED_EN;
case StringKey::ConnectToUnlock:
return STR_CONNECT_TO_UNLOCK_EN;
case StringKey::Battery:
return STR_BATTERY_EN;
case StringKey::Resuming:
return STR_RESUMING_EN;
case StringKey::LastHeard:
return STR_LAST_HEARD_EN;
case StringKey::HopsSig:
return STR_HOPS_SIG_EN;
case StringKey::HopsSignal:
return STR_HOPS_SIGNAL_EN;
case StringKey::Nodes:
return STR_NODES_EN;
case StringKey::Distance:
return STR_DISTANCE_EN;
case StringKey::Bearings:
return STR_BEARINGS_EN;
case StringKey::CreatingSSLCert:
return STR_CREATING_SSL_EN;
case StringKey::PleaseWait:
return STR_PLEASE_WAIT_EN;
case StringKey::CriticalFault:
return STR_CRITICAL_FAULT_EN;
case StringKey::ForHelpVisit:
return STR_FOR_HELP_VISIT_EN;
case StringKey::Updating:
return STR_UPDATING_EN;
case StringKey::PleaseBePatient:
return STR_PLEASE_BE_PATIENT_EN;
case StringKey::RebootInProgress:
return STR_REBOOT_IN_PROGRESS_EN;
case StringKey::PowerMenu:
return STR_POWER_MENU_EN;
case StringKey::SystemMenu:
return STR_SYSTEM_MENU_EN;
case StringKey::ScreenOptions:
return STR_SCREEN_OPTIONS_EN;
case StringKey::WifiToggle:
return STR_WIFI_TOGGLE_EN;
case StringKey::BluetoothToggle:
return STR_BLUETOOTH_TOGGLE_EN;
case StringKey::NodeDbReset:
return STR_NODE_DB_RESET_EN;
case StringKey::ManageNode:
return STR_MANAGE_NODE_EN;
case StringKey::RemoveFavorite:
return STR_REMOVE_FAVORITE_EN;
case StringKey::AddFavorite:
return STR_ADD_FAVORITE_EN;
case StringKey::TestMenu:
return STR_TEST_MENU_EN;
case StringKey::TraceRoute:
return STR_TRACE_ROUTE_EN;
case StringKey::ThrottleMessage:
return STR_THROTTLE_MSG_EN;
case StringKey::MessageViewMode:
return STR_MSG_VIEW_MODE_EN;
case StringKey::DeleteMessages:
return STR_DELETE_MESSAGES_EN;
case StringKey::NodeNameLength:
return STR_NODE_NAME_LENGTH_EN;
case StringKey::GPSMenu:
return STR_GPS_MENU_EN;
case StringKey::GPSToggle:
return STR_GPS_TOGGLE_EN;
case StringKey::GPSFormat:
return STR_GPS_FORMAT_EN;
case StringKey::GPSSmartPosition:
return STR_GPS_SMART_POS_EN;
case StringKey::GPSUpdateInterval:
return STR_GPS_UPDATE_INTERVAL_EN;
case StringKey::GPSPositionBroadcast:
return STR_GPS_POS_BCAST_EN;
case StringKey::CompassNorth:
return STR_COMPASS_NORTH_EN;
case StringKey::BuzzerMode:
return STR_BUZZER_MODE_EN;
case StringKey::HamModeConfirm:
return STR_HAM_MODE_EN;
case StringKey::LicensedToNormal:
return STR_LICENSED_NORMAL_EN;
case StringKey::KeyVerification:
return STR_KEY_VERIFY_EN;
case StringKey::RegionUnset:
return STR_REGION_UNSET_EN;
case StringKey::SelectRegionMessage:
return STR_SELECT_REGION_EN;
case StringKey::Rebooting:
return STR_REBOOTING_EN;
case StringKey::LoRaActions:
return STR_LORA_ACTIONS_EN;
case StringKey::Slot0:
return STR_SLOT0_EN;
case StringKey::WithPreset:
return STR_WITH_PRESET_EN;
case StringKey::WithFreetext:
return STR_WITH_FREETEXT_EN;
case StringKey::DeleteOldest:
return STR_DELETE_OLDEST_EN;
case StringKey::DeleteThisChat:
return STR_DELETE_THIS_CHAT_EN;
case StringKey::DeleteAll:
return STR_DELETE_ALL_EN;
case StringKey::DeleteAllChats:
return STR_DELETE_ALL_CHATS_EN;
case StringKey::TemporarilyMute:
return STR_TEMPORARILY_MUTE_EN;
case StringKey::Unmute:
return STR_UNMUTE_EN;
case StringKey::ToggleBacklight:
return STR_TOGGLE_BACKLIGHT_EN;
case StringKey::SleepScreen:
return STR_SLEEP_SCREEN_EN;
case StringKey::SendPosition:
return STR_SEND_POSITION_EN;
case StringKey::SendNodeInfo:
return STR_SEND_NODE_INFO_EN;
case StringKey::NewPresetMsg:
return STR_NEW_PRESET_MSG_EN;
case StringKey::NewFreetextMsg:
return STR_NEW_FREETEXT_MSG_EN;
case StringKey::Notifications:
return STR_NOTIFICATIONS_EN;
case StringKey::Power:
return STR_POWER_EN;
case StringKey::RebootShutdown:
return STR_REBOOT_SHUTDOWN_EN;
case StringKey::GoToChat:
return STR_GO_TO_CHAT_EN;
case StringKey::NewPreset:
return STR_NEW_PRESET_EN;
case StringKey::Favorite:
return STR_FAVORITE_EN;
case StringKey::UnmuteNotifications:
return STR_UNMUTE_NOTIFICATIONS_EN;
case StringKey::MuteNotifications:
return STR_MUTE_NOTIFICATIONS_EN;
case StringKey::UnignoreNode:
return STR_UNIGNORE_NODE_EN;
case StringKey::IgnoreNode:
return STR_IGNORE_NODE_EN;
case StringKey::NumberPicker:
return STR_NUMBER_PICKER_EN;
case StringKey::TestAnnounce:
return STR_TEST_ANNOUNCE_EN;
case StringKey::NodeActionsSettings:
return STR_NODE_ACTIONS_EN;
case StringKey::ShowNameLength:
return STR_SHOW_NAME_LENGTH_EN;
case StringKey::Bluetooth:
return STR_BLUETOOTH_EN;
case StringKey::NoMessages:
return STR_NO_MESSAGES_EN;
default:
return "?";
}
}
} // namespace l10n
} // namespace meshtastic
+452
View File
@@ -0,0 +1,452 @@
#pragma once
#include "Strings.h"
namespace meshtastic
{
namespace l10n
{
static const char STR_BACK_ZH[] = "\xe8\xbf\x94\xe5\x9b\x9e";
static const char STR_OK_ZH[] = "\xe7\xa1\xae\xe5\xae\x9a";
static const char STR_GOT_IT_ZH[] = "\xe7\x9f\xa5\xe9\x81\x93\xe4\xba\x86";
static const char STR_WELCOME_TO_MESHTASTIC_ZH[] = "\xe6\xac\xa2\xe8\xbf\x8e\xe4\xbd\xbf\xe7\x94\xa8 Meshtastic\xef\xbc\x81";
static const char STR_SWIPE_TO_NAVIGATE_ZH[] = "\xe6\xbb\x91\xe5\x8a\xa8\xe4\xbb\xa5\xe5\xaf\xbc\xe8\x88\xaa\xe3\x80\x82";
static const char STR_CLICK_TO_NAVIGATE_ZH[] = "\xe7\x82\xb9\xe5\x87\xbb\xe4\xbb\xa5\xe5\xaf\xbc\xe8\x88\xaa\xe3\x80\x82";
static const char STR_USE_SELECT_BUTTON_ZH[] = "\xe4\xbd\xbf\xe7\x94\xa8\xe9\x80\x89\xe6\x8b\xa9\xe6\x8c\x89\xe9\x92\xae\xe3\x80\x82";
static const char STR_DEVICE_ROLE_ZH[] = "\xe8\xae\xbe\xe5\xa4\x87\xe8\xa7\x92\xe8\x89\xb2";
static const char STR_RADIO_PRESET_ZH[] = "\xe6\x97\xa0\xe7\xba\xbf\xe9\xa2\x84\xe8\xae\xbe";
static const char STR_FREQUENCY_SLOT_ZH[] = "\xe9\xa2\x91\xe7\x8e\x87\xe6\xa7\xbd\xe4\xbd\x8d";
static const char STR_LORA_REGION_ZH[] = "LoRa \xe5\x9c\xb0\xe5\x8c\xba";
static const char STR_CLIENT_ZH[] = "\xe5\xae\xa2\xe6\x88\xb7\xe7\xab\xaf";
static const char STR_CLIENT_MUTE_ZH[] = "\xe9\x9d\x99\xe9\x9f\xb3\xe5\xae\xa2\xe6\x88\xb7\xe7\xab\xaf";
static const char STR_LOST_AND_FOUND_ZH[] = "\xe5\xa4\xb1\xe7\x89\xa9\xe6\x8b\x9b\xe9\xa2\x86";
static const char STR_TRACKER_ZH[] = "\xe8\xbf\xbd\xe8\xb8\xaa\xe5\x99\xa8";
static const char STR_12HOUR_ZH[] = "12 \xe5\xb0\x8f\xe6\x97\xb6\xe5\x88\xb6";
static const char STR_24HOUR_ZH[] = "24 \xe5\xb0\x8f\xe6\x97\xb6\xe5\x88\xb6";
static const char STR_CLOCK_ACTION_ZH[] = "\xe6\x97\xb6\xe9\x92\x9f\xe6\x93\x8d\xe4\xbd\x9c";
static const char STR_CLOCK_FACE_ZH[] = "\xe6\x97\xb6\xe9\x92\x9f\xe6\xa0\xb7\xe5\xbc\x8f";
static const char STR_TIME_FORMAT_ZH[] = "\xe6\x97\xb6\xe9\x97\xb4\xe6\xa0\xbc\xe5\xbc\x8f";
static const char STR_TIMEZONE_ZH[] = "\xe6\x97\xb6\xe5\x8c\xba";
static const char STR_MESSAGE_ACTION_ZH[] = "\xe6\xb6\x88\xe6\x81\xaf\xe6\x93\x8d\xe4\xbd\x9c";
static const char STR_REPLY_ZH[] = "\xe5\x9b\x9e\xe5\xa4\x8d";
static const char STR_VIEW_CHATS_ZH[] = "\xe6\x9f\xa5\xe7\x9c\x8b\xe8\x81\x8a\xe5\xa4\xa9";
static const char STR_UNMUTE_CHANNEL_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe9\x9d\x99\xe9\x9f\xb3";
static const char STR_MUTE_CHANNEL_ZH[] = "\xe9\x9d\x99\xe9\x9f\xb3\xe9\xa2\x91\xe9\x81\x93";
static const char STR_DELETE_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4";
static const char STR_READ_ALOUD_ZH[] = "\xe5\xa4\xa7\xe5\xa3\xb0\xe6\x9c\x97\xe8\xaf\xbb";
static const char STR_BRIGHTNESS_ZH[] = "\xe4\xba\xae\xe5\xba\xa6";
static const char STR_FRAME_VISIBILITY_ZH[] = "\xe9\xa1\xb5\xe9\x9d\xa2\xe5\x8f\xaf\xe8\xa7\x81\xe6\x80\xa7";
static const char STR_DISPLAY_UNITS_ZH[] = "\xe6\x98\xbe\xe7\xa4\xba\xe5\x8d\x95\xe4\xbd\x8d";
static const char STR_MESSAGE_BUBBLES_ZH[] = "\xe6\xb6\x88\xe6\x81\xaf\xe6\xb0\x94\xe6\xb3\xa1";
static const char STR_THEME_ZH[] = "\xe4\xb8\xbb\xe9\xa2\x98";
static const char STR_REBOOT_ZH[] = "\xe9\x87\x8d\xe5\x90\xaf";
static const char STR_SHUTDOWN_ZH[] = "\xe5\x85\xb3\xe6\x9c\xba";
static const char STR_SWITCH_TO_MUI_ZH[] = "\xe5\x88\x87\xe6\x8d\xa2\xe5\x88\xb0 MUI";
static const char STR_DISPLAY_OPTIONS_ZH[] = "\xe6\x98\xbe\xe7\xa4\xba\xe9\x80\x89\xe9\xa1\xb9";
static const char STR_LANGUAGE_ZH[] = "\xe8\xaf\xad\xe8\xa8\x80";
static const char STR_CHINESE_ZH[] = "\xe4\xb8\xad\xe6\x96\x87";
static const char STR_ENGLISH_ZH[] = "\xe8\x8b\xb1\xe6\x96\x87";
static const char STR_NO_GPS_ZH[] = "\xe6\x97\xa0 GPS";
static const char STR_GPS_OFF_ZH[] = "GPS \xe5\xb7\xb2\xe5\x85\xb3\xe9\x97\xad";
static const char STR_GPS_NOT_PRESENT_ZH[] = "GPS \xe4\xb8\x8d\xe5\xad\x98\xe5\x9c\xa8";
static const char STR_GPS_IS_DISABLED_ZH[] = "GPS \xe5\xb7\xb2\xe7\xa6\x81\xe7\x94\xa8";
static const char STR_NODE_ZH[] = "\xe8\x8a\x82\xe7\x82\xb9";
static const char STR_HOP_ZH[] = "\xe8\xb7\xb3\xe6\x95\xb0:";
static const char STR_USB_ZH[] = "USB";
static const char STR_BT_OFF_ZH[] = "\xe8\x93\x9d\xe7\x89\x99\xe5\xb7\xb2\xe5\x85\xb3";
static const char STR_WIFI_ZH[] = "WiFi";
static const char STR_WIFI_NOT_CONNECTED_ZH[] = "WiFi: \xe6\x9c\xaa\xe8\xbf\x9e\xe6\x8e\xa5";
static const char STR_WIFI_CONNECTED_ZH[] = "WiFi: \xe5\xb7\xb2\xe8\xbf\x9e\xe6\x8e\xa5";
static const char STR_SSID_ZH[] = "SSID:";
static const char STR_LORA_INFO_ZH[] = "LoRa \xe4\xbf\xa1\xe6\x81\xaf";
static const char STR_LORA_ZH[] = "LoRa";
static const char STR_ROLE_ZH[] = "\xe8\xa7\x92\xe8\x89\xb2:";
static const char STR_FREQ_ZH[] = "\xe9\xa2\x91\xe7\x8e\x87:";
static const char STR_CHUTIL_ZH[] = "\xe4\xbf\xa1\xe9\x81\x93\xe5\x88\xa9\xe7\x94\xa8";
static const char STR_SYSTEM_ZH[] = "\xe7\xb3\xbb\xe7\xbb\x9f";
static const char STR_HEAP_ZH[] = "\xe5\xa0\x86:";
static const char STR_PSRAM_ZH[] = "PSRAM:";
static const char STR_FLASH_ZH[] = "Flash:";
static const char STR_SD_ZH[] = "SD:";
static const char STR_VER_ZH[] = "\xe7\x89\x88\xe6\x9c\xac:";
static const char STR_UP_ZH[] = "\xe8\xbf\x90\xe8\xa1\x8c:";
static const char STR_CLIENT_APP_ZH[] = "\xe5\xae\xa2\xe6\x88\xb7\xe7\xab\xaf";
static const char STR_NO_CONNECTED_ZH[] = "\xe6\x9c\xaa\xe8\xbf\x9e\xe6\x8e\xa5";
static const char STR_LOCKED_ZH[] = "\xe5\xb7\xb2\xe9\x94\x81\xe5\xae\x9a";
static const char STR_CONNECT_TO_UNLOCK_ZH[] = "\xe8\xbf\x9e\xe6\x8e\xa5\xe4\xbb\xa5\xe8\xa7\xa3\xe9\x94\x81";
static const char STR_BATTERY_ZH[] = "\xe7\x94\xb5\xe6\xb1\xa0";
static const char STR_RESUMING_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe6\x81\xa2\xe5\xa4\x8d...";
static const char STR_LAST_HEARD_ZH[] = "\xe6\x9c\x80\xe5\x90\x8e\xe5\x90\xac\xe5\x88\xb0";
static const char STR_HOPS_SIG_ZH[] = "\xe8\xb7\xb3\xe6\x95\xb0/\xe4\xbf\xa1\xe5\x8f\xb7";
static const char STR_HOPS_SIGNAL_ZH[] = "\xe8\xb7\xb3\xe6\x95\xb0/\xe4\xbf\xa1\xe5\x8f\xb7";
static const char STR_NODES_ZH[] = "\xe8\x8a\x82\xe7\x82\xb9";
static const char STR_DISTANCE_ZH[] = "\xe8\xb7\x9d\xe7\xa6\xbb";
static const char STR_BEARINGS_ZH[] = "\xe6\x96\xb9\xe4\xbd\x8d";
static const char STR_CREATING_SSL_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe5\x88\x9b\xe5\xbb\xba SSL \xe8\xaf\x81\xe4\xb9\xa6";
static const char STR_PLEASE_WAIT_ZH[] = "\xe8\xaf\xb7\xe7\xa8\x8d\xe5\x80\x99 . . .";
static const char STR_CRITICAL_FAULT_ZH[] = "\xe4\xb8\xa5\xe9\x87\x8d\xe6\x95\x85\xe9\x9a\x9c";
static const char STR_FOR_HELP_VISIT_ZH[] = "\xe8\xaf\xb7\xe8\xae\xbf\xe9\x97\xae\x4e\xe5\xb8\xae\xe5\x8a\xa9";
static const char STR_UPDATING_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe6\x9b\xb4\xe6\x96\xb0";
static const char STR_PLEASE_BE_PATIENT_ZH[] = "\xe8\xaf\xb7\xe8\x80\x90\xe5\xbf\x83\xe7\xad\x89\xe5\xbe\x85\xef\xbc\x8c\xe4\xb8\x8d\xe8\xa6\x81\xe5\x85\xb3\xe9\x97\xad\xe7\x94\xb5\xe6\xba\x90\xe3\x80\x82";
static const char STR_REBOOT_IN_PROGRESS_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe9\x87\x8d\xe5\x90\xaf...";
static const char STR_POWER_MENU_ZH[] = "\xe7\x94\xb5\xe6\xba\x90";
static const char STR_SYSTEM_MENU_ZH[] = "\xe7\xb3\xbb\xe7\xbb\x9f";
static const char STR_SCREEN_OPTIONS_ZH[] = "\xe5\xb1\x8f\xe5\xb9\x95";
static const char STR_WIFI_TOGGLE_ZH[] = "WiFi";
static const char STR_BLUETOOTH_TOGGLE_ZH[] = "\xe8\x93\x9d\xe7\x89\x99";
static const char STR_NODE_DB_RESET_ZH[] = "\xe9\x87\x8d\xe7\xbd\xae\xe8\x8a\x82\xe7\x82\xb9\xe5\xba\x93";
static const char STR_MANAGE_NODE_ZH[] = "\xe7\xae\xa1\xe7\x90\x86\xe8\x8a\x82\xe7\x82\xb9";
static const char STR_REMOVE_FAVORITE_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe6\x94\xb6\xe8\x97\x8f";
static const char STR_ADD_FAVORITE_ZH[] = "\xe6\xb7\xbb\xe5\x8a\xa0\xe6\x94\xb6\xe8\x97\x8f";
static const char STR_TEST_MENU_ZH[] = "\xe6\xb5\x8b\xe8\xaf\x95";
static const char STR_TRACE_ROUTE_ZH[] = "\xe8\xb7\xaf\xe7\x94\xb1\xe8\xbf\xbd\xe8\xb8\xaa";
static const char STR_THROTTLE_MSG_ZH[] = "\xe6\xb6\x88\xe6\x81\xaf\xe5\xb7\xb2\xe9\x99\x90\xe6\xb5\x81";
static const char STR_MSG_VIEW_MODE_ZH[] = "\xe6\x9f\xa5\xe7\x9c\x8b\xe6\xa8\xa1\xe5\xbc\x8f";
static const char STR_DELETE_MESSAGES_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe6\xb6\x88\xe6\x81\xaf";
static const char STR_NODE_NAME_LENGTH_ZH[] = "\xe5\x90\x8d\xe7\xa7\xb0\xe9\x95\xbf\xe5\xba\xa6";
static const char STR_GPS_MENU_ZH[] = "GPS";
static const char STR_GPS_TOGGLE_ZH[] = "GPS \xe5\xbc\x80\xe5\x85\xb3";
static const char STR_GPS_FORMAT_ZH[] = "GPS \xe6\xa0\xbc\xe5\xbc\x8f";
static const char STR_GPS_SMART_POS_ZH[] = "\xe6\x99\xba\xe8\x83\xbd\xe5\xae\x9a\xe4\xbd\x8d";
static const char STR_GPS_UPDATE_INTERVAL_ZH[] = "\xe6\x9b\xb4\xe6\x96\xb0\xe9\x97\xb4\xe9\x9a\x94";
static const char STR_GPS_POS_BCAST_ZH[] = "\xe4\xbd\x8d\xe7\xbd\xae\xe5\xb9\xbf\xe6\x92\xad";
static const char STR_COMPASS_NORTH_ZH[] = "\xe6\x8c\x87\xe5\x8d\x97\xe9\x92\x88";
static const char STR_BUZZER_MODE_ZH[] = "\xe8\x9c\x82\xe9\xb8\xa3\xe5\x99\xa8";
static const char STR_HAM_MODE_ZH[] = "Ham \xe6\xa8\xa1\xe5\xbc\x8f";
static const char STR_LICENSED_NORMAL_ZH[] = "\xe6\x99\xae\xe9\x80\x9a\xe6\xa8\xa1\xe5\xbc\x8f";
static const char STR_KEY_VERIFY_ZH[] = "\xe9\xaa\x8c\xe8\xaf\x81\xe5\xaf\x86\xe9\x92\xa5";
static const char STR_REGION_UNSET_ZH[] = "\xe8\xaf\xb7\xe9\x80\x89\xe6\x8b\xa9\xe5\x9c\xb0\xe5\x8c\xba";
static const char STR_SELECT_REGION_ZH[] = "\xe8\xaf\xb7\xe9\x80\x89\xe6\x8b\xa9\xe6\x82\xa8\xe7\x9a\x84 LoRa \xe5\x8c\xba\xe5\x9f\x9f:";
static const char STR_REBOOTING_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe9\x87\x8d\xe5\x90\xaf...";
static const char STR_LORA_ACTIONS_ZH[] = "LoRa \xe6\x93\x8d\xe4\xbd\x9c";
static const char STR_SLOT0_ZH[] = "\xe6\xa7\xbd\xe4\xbd\x8d 0 (\xe8\x87\xaa\xe5\x8a\xa8)";
static const char STR_WITH_PRESET_ZH[] = "\xe9\xa2\x84\xe8\xae\xbe\xe6\xb6\x88\xe6\x81\xaf";
static const char STR_WITH_FREETEXT_ZH[] = "\xe8\x87\xaa\xe5\xae\x9a\xe4\xb9\x89";
static const char STR_DELETE_OLDEST_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe6\x9c\x80\xe6\x97\xa7";
static const char STR_DELETE_THIS_CHAT_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe6\xad\xa4\xe8\x81\x8a\xe5\xa4\xa9";
static const char STR_DELETE_ALL_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe5\x85\xa8\xe9\x83\xa8";
static const char STR_DELETE_ALL_CHATS_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe6\x89\x80\xe6\x9c\x89\xe8\x81\x8a\xe5\xa4\xa9";
static const char STR_TEMPORARILY_MUTE_ZH[] = "\xe4\xb8\xb4\xe6\x97\xb6\xe9\x9d\x99\xe9\x9f\xb3";
static const char STR_UNMUTE_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe9\x9d\x99\xe9\x9f\xb3";
static const char STR_TOGGLE_BACKLIGHT_ZH[] = "\xe8\x83\x8c\xe5\x85\x89\xe5\xbc\x80\xe5\x85\xb3";
static const char STR_SLEEP_SCREEN_ZH[] = "\xe4\xbc\x91\xe7\x9c\xa0\xe5\xb1\x8f\xe5\xb9\x95";
static const char STR_SEND_POSITION_ZH[] = "\xe5\x8f\x91\xe9\x80\x81\xe4\xbd\x8d\xe7\xbd\xae";
static const char STR_SEND_NODE_INFO_ZH[] = "\xe5\x8f\x91\xe9\x80\x81\xe8\x8a\x82\xe7\x82\xb9\xe4\xbf\xa1\xe6\x81\xaf";
static const char STR_NEW_PRESET_MSG_ZH[] = "\xe6\x96\xb0\xe9\xa2\x84\xe8\xae\xbe\xe6\xb6\x88\xe6\x81\xaf";
static const char STR_NEW_FREETEXT_MSG_ZH[] = "\xe6\x96\xb0\xe8\x87\xaa\xe5\xae\x9a\xe4\xb9\x89\xe6\xb6\x88\xe6\x81\xaf";
static const char STR_NOTIFICATIONS_ZH[] = "\xe9\x80\x9a\xe7\x9f\xa5";
static const char STR_POWER_ZH[] = "\xe7\x94\xb5\xe6\xba\x90";
static const char STR_REBOOT_SHUTDOWN_ZH[] = "\xe9\x87\x8d\xe5\x90\xaf/\xe5\x85\xb3\xe6\x9c\xba";
static const char STR_GO_TO_CHAT_ZH[] = "\xe8\xbf\x9b\xe5\x85\xa5\xe8\x81\x8a\xe5\xa4\xa9";
static const char STR_NEW_PRESET_ZH[] = "\xe6\x96\xb0\xe9\xa2\x84\xe8\xae\xbe";
static const char STR_FAVORITE_ZH[] = "\xe6\x94\xb6\xe8\x97\x8f";
static const char STR_UNMUTE_NOTIFICATIONS_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe9\x80\x9a\xe7\x9f\xa5\xe9\x9d\x99\xe9\x9f\xb3";
static const char STR_MUTE_NOTIFICATIONS_ZH[] = "\xe9\x80\x9a\xe7\x9f\xa5\xe9\x9d\x99\xe9\x9f\xb3";
static const char STR_UNIGNORE_NODE_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe5\xbf\xbd\xe7\x95\xa5";
static const char STR_IGNORE_NODE_ZH[] = "\xe5\xbf\xbd\xe7\x95\xa5\xe8\x8a\x82\xe7\x82\xb9";
static const char STR_NUMBER_PICKER_ZH[] = "\xe6\x95\xb0\xe5\xad\x97\xe9\x80\x89\xe6\x8b\xa9";
static const char STR_TEST_ANNOUNCE_ZH[] = "\xe6\xb5\x8b\xe8\xaf\x95\xe5\x85\xac\xe5\x91\x8a";
static const char STR_NODE_ACTIONS_ZH[] = "\xe8\x8a\x82\xe7\x82\xb9\xe6\x93\x8d\xe4\xbd\x9c/\xe8\xae\xbe\xe7\xbd\xae";
static const char STR_SHOW_NAME_LENGTH_ZH[] = "\xe6\x98\xbe\xe7\xa4\xba\xe9\x95\xbf/\xe7\x9f\xad\xe5\x90\x8d";
static const char STR_BLUETOOTH_ZH[] = "\xe8\x93\x9d\xe7\x89\x99";
static const char STR_NO_MESSAGES_ZH[] = "\xe6\x97\xa0\xe6\xb6\x88\xe6\x81\xaf";
inline const char *getStringZH(StringKey key)
{
switch (key) {
case StringKey::Back:
return STR_BACK_ZH;
case StringKey::OK:
return STR_OK_ZH;
case StringKey::GotIt:
return STR_GOT_IT_ZH;
case StringKey::WelcomeToMeshtastic:
return STR_WELCOME_TO_MESHTASTIC_ZH;
case StringKey::SwipeToNavigate:
return STR_SWIPE_TO_NAVIGATE_ZH;
case StringKey::ClickToNavigate:
return STR_CLICK_TO_NAVIGATE_ZH;
case StringKey::UseSelectButton:
return STR_USE_SELECT_BUTTON_ZH;
case StringKey::DeviceRole:
return STR_DEVICE_ROLE_ZH;
case StringKey::RadioPreset:
return STR_RADIO_PRESET_ZH;
case StringKey::FrequencySlot:
return STR_FREQUENCY_SLOT_ZH;
case StringKey::LoRaRegion:
return STR_LORA_REGION_ZH;
case StringKey::Client:
return STR_CLIENT_ZH;
case StringKey::ClientMute:
return STR_CLIENT_MUTE_ZH;
case StringKey::LostAndFound:
return STR_LOST_AND_FOUND_ZH;
case StringKey::Tracker:
return STR_TRACKER_ZH;
case StringKey::Hour12:
return STR_12HOUR_ZH;
case StringKey::Hour24:
return STR_24HOUR_ZH;
case StringKey::ClockAction:
return STR_CLOCK_ACTION_ZH;
case StringKey::ClockFace:
return STR_CLOCK_FACE_ZH;
case StringKey::TimeFormat:
return STR_TIME_FORMAT_ZH;
case StringKey::Timezone:
return STR_TIMEZONE_ZH;
case StringKey::MessageAction:
return STR_MESSAGE_ACTION_ZH;
case StringKey::Reply:
return STR_REPLY_ZH;
case StringKey::ViewChats:
return STR_VIEW_CHATS_ZH;
case StringKey::UnmuteChannel:
return STR_UNMUTE_CHANNEL_ZH;
case StringKey::MuteChannel:
return STR_MUTE_CHANNEL_ZH;
case StringKey::Delete:
return STR_DELETE_ZH;
case StringKey::ReadAloud:
return STR_READ_ALOUD_ZH;
case StringKey::Brightness:
return STR_BRIGHTNESS_ZH;
case StringKey::FrameVisibility:
return STR_FRAME_VISIBILITY_ZH;
case StringKey::DisplayUnits:
return STR_DISPLAY_UNITS_ZH;
case StringKey::MessageBubbles:
return STR_MESSAGE_BUBBLES_ZH;
case StringKey::Theme:
return STR_THEME_ZH;
case StringKey::Reboot:
return STR_REBOOT_ZH;
case StringKey::Shutdown:
return STR_SHUTDOWN_ZH;
case StringKey::SwitchToMUI:
return STR_SWITCH_TO_MUI_ZH;
case StringKey::DisplayOptions:
return STR_DISPLAY_OPTIONS_ZH;
case StringKey::Language:
return STR_LANGUAGE_ZH;
case StringKey::Chinese:
return STR_CHINESE_ZH;
case StringKey::English:
return STR_ENGLISH_ZH;
case StringKey::NoGPS:
return STR_NO_GPS_ZH;
case StringKey::GPSoff:
return STR_GPS_OFF_ZH;
case StringKey::GPSnotPresent:
return STR_GPS_NOT_PRESENT_ZH;
case StringKey::GPSisDisabled:
return STR_GPS_IS_DISABLED_ZH;
case StringKey::Node:
return STR_NODE_ZH;
case StringKey::Hop:
return STR_HOP_ZH;
case StringKey::USB:
return STR_USB_ZH;
case StringKey::BT_off:
return STR_BT_OFF_ZH;
case StringKey::WiFi:
return STR_WIFI_ZH;
case StringKey::WiFiNotConnected:
return STR_WIFI_NOT_CONNECTED_ZH;
case StringKey::WiFiConnected:
return STR_WIFI_CONNECTED_ZH;
case StringKey::SSID:
return STR_SSID_ZH;
case StringKey::LoRaInfo:
return STR_LORA_INFO_ZH;
case StringKey::LoRa:
return STR_LORA_ZH;
case StringKey::Role:
return STR_ROLE_ZH;
case StringKey::Freq:
return STR_FREQ_ZH;
case StringKey::ChUtil:
return STR_CHUTIL_ZH;
case StringKey::System:
return STR_SYSTEM_ZH;
case StringKey::Heap:
return STR_HEAP_ZH;
case StringKey::PSRAM:
return STR_PSRAM_ZH;
case StringKey::Flash:
return STR_FLASH_ZH;
case StringKey::SD:
return STR_SD_ZH;
case StringKey::Ver:
return STR_VER_ZH;
case StringKey::Up:
return STR_UP_ZH;
case StringKey::ClientApp:
return STR_CLIENT_APP_ZH;
case StringKey::NoConnected:
return STR_NO_CONNECTED_ZH;
case StringKey::LOCKED:
return STR_LOCKED_ZH;
case StringKey::ConnectToUnlock:
return STR_CONNECT_TO_UNLOCK_ZH;
case StringKey::Battery:
return STR_BATTERY_ZH;
case StringKey::Resuming:
return STR_RESUMING_ZH;
case StringKey::LastHeard:
return STR_LAST_HEARD_ZH;
case StringKey::HopsSig:
return STR_HOPS_SIG_ZH;
case StringKey::HopsSignal:
return STR_HOPS_SIGNAL_ZH;
case StringKey::Nodes:
return STR_NODES_ZH;
case StringKey::Distance:
return STR_DISTANCE_ZH;
case StringKey::Bearings:
return STR_BEARINGS_ZH;
case StringKey::CreatingSSLCert:
return STR_CREATING_SSL_ZH;
case StringKey::PleaseWait:
return STR_PLEASE_WAIT_ZH;
case StringKey::CriticalFault:
return STR_CRITICAL_FAULT_ZH;
case StringKey::ForHelpVisit:
return STR_FOR_HELP_VISIT_ZH;
case StringKey::Updating:
return STR_UPDATING_ZH;
case StringKey::PleaseBePatient:
return STR_PLEASE_BE_PATIENT_ZH;
case StringKey::RebootInProgress:
return STR_REBOOT_IN_PROGRESS_ZH;
case StringKey::PowerMenu:
return STR_POWER_MENU_ZH;
case StringKey::SystemMenu:
return STR_SYSTEM_MENU_ZH;
case StringKey::ScreenOptions:
return STR_SCREEN_OPTIONS_ZH;
case StringKey::WifiToggle:
return STR_WIFI_TOGGLE_ZH;
case StringKey::BluetoothToggle:
return STR_BLUETOOTH_TOGGLE_ZH;
case StringKey::NodeDbReset:
return STR_NODE_DB_RESET_ZH;
case StringKey::ManageNode:
return STR_MANAGE_NODE_ZH;
case StringKey::RemoveFavorite:
return STR_REMOVE_FAVORITE_ZH;
case StringKey::AddFavorite:
return STR_ADD_FAVORITE_ZH;
case StringKey::TestMenu:
return STR_TEST_MENU_ZH;
case StringKey::TraceRoute:
return STR_TRACE_ROUTE_ZH;
case StringKey::ThrottleMessage:
return STR_THROTTLE_MSG_ZH;
case StringKey::MessageViewMode:
return STR_MSG_VIEW_MODE_ZH;
case StringKey::DeleteMessages:
return STR_DELETE_MESSAGES_ZH;
case StringKey::NodeNameLength:
return STR_NODE_NAME_LENGTH_ZH;
case StringKey::GPSMenu:
return STR_GPS_MENU_ZH;
case StringKey::GPSToggle:
return STR_GPS_TOGGLE_ZH;
case StringKey::GPSFormat:
return STR_GPS_FORMAT_ZH;
case StringKey::GPSSmartPosition:
return STR_GPS_SMART_POS_ZH;
case StringKey::GPSUpdateInterval:
return STR_GPS_UPDATE_INTERVAL_ZH;
case StringKey::GPSPositionBroadcast:
return STR_GPS_POS_BCAST_ZH;
case StringKey::CompassNorth:
return STR_COMPASS_NORTH_ZH;
case StringKey::BuzzerMode:
return STR_BUZZER_MODE_ZH;
case StringKey::HamModeConfirm:
return STR_HAM_MODE_ZH;
case StringKey::LicensedToNormal:
return STR_LICENSED_NORMAL_ZH;
case StringKey::KeyVerification:
return STR_KEY_VERIFY_ZH;
case StringKey::RegionUnset:
return STR_REGION_UNSET_ZH;
case StringKey::SelectRegionMessage:
return STR_SELECT_REGION_ZH;
case StringKey::Rebooting:
return STR_REBOOTING_ZH;
case StringKey::LoRaActions:
return STR_LORA_ACTIONS_ZH;
case StringKey::Slot0:
return STR_SLOT0_ZH;
case StringKey::WithPreset:
return STR_WITH_PRESET_ZH;
case StringKey::WithFreetext:
return STR_WITH_FREETEXT_ZH;
case StringKey::DeleteOldest:
return STR_DELETE_OLDEST_ZH;
case StringKey::DeleteThisChat:
return STR_DELETE_THIS_CHAT_ZH;
case StringKey::DeleteAll:
return STR_DELETE_ALL_ZH;
case StringKey::DeleteAllChats:
return STR_DELETE_ALL_CHATS_ZH;
case StringKey::TemporarilyMute:
return STR_TEMPORARILY_MUTE_ZH;
case StringKey::Unmute:
return STR_UNMUTE_ZH;
case StringKey::ToggleBacklight:
return STR_TOGGLE_BACKLIGHT_ZH;
case StringKey::SleepScreen:
return STR_SLEEP_SCREEN_ZH;
case StringKey::SendPosition:
return STR_SEND_POSITION_ZH;
case StringKey::SendNodeInfo:
return STR_SEND_NODE_INFO_ZH;
case StringKey::NewPresetMsg:
return STR_NEW_PRESET_MSG_ZH;
case StringKey::NewFreetextMsg:
return STR_NEW_FREETEXT_MSG_ZH;
case StringKey::Notifications:
return STR_NOTIFICATIONS_ZH;
case StringKey::Power:
return STR_POWER_ZH;
case StringKey::RebootShutdown:
return STR_REBOOT_SHUTDOWN_ZH;
case StringKey::GoToChat:
return STR_GO_TO_CHAT_ZH;
case StringKey::NewPreset:
return STR_NEW_PRESET_ZH;
case StringKey::Favorite:
return STR_FAVORITE_ZH;
case StringKey::UnmuteNotifications:
return STR_UNMUTE_NOTIFICATIONS_ZH;
case StringKey::MuteNotifications:
return STR_MUTE_NOTIFICATIONS_ZH;
case StringKey::UnignoreNode:
return STR_UNIGNORE_NODE_ZH;
case StringKey::IgnoreNode:
return STR_IGNORE_NODE_ZH;
case StringKey::NumberPicker:
return STR_NUMBER_PICKER_ZH;
case StringKey::TestAnnounce:
return STR_TEST_ANNOUNCE_ZH;
case StringKey::NodeActionsSettings:
return STR_NODE_ACTIONS_ZH;
case StringKey::ShowNameLength:
return STR_SHOW_NAME_LENGTH_ZH;
case StringKey::Bluetooth:
return STR_BLUETOOTH_ZH;
case StringKey::NoMessages:
return STR_NO_MESSAGES_ZH;
default:
return "?";
}
}
} // namespace l10n
} // namespace meshtastic
@@ -129,6 +129,7 @@ enum MenuAction {
// Administration
RESET_NODEDB_ALL,
RESET_NODEDB_KEEP_FAVORITES,
WIPE_MESSAGES_ALL,
};
} // namespace NicheGraphics::InkHUD
@@ -6,6 +6,7 @@
#include "GPS.h"
#include "MeshRadio.h"
#include "MeshService.h"
#include "MessageStore.h"
#include "RTC.h"
#include "Router.h"
#include "airtime.h"
@@ -1013,6 +1014,13 @@ void InkHUD::MenuApplet::execute(MenuItem item)
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
break;
case WIPE_MESSAGES_ALL:
LOG_INFO("Wiping all messages from menu");
messageStore.clearAllMessages();
inkhud->persistence->loadLatestMessage();
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true);
break;
default:
LOG_WARN("Action not implemented");
}
@@ -1130,6 +1138,7 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
// Administration Section
items.push_back(MenuItem::Header("Administration"));
items.push_back(MenuItem("Reset NodeDB", MenuPage::NODE_CONFIG_ADMIN_RESET));
items.push_back(MenuItem("Wipe Messages", MenuPage::NODE_CONFIG_ADMIN_MESSAGES));
// Exit
items.push_back(MenuItem("Exit", MenuPage::EXIT));
@@ -1534,6 +1543,13 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
case NODE_CONFIG_ADMIN_MESSAGES:
previousPage = MenuPage::NODE_CONFIG;
items.push_back(MenuItem("Back", previousPage));
items.push_back(MenuItem("Wipe All Messages", MenuAction::WIPE_MESSAGES_ALL, MenuPage::EXIT));
items.push_back(MenuItem("Exit", MenuPage::EXIT));
break;
// Exit
case EXIT:
sendToBackground(); // Menu applet dismissed, allow normal behavior to resume
@@ -36,6 +36,7 @@ enum MenuPage : uint8_t {
NODE_CONFIG_BLUETOOTH,
NODE_CONFIG_POSITION,
NODE_CONFIG_ADMIN_RESET,
NODE_CONFIG_ADMIN_MESSAGES,
TIMEZONE,
APPLETS,
AUTOSHOW,
+3 -1
View File
@@ -22,6 +22,8 @@ void InkHUD::Persistence::loadSettings()
// are immediately available to applets (DMApplet, AllMessageApplet, NotificationApplet).
void InkHUD::Persistence::loadLatestMessage()
{
latestMessage = LatestMessage();
int lastBroadcastPos = -1, lastDMPos = -1, pos = 0;
for (const StoredMessage &m : messageStore.getLiveMessages()) {
if (m.type == MessageType::BROADCAST) {
@@ -75,4 +77,4 @@ void InkHUD::Persistence::printSettings(Settings *settings)
}
*/
#endif
#endif
+97
View File
@@ -0,0 +1,97 @@
#include "HapticFeedback.h"
#ifdef HAPTIC_FEEDBACK_PIN
#include <Arduino.h>
#ifdef HAPTIC_FEEDBACK_ACTIVE_LOW
#define HAPTIC_FEEDBACK_ON_STATE LOW
#define HAPTIC_FEEDBACK_OFF_STATE HIGH
#else
#define HAPTIC_FEEDBACK_ON_STATE HIGH
#define HAPTIC_FEEDBACK_OFF_STATE LOW
#endif
HapticFeedback *hapticFeedback = nullptr;
void initHapticFeedback()
{
if (!hapticFeedback)
hapticFeedback = new HapticFeedback();
}
HapticFeedback::HapticFeedback() : concurrency::OSThread("Haptic")
{
pinMode(HAPTIC_FEEDBACK_PIN, OUTPUT);
digitalWrite(HAPTIC_FEEDBACK_PIN, HAPTIC_FEEDBACK_OFF_STATE);
}
void HapticFeedback::motorWrite(bool on)
{
digitalWrite(HAPTIC_FEEDBACK_PIN, on ? HAPTIC_FEEDBACK_ON_STATE : HAPTIC_FEEDBACK_OFF_STATE);
}
void HapticFeedback::pulse(uint16_t durationMs)
{
motorWrite(true);
pulseOffAt = millis() + durationMs;
if (pulseOffAt == 0) // 0 is the "no pulse" sentinel
pulseOffAt = 1;
scheduleNext();
}
void HapticFeedback::armDelayedPulse(uint16_t delayMs, uint16_t durationMs)
{
delayedPulseAt = millis() + delayMs;
if (delayedPulseAt == 0)
delayedPulseAt = 1;
delayedPulseDuration = durationMs;
scheduleNext();
}
void HapticFeedback::cancelDelayedPulse()
{
delayedPulseAt = 0;
}
void HapticFeedback::scheduleNext()
{
uint32_t now = millis();
uint32_t next = 0;
if (pulseOffAt != 0)
next = pulseOffAt;
if (delayedPulseAt != 0 && (next == 0 || (int32_t)(delayedPulseAt - next) < 0))
next = delayedPulseAt;
if (next == 0)
return;
int32_t delay = (int32_t)(next - now);
setIntervalFromNow(delay > 0 ? (unsigned long)delay : 0);
}
int32_t HapticFeedback::runOnce()
{
uint32_t now = millis();
if (pulseOffAt != 0 && (int32_t)(now - pulseOffAt) >= 0) {
motorWrite(false);
pulseOffAt = 0;
}
if (delayedPulseAt != 0 && (int32_t)(now - delayedPulseAt) >= 0) {
uint16_t dur = delayedPulseDuration;
delayedPulseAt = 0;
pulse(dur);
}
uint32_t next = 0;
if (pulseOffAt != 0)
next = pulseOffAt;
if (delayedPulseAt != 0 && (next == 0 || (int32_t)(delayedPulseAt - next) < 0))
next = delayedPulseAt;
if (next == 0)
return 60 * 1000;
int32_t delay = (int32_t)(next - now);
return delay > 0 ? delay : 0;
}
#endif // HAPTIC_FEEDBACK_PIN
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "configuration.h"
#ifdef HAPTIC_FEEDBACK_PIN
#include "concurrency/OSThread.h"
#include <stdint.h>
// Non-blocking pulses on a GPIO vibration motor. HAPTIC_FEEDBACK_ACTIVE_LOW inverts polarity.
class HapticFeedback : public concurrency::OSThread
{
public:
HapticFeedback();
void pulse(uint16_t durationMs = 30);
void armDelayedPulse(uint16_t delayMs, uint16_t durationMs = 30);
void cancelDelayedPulse();
protected:
int32_t runOnce() override;
private:
uint32_t pulseOffAt = 0;
uint32_t delayedPulseAt = 0;
uint16_t delayedPulseDuration = 0;
void motorWrite(bool on);
// Reschedule to the soonest pending event so later arms don't clobber earlier wakes.
void scheduleNext();
};
extern HapticFeedback *hapticFeedback;
void initHapticFeedback();
#endif // HAPTIC_FEEDBACK_PIN
+11
View File
@@ -2,6 +2,7 @@
#include "PowerFSM.h" // needed for event trigger
#include "configuration.h"
#include "graphics/Screen.h"
#include "input/HapticFeedback.h"
#include "modules/ExternalNotificationModule.h"
#ifdef MESHTASTIC_LOCKDOWN
#include "security/LockdownDisplay.h"
@@ -256,6 +257,16 @@ void InputBroker::Init()
}
touchBacklightActive = false;
};
#endif
#if defined(HAPTIC_FEEDBACK_PIN)
// Blip on touch, second blip when long-press fires (500 ms = touchConfig.longPressTime default).
touchConfig.suppressLeadUpSound = true;
initHapticFeedback();
touchConfig.onPress = []() {
hapticFeedback->pulse(80);
hapticFeedback->armDelayedPulse(500, 80);
};
touchConfig.onRelease = []() { hapticFeedback->cancelDelayedPulse(); };
#endif
TouchButtonThread->initButton(touchConfig);
#endif
+17 -14
View File
@@ -1071,19 +1071,6 @@ void setup()
digitalWrite(RF95_FAN_EN, LOW ^ 0);
#endif
#ifndef ARCH_PORTDUINO
// Initialize Wifi
#if HAS_WIFI
initWifi();
#endif
#if HAS_ETHERNET
// Initialize Ethernet
initEthernet();
#endif
#endif
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WEBSERVER
// Start web server thread.
webServerThread = new WebServerThread();
@@ -1121,6 +1108,22 @@ void setup()
setCPUFast(false); // 80MHz is fine for our slow peripherals
#endif
#ifndef ARCH_PORTDUINO
// Force BT init before WiFi so NimBLE gets clean heap
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_BLUETOOTH
setBluetoothEnable(true);
#endif
// Initialize Wifi after PowerFSM so BT gets clean heap during init
#if HAS_WIFI
initWifi();
#endif
#if HAS_ETHERNET
// Initialize Ethernet
initEthernet();
#endif
#endif
#ifdef ARDUINO_ARCH_ESP32
LOG_DEBUG("Free heap : %7d bytes", ESP.getFreeHeap());
LOG_DEBUG("Free PSRAM : %7d bytes", ESP.getFreePsram());
@@ -1191,7 +1194,7 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
// No bluetooth on these targets (yet):
// Pico W / 2W may get it at some point
// Portduino and ESP32-C6 are excluded because we don't have a working bluetooth stacks integrated yet.
#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL) || defined(CONFIG_IDF_TARGET_ESP32C6)
#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32) || defined(CONFIG_IDF_TARGET_ESP32C6)
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_BLUETOOTH_CONFIG;
#endif
+1 -1
View File
@@ -14,7 +14,7 @@
#include <malloc.h>
#include <unistd.h> // sbrk
#ifdef ARCH_STM32WL
#if defined(ARCH_STM32)
// Returns the uncommitted sbrk headroom: addressable space between the current heap
// break and the stack pointer that has not yet been committed to the arena.
static uint32_t sbrkHeadroom()
+54
View File
@@ -404,6 +404,60 @@ bool Channels::isDefaultChannel(ChannelIndex chIndex)
return false;
}
bool cryptoKeyIsPublic(const CryptoKey &key)
{
if (key.length == 0)
return true; // encryption disabled
// Match the defaultpsk family ignoring its last byte (getKey() bumps only that byte per 1-byte index).
if (key.length == (int)sizeof(defaultpsk) && memcmp(key.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0)
return true;
return false;
}
bool Channels::usesPublicKey(ChannelIndex chIndex)
{
const meshtastic_Channel &ch = getByIndex(chIndex);
if (!ch.has_settings || ch.role == meshtastic_Channel_Role_DISABLED)
return false;
const auto &psk = ch.settings.psk;
if (psk.size == 0) {
// Secondary channels inherit the primary key when unset; primary size==0 means encryption disabled.
if (ch.role == meshtastic_Channel_Role_SECONDARY) {
// Guard against malformed configs with no PRIMARY channel (primaryIndex could point back to us).
if (primaryIndex == chIndex)
return true; // fail closed: treat as public
return usesPublicKey(primaryIndex);
}
return true;
}
if (psk.size == 1) {
// Short PSK aliases: 0 disables encryption; 1..255 are the public defaultpsk family.
return true;
}
return (psk.size == sizeof(defaultpsk) && memcmp(psk.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0);
}
bool Channels::isWellKnownChannel(ChannelIndex chIndex)
{
const auto &ch = getByIndex(chIndex);
// Absent (unencrypted) or single-byte PSK — all the well-known key indexes
if (ch.settings.psk.size > 1)
return false;
const char *name = getName(chIndex);
for (int p = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; p <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; p++) {
const char *presetName =
DisplayFormatters::getModemPresetDisplayName(static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(p), false, true);
// Presets without a display name fall through to "Invalid" — never a match
if (strcmp(presetName, "Invalid") != 0 && strcmp(name, presetName) == 0)
return true;
}
return false;
}
bool Channels::hasDefaultChannel()
{
// If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel
+12
View File
@@ -86,6 +86,15 @@ class Channels
// Returns true if the channel has the default name and PSK
bool isDefaultChannel(ChannelIndex chIndex);
// Returns true if this channel's effective key is publicly decryptable (open or well-known/default PSK).
bool usesPublicKey(ChannelIndex chIndex);
// Returns true if the channel is "well known": its PSK is absent or a
// single-byte well-known key index, AND its name is any modem-preset
// display name (e.g. a channel named "LongFast" counts even while the
// radio runs MediumFast). Broader than isDefaultChannel, which only
// matches the current preset's name and PSK byte 1.
bool isWellKnownChannel(ChannelIndex chIndex);
// Returns true if we can be reached via a channel with the default settings given a region and modem preset
bool hasDefaultChannel();
@@ -144,6 +153,9 @@ extern Channels channels;
static const uint8_t defaultpsk[] = {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59,
0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01};
/// True if a getKey()-resolved key offers no privacy: length 0 (off) or the public defaultpsk family. Pure; for tests.
bool cryptoKeyIsPublic(const CryptoKey &key);
static const uint8_t eventpsk[] = {0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36,
0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74,
0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1};
+108 -3
View File
@@ -12,10 +12,18 @@
#include <Curve25519.h>
#include <RNG.h>
#include <SHA256.h>
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
#if !defined(ARCH_STM32WL)
#define CryptRNG RNG
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
#include "XEdDSA.h"
#include <Ed25519.h>
#ifndef NUM_LIMBS_256BIT
#define NUM_LIMBS_BITS(n) (((n) + sizeof(limb_t) * 8 - 1) / (8 * sizeof(limb_t)))
#define NUM_LIMBS_256BIT NUM_LIMBS_BITS(256)
#endif
#endif
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
/**
* Create a public/private key pair with Curve25519.
@@ -46,6 +54,9 @@ void CryptoEngine::generateKeyPair(uint8_t *pubKey, uint8_t *privKey)
Curve25519::dh1(public_key, private_key);
memcpy(pubKey, public_key, sizeof(public_key));
memcpy(privKey, private_key, sizeof(private_key));
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key);
#endif
}
/**
@@ -65,6 +76,9 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
}
memcpy(private_key, privKey, sizeof(private_key));
memcpy(public_key, pubKey, sizeof(public_key));
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key);
#endif
} else {
LOG_WARN("X25519 key generation failed due to blank private key");
return false;
@@ -72,6 +86,97 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
return true;
}
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
/**
* Build a signing buffer that covers packet metadata and payload:
* [fromNode(4) | packetId(4) | portnum(4) | payload(N)]
* This prevents replay, reattribution, and portnum redirection attacks.
*/
static size_t buildSigningBuffer(uint8_t *buf, size_t bufSize, uint32_t fromNode, uint32_t packetId, uint32_t portnum,
const uint8_t *payload, size_t payloadLen)
{
const size_t headerLen = sizeof(uint32_t) * 3;
size_t totalLen = headerLen + payloadLen;
if (totalLen > bufSize)
return 0;
// May need endian conversion for oddball platforms.
memcpy(buf, &fromNode, sizeof(uint32_t));
memcpy(buf + sizeof(uint32_t), &packetId, sizeof(uint32_t));
memcpy(buf + sizeof(uint32_t) * 2, &portnum, sizeof(uint32_t));
memcpy(buf + headerLen, payload, payloadLen);
return totalLen;
}
bool CryptoEngine::xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen,
uint8_t *signature)
{
if (memfll(xeddsa_private_key, 0, sizeof(xeddsa_private_key)))
return false;
uint8_t sigBuf[MAX_BLOCKSIZE];
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
if (sigLen == 0)
return false;
// the XEdDSA::sign function requires at least the first 32 bytes of signature to be pre-filled with randomness
HardwareRNG::fill(signature, 32);
XEdDSA::sign(signature, xeddsa_private_key, xeddsa_public_key, sigBuf, sigLen);
return true;
}
bool CryptoEngine::xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum,
const uint8_t *payload, size_t payloadLen, const uint8_t *signature)
{
// Use cached Ed25519 key if the Curve25519 key matches, avoiding expensive field inversion
if (memcmp(pubKey, cached_curve_pubkey, 32) != 0) {
curve_to_ed_pub(pubKey, cached_ed_pubkey);
memcpy(cached_curve_pubkey, pubKey, 32);
}
uint8_t sigBuf[MAX_BLOCKSIZE];
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
if (sigLen == 0)
return false;
return XEdDSA::verify(signature, cached_ed_pubkey, sigBuf, sigLen);
}
void CryptoEngine::curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey)
{
// Apply the birational map defined in RFC 7748, section 4.1 "Curve25519" to calculate an Ed25519 public
// key from a Curve25519 public key. Because the serialization format of Curve25519 public keys only
// contains the u coordinate, the x coordinate of the corresponding Ed25519 public key can't be uniquely
// calculated as defined by the birational map. The x coordinate is represented in the serialization
// format of Ed25519 public keys only in a single sign bit. XEdDSA always normalizes the Ed25519 public
// key to a sign bit of zero (the signer negates its key pair when needed), so this function clears the
// sign bit unconditionally below instead of taking it as an input.
fe u, y;
fe one;
fe u_minus_one, u_plus_one, u_plus_one_inv;
// Parse the Curve25519 public key input as a field element containing the u coordinate. RFC 7748,
// section 5 "The X25519 and X448 Functions", mandates that the most significant bit of the Curve25519
// public key has to be zeroized. This is handled by fe_frombytes internally.
fe_frombytes(u, curve_pubkey);
// Calculate the parameters (u - 1) and (u + 1)
fe_1(one);
fe_sub(u_minus_one, u, one);
fe_add(u_plus_one, u, one);
// Invert u + 1
fe_invert(u_plus_one_inv, u_plus_one);
// Calculate y = (u - 1) * inv(u + 1) (mod p)
fe_mul(y, u_minus_one, u_plus_one_inv);
// Serialize the field element containing the y coordinate to the Ed25519 public key output
fe_tobytes(ed_pubkey, y);
// Set the sign bit to zero
ed_pubkey[31] &= 0x7f;
// need to convert the pubkey y = ( u - 1) * inv( u + 1) (mod p).
}
#endif
bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user)
{
if (user.is_licensed) {
+15 -1
View File
@@ -23,6 +23,7 @@ struct CryptoKey {
#define MAX_BLOCKSIZE 256
#define TEST_CURVE25519_FIELD_OPS // Exposes Curve25519::isWeakPoint() for testing keys
#define XEDDSA_SIGNATURE_SIZE 64
class CryptoEngine
{
@@ -37,7 +38,12 @@ class CryptoEngine
virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey);
virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey);
virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user);
#endif
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
bool xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen,
uint8_t *signature);
bool xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload,
size_t payloadLen, const uint8_t *signature);
#endif
void setDHPrivateKey(uint8_t *_private_key);
// The remotePublic key parameter takes the public_key bytes container from
@@ -85,6 +91,14 @@ class CryptoEngine
#if !(MESHTASTIC_EXCLUDE_PKI)
uint8_t shared_key[32] = {0};
uint8_t private_key[32] = {0};
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
uint8_t xeddsa_public_key[32] = {0};
uint8_t xeddsa_private_key[32] = {0};
void curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey);
// Single-entry cache for curve_to_ed_pub conversion (avoids expensive field inversion per packet)
uint8_t cached_curve_pubkey[32] = {0};
uint8_t cached_ed_pubkey[32] = {0};
#endif
#endif
/**
* Init our 128 bit nonce for a new packet
+11 -2
View File
@@ -18,6 +18,9 @@
#define default_telemetry_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
#define default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
#define default_broadcast_smart_minimum_interval_secs 5 * 60
// Floor for our own position broadcasts when stationary (unchanged beyond the broadcast
// precision) or fixed_position: identical positions get deduped by traffic management anyway.
#define default_position_stationary_broadcast_secs (12 * 60 * 60)
#define min_default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
#define min_default_broadcast_smart_minimum_interval_secs 5 * 60
#define default_wait_bluetooth_secs IF_ROUTER(1, 60)
@@ -34,8 +37,14 @@
enum class TrafficType { POSITION, TELEMETRY };
// Traffic management defaults
#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells
#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions
#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m)
#define default_traffic_mgmt_position_min_interval_secs (11 * 60 * 60) // 11 hours between identical positions
// Role cap: tracker-role origins may refresh a duplicate position this often (vs the 11h default).
#define default_traffic_mgmt_tracker_position_min_interval_secs (60 * 60) // 1 hour
// Role cap: lost-and-found origins may refresh a duplicate position this often, so a lost
// device updates frequently without flooding. (Quantised to the dedup tick: ~2 ticks.)
// Unlike before, lost-and-found is NOT exempt from the relayed precision clamp.
#define default_traffic_mgmt_lost_and_found_position_min_interval_secs (15 * 60) // 15 minutes
// Hop scaling defaults
#define default_hop_scaling_min_target_nodes 40 // walk threshold: first hop reaching this cumulative count
+29
View File
@@ -57,6 +57,11 @@ static void releaseSleepHolds()
void LoRaFEMInterface::init(void)
{
setLnaCanControl(false); // Default is uncontrollable
#if defined(RF_PA_DETECT_PIN)
pinMode(RF_PA_DETECT_PIN, INPUT);
high_power_pa = (digitalRead(RF_PA_DETECT_PIN) == RF_PA_HIGH_POWER_VALUE);
LOG_INFO("Detected %s LoRa PA profile", high_power_pa ? "high-power" : "low-power");
#endif
#ifdef HELTEC_V4
pinMode(LORA_PA_POWER, OUTPUT);
digitalWrite(LORA_PA_POWER, HIGH);
@@ -119,6 +124,13 @@ void LoRaFEMInterface::init(void)
pinMode(LORA_KCT8103L_PA_CTX, OUTPUT);
digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default
setLnaCanControl(true);
#elif defined(USE_KCT8103L_PA_ONLY)
fem_type = KCT8103L_PA;
pinMode(LORA_KCT8103L_EN, OUTPUT);
digitalWrite(LORA_KCT8103L_EN, HIGH);
delay(1);
pinMode(LORA_KCT8103L_TX_RX, OUTPUT);
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
#endif
}
@@ -148,6 +160,9 @@ void LoRaFEMInterface::setSleepModeEnable(void)
// shutdown the PA
digitalWrite(LORA_KCT8103L_PA_CSD, LOW);
digitalWrite(LORA_PA_POWER, LOW);
#elif defined(USE_KCT8103L_PA_ONLY)
// shutdown the PA
digitalWrite(LORA_KCT8103L_EN, LOW);
#endif
}
@@ -173,6 +188,9 @@ void LoRaFEMInterface::setTxModeEnable(void)
enableFEMPower();
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
#elif defined(USE_KCT8103L_PA_ONLY)
enableFEMPower();
digitalWrite(LORA_KCT8103L_TX_RX, HIGH);
#endif
}
@@ -206,6 +224,9 @@ void LoRaFEMInterface::setRxModeEnable(void)
} else {
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
}
#elif defined(USE_KCT8103L_PA_ONLY)
enableFEMPower();
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
#endif
}
@@ -247,6 +268,9 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX);
#endif
#elif defined(USE_KCT8103L_PA_ONLY)
enableFEMPower();
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
#endif
}
@@ -257,6 +281,11 @@ void LoRaFEMInterface::setLNAEnable(bool enabled)
int8_t LoRaFEMInterface::powerConversion(int8_t loraOutputPower)
{
#if defined(RF_PA_DETECT_PIN)
if (!high_power_pa) {
return loraOutputPower;
}
#endif
#ifdef HELTEC_V4
const uint16_t gc1109_tx_gain[] = {11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10, 9, 9, 8, 7};
const uint16_t kct8103l_tx_gain[] = {13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7};
+2 -1
View File
@@ -24,7 +24,8 @@ class LoRaFEMInterface
LoRaFEMType fem_type;
bool lna_enabled = true;
bool lna_can_control = false;
bool high_power_pa = true;
};
extern LoRaFEMInterface loraFEMInterface;
#endif
#endif
+5
View File
@@ -88,6 +88,11 @@ extern const RegionInfo *myRegion;
extern void initRegion();
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
// Fill `map` with the region->valid-preset table, grouped so regions sharing a
// preset list reference the same group. Sent to clients during want_config so
// their UI can block illegal region+preset combinations.
extern void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map);
// Valid LoRa spread factor range and defaults
constexpr uint8_t LORA_SF_MIN = 5;
constexpr uint8_t LORA_SF_MAX = 12;
+5
View File
@@ -45,6 +45,11 @@ enum RxSource {
// For old firmware there is no relay node set
#define NO_RELAY_NODE 0
// How recently we must have heard a direct neighbor for its single-byte relay id to be trusted as a
// unique next hop. Mirrors NUM_ONLINE_SECS (NodeDB.cpp). Used by NodeDB::resolveLastByte() to scope
// last-byte collision resolution to currently-reachable neighbors.
#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2) // 2 hrs
typedef int ErrorCode;
/// Alloc and free packets to our global, ISR safe pool
+203 -14
View File
@@ -98,21 +98,38 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast
// destination
if (p->from != 0) {
meshtastic_NodeInfoLite *origTx = nodeDB->getMeshNode(p->from);
if (origTx) {
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
// directly from the destination
// Single lookup for both relayer checks on the same (request_id, to) pair
bool wasAlreadyRelayer = false;
bool weWereSoleRelayer = false;
bool weWereRelayer = false;
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
&weWereSoleRelayer);
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
if (origTx->next_hop != p->relay_node) { // Not already set
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
// directly from the destination. checkRelayers is read-only on PacketHistory and O(1), so we run it even
// when origTx is absent — that lets us still capture the confirmed hop into the TMM overflow cache below.
// Single lookup for both relayer checks on the same (request_id, to) pair
bool wasAlreadyRelayer = false;
bool weWereSoleRelayer = false;
bool weWereRelayer = false;
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
&weWereSoleRelayer);
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
// M1/M2: only learn a next hop whose last byte maps to a single plausible relay. On a dense
// mesh the byte may be ambiguous; storing it would aim future DMs at the wrong node. This gate
// now protects BOTH the hot-store route (NodeInfoLite.next_hop) AND the TMM overflow cache —
// the overflow cache deliberately holds many more next-hop bytes (long-tail nodes), so it is
// even more collision-prone and must never store an ambiguous byte either. Ambiguous/unknown
// -> store nothing and keep flooding (safe).
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false)) {
if (origTx && origTx->next_hop != p->relay_node) { // Not already set
LOG_INFO("Update next hop of 0x%x to 0x%x based on ACK/reply (was relayer %d we were sole %d)", p->from,
p->relay_node, wasAlreadyRelayer, weWereSoleRelayer);
origTx->next_hop = p->relay_node;
}
noteRouteLearned(p->from, p->relay_node, millis()); // M3: anchor freshness (hot or overflow route)
#if HAS_TRAFFIC_MANAGEMENT
// Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it
// survives even when the source isn't (or is no longer) in the hot NodeDB.
if (trafficManagementModule)
trafficManagementModule->setNextHop(p->from, p->relay_node);
#endif
} else {
LOG_DEBUG("Not learning next hop for 0x%x: relay byte 0x%x ambiguous/unknown; keep flooding", p->from,
p->relay_node);
}
}
}
@@ -144,6 +161,11 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
if (p->id != 0) {
if (isRebroadcaster()) {
// NOTE: this is a self-identity match (is the addressed next_hop OUR last byte?), so it
// cannot be hardened with resolveLastByte() — a remote node that legitimately shares our
// last byte will also match here and rebroadcast. That residual collision needs a wider
// on-wire field to fix. M1/M2 instead shrink the blast radius by reducing how often an
// ambiguous next_hop byte is ever learned (sniffReceived) or originated (getNextHop).
if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) {
meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it
LOG_INFO("Rebroadcast received message coming from %x", p->relay_node);
@@ -194,15 +216,63 @@ std::optional<uint8_t> NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
if (isBroadcast(to))
return std::nullopt;
// Hot store first: a direct array hit on the live NodeDB entry.
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(to);
if (node && node->next_hop) {
// M3: proactively decay a stale or repeatedly-failing route back to flooding, so a dead hop
// isn't trusted on the next DM's first (and on dense meshes, slowest) attempt. We only act on
// a health record that still matches the stored byte; a next_hop set by another path (e.g.
// TraceRouteModule) with no matching record is left authoritative.
const RouteHealth *h = findRouteHealth(to);
if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, millis())) {
LOG_INFO("Next hop 0x%x for 0x%x is stale (age/fails); flood and clear", node->next_hop, to);
node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route
clearRouteHealth(to); // clear RAM health
return std::nullopt;
}
// We are careful not to return the relay node as the next hop
if (node->next_hop != relay_node) {
// LOG_DEBUG("Next hop for 0x%x is 0x%x", to, node->next_hop);
return node->next_hop;
// M1/M2: only emit a stored next_hop if its last byte still maps to a UNIQUE, currently
// reachable direct neighbor. On a dense mesh the last byte collides, so an ambiguous byte
// would unicast a hint toward the wrong physical node; if the neighbor has gone away we'd
// unicast into a void. In both cases flood instead (managed flooding still delivers).
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; set no pref", node->next_hop, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
} else
LOG_WARN("Next hop for 0x%x is 0x%x, same as relayer; set no pref", to, node->next_hop);
}
#if HAS_TRAFFIC_MANAGEMENT
// Fallback: TMM overflow cache holds confirmed hops for nodes that have aged out of the hot store.
// It is the same byte source/confidence as NodeInfoLite.next_hop, so it gets the same M1/M2/M3
// protection: decay a stale/failing route, then only emit a byte that still resolves to a unique
// reachable neighbor. Without this the overflow cache (which holds MORE bytes for MORE nodes) would
// reintroduce exactly the silent-misroute that M1/M2 closes on the hot path.
if (trafficManagementModule) {
uint8_t hint = trafficManagementModule->getNextHopHint(to);
if (hint && hint != relay_node) {
const RouteHealth *h = findRouteHealth(to);
if (h && h->lastNextHop == hint && isRouteStale(*h, millis())) {
LOG_INFO("TMM next hop 0x%x for 0x%x is stale (age/fails); flood and clear", hint, to);
trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0)
clearRouteHealth(to); // clear RAM health
return std::nullopt;
}
ResolvedNode r = nodeDB->resolveLastByte(hint, /*requireDirectNeighbor=*/true);
if (r.status == LastByteResolution::Unique) {
LOG_DEBUG("Next hop for 0x%x is 0x%x (TMM cache)", to, hint);
return hint;
}
LOG_WARN("TMM next hop 0x%x for 0x%x %s; set no pref", hint, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
}
}
#endif
return std::nullopt;
}
@@ -311,7 +381,10 @@ int32_t NextHopRouter::doRetransmissions()
if (!isBroadcast(p.packet->to)) {
if (p.numRetransmissions == 1) {
// Last retransmission, reset next_hop (fallback to FloodingRouter)
// Last retransmission: this directed delivery went un-ACKed. Record the failure
// (M3 — accumulates across DMs to age out a flapping/dead route) and reset
// next_hop so the final try falls back to FloodingRouter.
noteRouteFailure(p.packet->to);
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
// Also reset it in the nodeDB
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
@@ -319,9 +392,32 @@ int32_t NextHopRouter::doRetransmissions()
LOG_INFO("Resetting next hop for packet with dest 0x%x\n", p.packet->to);
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
}
#if HAS_TRAFFIC_MANAGEMENT
if (trafficManagementModule) {
trafficManagementModule->clearNextHop(p.packet->to);
}
#endif
FloodingRouter::send(packetPool.allocCopy(*p.packet));
} else {
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
// M4 (gated): if the route isn't proven healthy, don't spend a second directed
// attempt — start flooding one retry sooner to cut recovery latency. A verified
// route (fresh, zero recent failures) keeps the unchanged directed-retry path so
// the sparse-mesh happy path is untouched.
RouteHealth *h = findRouteHealth(p.packet->to);
bool verified = h && h->consecutiveFailures == 0 && !isRouteStale(*h, now);
if (!verified) {
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
if (sentTo)
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
FloodingRouter::send(packetPool.allocCopy(*p.packet));
} else {
NextHopRouter::send(packetPool.allocCopy(*p.packet));
}
#else
NextHopRouter::send(packetPool.allocCopy(*p.packet));
#endif
}
} else {
// Note: we call the superclass version because we don't want to have our version of send() add a new
@@ -355,3 +451,96 @@ void NextHopRouter::setNextTx(PendingPacket *pending)
printPacket("", pending->packet);
setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time
}
// ---------------------------------------------------------------------------
// M3: RAM route-health table. Bounded array with reuse-oldest eviction (same discipline as
// PacketHistory). All age comparisons use unsigned subtraction so they survive the 49.7-day millis()
// rollover. dest == 0 marks an empty slot; learnedAtMsec is normalized to 1 on write so an occupied
// slot is never read as infinitely old.
// ---------------------------------------------------------------------------
RouteHealth *NextHopRouter::findRouteHealth(NodeNum dest)
{
if (dest == 0)
return nullptr;
for (auto &h : routeHealth)
if (h.dest == dest)
return &h;
return nullptr;
}
RouteHealth *NextHopRouter::getOrAllocRouteHealth(NodeNum dest, uint32_t now)
{
if (dest == 0)
return nullptr;
RouteHealth *oldest = &routeHealth[0];
RouteHealth *freeSlot = nullptr;
for (auto &h : routeHealth) {
if (h.dest == dest)
return &h; // existing record
if (h.dest == 0) {
if (!freeSlot)
freeSlot = &h; // remember the first free slot; prefer it over evicting
continue;
}
// Track the oldest occupied slot in case the table is full (rollover-safe).
if ((uint32_t)(now - h.learnedAtMsec) > (uint32_t)(now - oldest->learnedAtMsec))
oldest = &h;
}
// Claim the free slot if there is one, else reuse the oldest. Reset before use and stamp the dest
// so the record is findable.
RouteHealth *slot = freeSlot ? freeSlot : oldest;
*slot = RouteHealth{};
slot->dest = dest;
return slot;
}
void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now)
{
if (dest == 0 || nextHop == NO_NEXT_HOP_PREFERENCE)
return;
RouteHealth *h = getOrAllocRouteHealth(dest, now);
if (!h)
return;
// A genuinely new next hop earns a clean slate; re-learning the SAME hop keeps the accumulated
// failure count so an asymmetric reverse path that keeps re-teaching a dead forward hop still ages
// out instead of resetting the counter every time.
if (h->lastNextHop != nextHop) {
h->lastNextHop = nextHop;
h->consecutiveFailures = 0;
}
h->learnedAtMsec = now ? now : 1;
}
void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now)
{
RouteHealth *h = findRouteHealth(dest);
if (!h)
return; // only routes we actually learned have health to refresh
h->consecutiveFailures = 0;
h->learnedAtMsec = now ? now : 1;
}
void NextHopRouter::noteRouteFailure(NodeNum dest)
{
RouteHealth *h = findRouteHealth(dest);
if (!h)
return; // nothing to penalize (we were flooding, or never learned a route here)
if (h->consecutiveFailures < 255)
h->consecutiveFailures++;
}
bool NextHopRouter::isRouteStale(const RouteHealth &h, uint32_t now) const
{
if (h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD)
return true;
return (uint32_t)(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC;
}
void NextHopRouter::clearRouteHealth(NodeNum dest)
{
RouteHealth *h = findRouteHealth(dest);
if (h)
*h = RouteHealth{};
}
+57
View File
@@ -43,6 +43,28 @@ struct PendingPacket {
explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions);
};
/**
* RAM-only per-destination route health. Tracks how fresh a learned next_hop is and how many
* consecutive directed deliveries to it have failed, so getNextHop() can proactively decay a stale or
* repeatedly-failing route back to flooding instead of trusting a dead hop on the next (and on dense
* meshes, slowest) attempt. Not persisted: the learned next_hop itself lives in NodeInfoLite; this is
* just freshness/failure metadata.
*/
struct RouteHealth {
NodeNum dest = 0; ///< destination this record describes; 0 == empty slot
uint32_t learnedAtMsec = 0; ///< millis() when next_hop was last (re)learned (rollover-aware)
uint8_t consecutiveFailures = 0; ///< directed deliveries to `dest` that went un-ACKed
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; ///< the relay byte this health refers to
};
// M4 (optional, off by default): when a route is not proven healthy, fall back to flooding one retry
// earlier instead of spending a second directed attempt. Trades airtime for recovery latency on dense
// meshes; leaves the sparse-mesh happy path (fresh, verified routes) unchanged. Measure on the
// simulator before enabling broadly.
#ifndef NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 0
#endif
class GlobalPacketIdHashFunction
{
public:
@@ -92,12 +114,22 @@ class NextHopRouter : public FloodingRouter
// The number of retransmissions the original sender will do
constexpr static uint8_t NUM_RELIABLE_RETX = 3;
// M3: bounded RAM route-health table (reuse-oldest eviction, like PacketHistory)
constexpr static uint8_t ROUTE_HEALTH_MAX = 32; // ~12B/slot -> ~384B
constexpr static uint32_t ROUTE_TTL_MSEC = 30UL * 60 * 1000; // re-discover a route unconfirmed for 30 min
constexpr static uint8_t ROUTE_FAILURE_THRESHOLD = 3; // consecutive un-ACKed directed deliveries -> dead
protected:
/**
* Pending retransmissions
*/
std::unordered_map<GlobalPacketId, PendingPacket, GlobalPacketIdHashFunction> pending;
/**
* Per-destination route health (M3). Bounded array, reuse-oldest eviction. RAM-only.
*/
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
/**
* Should this incoming filter be dropped?
*
@@ -142,13 +174,38 @@ class NextHopRouter : public FloodingRouter
void setNextTx(PendingPacket *pending);
// --- M3 route-health helpers (RAM-only). Protected so ReliableRouter (a subclass) can record
// delivery success, and so the unit-test shim can reach them via `using`. All take `now` where
// time matters so the decay logic is pure and testable without a clock mock. ---
/// @return the health record for `dest`, or nullptr if we hold none.
RouteHealth *findRouteHealth(NodeNum dest);
/// @return an existing record for `dest`, else a freshly claimed slot (reuse-oldest on overflow).
RouteHealth *getOrAllocRouteHealth(NodeNum dest, uint32_t now);
/// Record that we (re)learned `nextHop` for `dest`. Resets the failure count only when the hop
/// changed (so a flapping reverse-path re-learn of the same dead hop still ages out).
void noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now);
/// Record an end-to-end delivery success to `dest` (clears failures, refreshes freshness).
void noteRouteSuccess(NodeNum dest, uint32_t now);
/// Record that a directed delivery to `dest` went un-ACKed (no-op if we hold no record).
void noteRouteFailure(NodeNum dest);
/// @return true if the route is too old (TTL) or has failed too many times in a row.
bool isRouteStale(const RouteHealth &h, uint32_t now) const;
/// Forget any health record for `dest`.
void clearRouteHealth(NodeNum dest);
#ifdef PIO_UNIT_TESTING
public: // expose getNextHop to the test shim without widening production visibility
#else
private:
#endif
/**
* Get the next hop for a destination, given the relay node
* @return the node number of the next hop, 0 if no preference (fallback to FloodingRouter)
*/
std::optional<uint8_t> getNextHop(NodeNum to, uint8_t relay_node);
private:
/** Check if we should be rebroadcasting this packet if so, do so.
* @return true if we did rebroadcast */
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
+711 -54
View File
File diff suppressed because it is too large Load Diff
+126 -10
View File
@@ -11,6 +11,7 @@
#include "MeshTypes.h"
#include "NodeStatus.h"
#include "WarmNodeStore.h"
#include "concurrency/Lock.h"
#include "configuration.h"
#include "mesh-pb-constants.h"
@@ -114,6 +115,20 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n);
/// Given a packet, return how many seconds in the past (vs now) it was received
uint32_t sinceReceived(const meshtastic_MeshPacket *p);
/// Outcome of mapping a single on-wire last-byte (next_hop / relay_node) back to a full NodeNum.
/// Because the wire only carries the last byte of a 32-bit node number, the mapping is ambiguous on
/// dense meshes (the "birthday problem"). Callers must treat Ambiguous and None as "don't trust it".
enum class LastByteResolution : uint8_t {
None, ///< no relevant candidate node has this last byte
Unique, ///< exactly one relevant candidate -> `num` is valid
Ambiguous, ///< two or more relevant candidates collide on this byte
};
struct ResolvedNode {
LastByteResolution status = LastByteResolution::None;
NodeNum num = 0; ///< valid only when status == Unique
};
/// Given a packet, return the number of hops used to reach this node.
/// Returns defaultIfUnknown if the number of hops couldn't be determined.
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1);
@@ -226,9 +241,25 @@ class NodeDB
bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0);
/*
* Sets a node either favorite or unfavorite
* Sets a node either favorite or unfavorite. Returns true if the node ends
* up in the requested state; false if the node is unknown or favouriting
* was refused by the protected-node cap (MAX_NUM_NODES - 2).
*/
void set_favorite(bool is_favorite, uint32_t nodeId);
bool set_favorite(bool is_favorite, uint32_t nodeId);
/// Count of eviction-protected (favourite/ignored/manually-verified) nodes.
int numProtectedNodes() const;
/// printf-style warning emitted when setProtectedFlag() refuses a node at
/// the cap. %s = verb (favorite/ignore), 0x%08x = node, %d = cap. Shared by
/// LOG_WARN here and AdminModule::sendWarning so the wording stays in sync.
static constexpr const char *PROTECTED_CAP_WARN_FMT = "Can't %s 0x%08x: protected-node limit (%d) reached";
/// Turn an eviction-protection flag (favourite/ignored/verified) on/off. Off
/// always succeeds; on returns false (no change) once the protected set hits
/// the cap (MAX_NUM_NODES-2), keeping >=2 always-evictable slots. Callers
/// surface the refusal to the user.
bool setProtectedFlag(meshtastic_NodeInfoLite *node, uint32_t mask, bool on);
/*
* Returns true if the node is in the NodeDB and marked as favorite
@@ -295,6 +326,46 @@ class NodeDB
virtual meshtastic_NodeInfoLite *getMeshNode(NodeNum n);
size_t getNumMeshNodes() { return numMeshNodes; }
/// Find a node in our DB, create an empty NodeInfoLite if missing (evicting
/// the oldest non-protected node when full). Public so admin handlers can
/// register a node we have not heard from yet (e.g. to block it by ID).
meshtastic_NodeInfoLite *getOrCreateMeshNode(NodeNum n);
#if WARM_NODE_COUNT > 0
// Warm ("long-tail") tier: minimal {num, last_heard, public_key} records
// for nodes evicted from the hot store. See WarmNodeStore.h.
WarmNodeStore warmStore;
#endif
/// Copy the 32-byte public key for node n — hot store first, then the warm
/// tier. Returns false if we don't know a key for n.
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
/// Resolve a node's device role — hot store (with user) first, then the role
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
/// nodes that have aged out of the hot store.
meshtastic_Config_DeviceConfig_Role getNodeRole(NodeNum n);
/// last_heard of a hot-store node, or 0 if absent. Plain scan of meshNodes
/// with no allocation side effects (unlike getOrCreateMeshNode).
uint32_t hotNodeLastHeard(NodeNum n) const;
/**
* Resolve a single on-wire last-byte (e.g. next_hop / relay_node) back to a unique full NodeNum,
* detecting last-byte collisions instead of silently picking the first match. A 1-byte id only
* needs to be unique among a node's plausible relays, not the whole mesh, so we scope the search:
* - requireDirectNeighbor == true : candidates are direct neighbors (hops_away==0) heard within
* NEXTHOP_NEIGHBOR_FRESH_SECS. Use on the SEND path.
* - requireDirectNeighbor == false : also accept favorites and router-role nodes (unknown hop
* distance allowed). Use when learning / preserving hops.
* Ignored nodes, our own node, and the broadcast/0 sentinels are never candidates. On a tie the
* result is Ambiguous (no tie-break) so callers fall back to flooding rather than misroute.
*/
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
/// Convenience wrapper around resolveLastByte(): true iff exactly one relevant candidate matches.
/// Ambiguous and None both return false (the safe answer for learning / hop preservation).
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
// Thread-safe satellite-map accessors. Return false if absent or the
// corresponding DB is compiled out.
@@ -341,11 +412,15 @@ class NodeDB
emptyNodeDatabase.version = DEVICESTATE_CUR_VER;
size_t nodeDatabaseSize;
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &emptyNodeDatabase);
// Always include satellite slots so backups from higher-cap peers
// decode without truncation, even when our build excludes the DBs.
return nodeDatabaseSize + (MAX_NUM_NODES * meshtastic_NodeInfoLite_size) +
(MAX_NUM_NODES * meshtastic_NodePositionEntry_size) + (MAX_NUM_NODES * meshtastic_NodeTelemetryEntry_size) +
(MAX_NUM_NODES * meshtastic_NodeEnvironmentEntry_size) + (MAX_NUM_NODES * meshtastic_NodeStatusEntry_size);
// Decode-stream size ceiling only — no buffer this big is allocated (load
// streams from the file). Sized for the largest file any prior firmware
// could write (250-node ESP32-S3, satellites uncapped) so capacity
// downgrades / peer backups still decode; excess is trimmed after load.
// (not constexpr: portduino resolves MAX_NUM_NODES from runtime config)
const size_t loadCeiling = ((size_t)MAX_NUM_NODES > 250) ? (size_t)MAX_NUM_NODES : 250;
return nodeDatabaseSize + (loadCeiling * meshtastic_NodeInfoLite_size) +
(loadCeiling * meshtastic_NodePositionEntry_size) + (loadCeiling * meshtastic_NodeTelemetryEntry_size) +
(loadCeiling * meshtastic_NodeEnvironmentEntry_size) + (loadCeiling * meshtastic_NodeStatusEntry_size);
}
// returns true if the maximum number of nodes is reached or we are running low on memory
@@ -376,6 +451,12 @@ class NodeDB
bool checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest);
#endif
/// Consolidate crypto key generation logic used across multiple modules
/// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys.
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);
bool createNewIdentity();
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
bool restorePreferences(meshtastic_AdminMessage_BackupLocation location,
int restoreWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
@@ -424,11 +505,10 @@ class NodeDB
mutable concurrency::Lock satelliteMutex;
bool duplicateWarned = false;
bool localPositionUpdatedSinceBoot = false;
bool migrationSavePending = false;
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
uint32_t lastSort = 0; // When last sorted the nodeDB
/// Find a node in our DB, create an empty NodeInfoLite if missing
meshtastic_NodeInfoLite *getOrCreateMeshNode(NodeNum n);
/*
* Internal boolean to track sorting paused
@@ -441,9 +521,33 @@ class NodeDB
/// read our db from flash
void loadFromDisk();
#ifdef PIO_UNIT_TESTING
// Grant the unit-test shim access to the private maintenance paths below
// (migration / cleanup / eviction) without relaxing production access.
friend class NodeDBTestShim;
#endif
/// purge db entries without user info
void cleanupMeshDB();
/// Trim each satellite map down to MAX_SATELLITE_NODES, dropping the
/// stalest entries (used after loading files written before the cap, or by
/// a build with a larger cap). Returns true iff anything was trimmed.
bool enforceSatelliteCaps();
/// Node-DB self-care; call only once identity is established (getNodeNum()
/// valid). Confirms self is present, trims/demotes only NON-self overflow, and
/// rewrites the store once when something changed (never while storage locked).
void nodeDBSelfCare();
#if WARM_NODE_COUNT > 0
/// A database from a larger-cap build (e.g. the pre-fork 150-node nRF52 store)
/// can exceed MAX_NUM_NODES on load. Rank the hot store, demote the oldest
/// overflow into the warm tier preserving {num, last_heard, public_key} so PKI
/// DMs survive instead of dropping on truncation.
void demoteOldestHotNodesToWarm();
#endif
/// Reinit device state from scratch (not loading from disk)
void installDefaultDeviceState(), installDefaultNodeDatabase(), installDefaultChannels(),
installDefaultConfig(bool preserveKey), installDefaultModuleConfig();
@@ -519,7 +623,9 @@ extern uint32_t error_address;
#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT)
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT 8
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT)
// Bits 9..31 reserved for future single-bit flags.
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT 9
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK (1u << NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT)
// Bits 10..31 reserved for future single-bit flags.
// Convenience accessors so call sites read like the old struct fields.
inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n)
@@ -558,6 +664,16 @@ inline bool nodeInfoLiteIsKeyManuallyVerified(const meshtastic_NodeInfoLite *n)
{
return n && (n->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK);
}
inline bool nodeInfoLiteHasXeddsaSigned(const meshtastic_NodeInfoLite *n)
{
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK);
}
/// A node that the eviction/migration paths must not drop: a favourite, an
/// ignored (blocked) node, or a manually-verified key.
inline bool nodeInfoLiteIsProtected(const meshtastic_NodeInfoLite *n)
{
return nodeInfoLiteIsFavorite(n) || nodeInfoLiteIsIgnored(n) || nodeInfoLiteIsKeyManuallyVerified(n);
}
inline void nodeInfoLiteSetBit(meshtastic_NodeInfoLite *n, uint32_t mask, bool value)
{
+3
View File
@@ -14,6 +14,7 @@
#include "configuration.h"
#include "mesh-pb-constants.h"
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
#include "meshUtils.h"
#include <algorithm>
#include <cstring>
@@ -88,8 +89,10 @@ bool NodeDB::migrateLegacyNodeDatabase()
slim.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
strncpy(slim.long_name, legacy.user.long_name, sizeof(slim.long_name));
slim.long_name[sizeof(slim.long_name) - 1] = '\0';
sanitizeUtf8(slim.long_name, sizeof(slim.long_name)); // replace bad bytes so nanopb encode never fails
strncpy(slim.short_name, legacy.user.short_name, sizeof(slim.short_name));
slim.short_name[sizeof(slim.short_name) - 1] = '\0';
sanitizeUtf8(slim.short_name, sizeof(slim.short_name)); // same — v24 names may contain non-UTF-8 bytes
slim.hw_model = legacy.user.hw_model;
slim.role = legacy.user.role;
if (legacy.user.is_licensed)
+5 -1
View File
@@ -486,7 +486,11 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const N
}
/* Check if a certain node was a relayer of a packet in the history given iterator
* @return true if node was indeed a relayer, false if not */
* @return true if node was indeed a relayer, false if not
* NOTE: intentionally byte-domain. Both `relayer` and relayed_by[] are on-wire last bytes, so this
* answers "did a relayer with this byte touch the packet" correct without resolving to a NodeNum.
* The collision risk is neutralized where the result is consumed (route learning in
* NextHopRouter::sniffReceived now gates the write through NodeDB::resolveUniqueLastByte). */
bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, bool *wasSole)
{
bool found = false;
+49 -10
View File
@@ -12,6 +12,7 @@
#include "Channels.h"
#include "Default.h"
#include "FSCommon.h"
#include "MeshRadio.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "PacketHistory.h"
@@ -39,6 +40,17 @@
#include "Throttle.h"
#include <RTC.h>
namespace
{
constexpr uint8_t FILES_MANIFEST_LEVELS = 3;
constexpr size_t FILES_MANIFEST_MAX_COUNT = 64;
void releaseFilesManifest(std::vector<meshtastic_FileInfo> &filesManifest)
{
std::vector<meshtastic_FileInfo>().swap(filesManifest);
}
} // namespace
// Flag to indicate a heartbeat was received and we should send queue status
bool heartbeatReceived = false;
@@ -297,18 +309,31 @@ void PhoneAPI::handleStartConfig()
state = STATE_SEND_MY_INFO;
}
pauseBluetoothLogging = true;
spiLock->lock();
#if defined(MESHTASTIC_EXCLUDE_FILES_MANIFEST)
// Skip the recursive FS walk. Used by platforms whose Zephyr LittleFS
// backend can't safely traverse a deep tree (e.g. nRF54L15) and platforms
// that don't support OTA browsing — the manifest is only consumed by
// companion apps for those flows.
filesManifest.clear();
releaseFilesManifest(filesManifest);
#else
filesManifest = getFiles("/", 10);
// Manifest is never read on the node-info-only path (STATE_SEND_FILEMANIFEST
// short-circuits to sendConfigComplete), so skip the SPI lock + FS walk.
if (config_nonce != SPECIAL_NONCE_ONLY_NODES) {
bool filesManifestLimited = false;
{
concurrency::LockGuard guard(spiLock);
filesManifest = getFiles("/", FILES_MANIFEST_LEVELS, FILES_MANIFEST_MAX_COUNT, &filesManifestLimited);
}
if (filesManifestLimited) {
LOG_WARN("Got %zu files in manifest (limited to %zu entries/depth %u)", filesManifest.size(),
FILES_MANIFEST_MAX_COUNT, static_cast<unsigned>(FILES_MANIFEST_LEVELS));
} else {
LOG_DEBUG("Got %zu files in manifest", filesManifest.size());
}
} else {
releaseFilesManifest(filesManifest);
}
#endif
spiLock->unlock();
LOG_DEBUG("Got %d files in manifest", filesManifest.size());
LOG_INFO("Start API client config millis=%u", millis());
// Protect against concurrent BLE callbacks: they run in NimBLE's FreeRTOS task and also touch nodeInfoQueue.
@@ -376,8 +401,7 @@ void PhoneAPI::close()
replayPhase = REPLAY_PHASE_IDLE;
}
packetForPhone = NULL;
filesManifest.clear();
filesManifest.shrink_to_fit();
releaseFilesManifest(filesManifest);
lastPortNumToRadio.clear();
fromRadioNum = 0;
config_nonce = 0;
@@ -516,9 +540,10 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
STATE_SEND_UIDATA,
STATE_SEND_OWN_NODEINFO,
STATE_SEND_METADATA,
STATE_SEND_CHANNELS
STATE_SEND_REGION_PRESETS, // region -> valid modem presets (one message)
STATE_SEND_CHANNELS,
STATE_SEND_CONFIG,
STATE_SEND_MODULE_CONFIG,
STATE_SEND_MODULECONFIG,
STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to the client
STATE_SEND_FILEMANIFEST,
STATE_SEND_COMPLETE_ID,
@@ -636,7 +661,20 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
memset(&fromRadioScratch.metadata, 0, sizeof(fromRadioScratch.metadata));
}
#endif
state = STATE_SEND_REGION_PRESETS;
break;
case STATE_SEND_REGION_PRESETS:
// Tell the client which modem presets are legal in each region so its UI
// can block illegal region+preset combinations. This is public RF /
// regulatory information (region and modem_preset are already in the
// unauthenticated LoRa whitelist below), so it is sent unconditionally —
// even an unauthorized/locked-down client can render a correct picker.
LOG_DEBUG("Send region preset map");
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_region_presets_tag;
getRegionPresetMap(fromRadioScratch.region_presets);
state = STATE_SEND_CHANNELS;
config_state = 0; // STATE_SEND_CHANNELS indexes channels starting at 0
break;
case STATE_SEND_CHANNELS:
@@ -928,7 +966,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
// ONLY_NODES variants skip the manifest.
if (config_state == filesManifest.size() || config_nonce == SPECIAL_NONCE_ONLY_NODES) {
config_state = 0;
filesManifest.clear();
releaseFilesManifest(filesManifest);
// Skip to complete packet
sendConfigComplete();
} else {
@@ -1517,6 +1555,7 @@ bool PhoneAPI::available()
case STATE_SEND_CONFIG:
case STATE_SEND_MODULECONFIG:
case STATE_SEND_METADATA:
case STATE_SEND_REGION_PRESETS:
case STATE_SEND_OWN_NODEINFO:
case STATE_SEND_FILEMANIFEST:
case STATE_SEND_COMPLETE_ID:
+1
View File
@@ -46,6 +46,7 @@ class PhoneAPI
STATE_SEND_MY_INFO, // send our my info record
STATE_SEND_OWN_NODEINFO,
STATE_SEND_METADATA,
STATE_SEND_REGION_PRESETS, // Send the region->valid-preset map (one message)
STATE_SEND_CHANNELS, // Send all channels
STATE_SEND_CONFIG, // Replacement for the old Radioconfig
STATE_SEND_MODULECONFIG, // Send Module specific config
+19 -2
View File
@@ -16,11 +16,23 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel)
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex)
{
return getPositionPrecisionForChannel(channels.getByIndex(channelIndex));
const meshtastic_Channel &ch = channels.getByIndex(channelIndex);
if (ch.role == meshtastic_Channel_Role_DISABLED)
return 0;
uint32_t precision = getPositionPrecisionForChannel(ch);
// Never send a precise position on a publicly-decryptable channel (key check is gated on > ceiling).
if (precision > MAX_POSITION_PRECISION_PUBLIC_KEY && channels.usesPublicKey(channelIndex)) {
precision = MAX_POSITION_PRECISION_PUBLIC_KEY;
}
return precision;
}
static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
{
if (precision == 0 || precision >= 32)
return coordinate;
uint32_t coordinateBits = static_cast<uint32_t>(coordinate);
uint32_t truncated = coordinateBits & (UINT32_MAX << (32 - precision));
@@ -30,6 +42,11 @@ static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
return static_cast<int32_t>(truncated);
}
int32_t truncateCoordinate(int32_t coordinate, uint8_t precision)
{
return truncateCoordinate(coordinate, static_cast<uint32_t>(precision));
}
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision)
{
if (precision == 0) {
+15
View File
@@ -4,8 +4,23 @@
#include "meshtastic/mesh.pb.h"
#include <stdint.h>
// Max precision on a publicly-decryptable channel. CCPA "precise geolocation" = within a ~564m (1,850ft) radius.
// Precision is bit-truncation of latitude_i/longitude_i: the latitude cell stays ~constant in meters worldwide
// (~700m at 15 bits), while only the longitude cell varies — widest at the equator, narrowing toward the poles.
// 15 also matches the MQTT map-report public precision ceiling.
#define MAX_POSITION_PRECISION_PUBLIC_KEY 15
// Configured precision as-is; does NOT apply the public-key clamp -- use the channelIndex overload for the on-wire value.
uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel);
// Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable.
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex);
// Truncate a single latitude_i/longitude_i to `precision` significant bits, centered in the
// resulting grid cell (stable under GPS jitter). precision 0 or >=32 returns the value unchanged.
// The return is the coordinate (int32_t); the uint8_t overload only narrows the precision arg.
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision);
int32_t truncateCoordinate(int32_t coordinate, uint8_t precision);
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision);
bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision);
bool applyPositionPrecisionForChannel(meshtastic_MeshPacket &packet, uint8_t channelIndex);
+59 -1
View File
@@ -63,6 +63,8 @@ const RegionProfile PROFILE_HAM_20KHZ = {PRESETS_TINY, 0, 0.0022f, false, true,
// Ham '100kHz' profile. 62.5kHz bandwidth coerced to 100kHz via padding.
const RegionProfile PROFILE_HAM_100KHZ = {PRESETS_NARROW, 0, 0.01875f, false, true, 0, 1, 1};
Observable<uint32_t> RadioInterface::loraRxPacketObservable;
#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr, default_preset, \
override_slot) \
{ \
@@ -603,6 +605,62 @@ const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code)
return r;
}
void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map)
{
map = meshtastic_LoRaRegionPresetMap_init_zero;
const size_t maxGroups = sizeof(map.groups) / sizeof(map.groups[0]);
const size_t maxRegions = sizeof(map.region_groups) / sizeof(map.region_groups[0]);
const size_t maxPresets = sizeof(map.groups[0].presets) / sizeof(map.groups[0].presets[0]);
// Coalesce regions that share an identical preset list into one group. Two
// regions belong to the same group when they share the same RegionProfile
// (which owns the preset list + licensing) AND the same default preset.
// Keyed by profile pointer, not the preset-array pointer: PROFILE_NARROW and
// PROFILE_HAM_100KHZ share PRESETS_NARROW but differ in licensedOnly.
const RegionProfile *groupProfile[sizeof(map.groups) / sizeof(map.groups[0])] = {};
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) {
// No room left to map any further region; once full we can't add more, so
// log once and stop. An incomplete map means clients won't constrain the
// omitted regions, so this must be discoverable rather than silent.
if (map.region_groups_count >= maxRegions) {
LOG_ERROR("Region preset map full at %u regions; remaining regions omitted", (unsigned)maxRegions);
break;
}
// Find the group this region belongs to, or create it.
int gi = -1;
for (pb_size_t g = 0; g < map.groups_count; g++) {
if (groupProfile[g] == r->profile && map.groups[g].default_preset == r->getDefaultPreset()) {
gi = g;
break;
}
}
if (gi < 0) {
if (map.groups_count >= maxGroups) {
// Out of group slots (should not happen for the current table). The
// region can't be advertised; skip it but make the gap visible.
LOG_ERROR("Region preset map out of group slots (%u); region %d omitted", (unsigned)maxGroups, r->code);
continue;
}
gi = map.groups_count++;
groupProfile[gi] = r->profile;
meshtastic_LoRaPresetGroup &grp = map.groups[gi];
grp.default_preset = r->getDefaultPreset();
grp.licensed_only = r->profile->licensedOnly;
grp.presets_count = 0;
for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++)
grp.presets[grp.presets_count++] = r->profile->presets[i];
}
// Map this region to its group (capacity checked at the top of the loop).
meshtastic_LoRaRegionPresets &rg = map.region_groups[map.region_groups_count++];
rg.region = r->code;
rg.group_index = (uint8_t)gi;
}
}
/**
* Get duty cycle for current region. EU_866: 10% for routers, 2.5% for mobile.
*/
@@ -1365,4 +1423,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p)
sendingPacket = p;
return p->encrypted.size + sizeof(PacketHeader);
}
}
+3
View File
@@ -128,6 +128,9 @@ class RadioInterface
virtual ~RadioInterface() {}
/// Fires once per valid received LoRa packet (arg = sender NodeNum). Used e.g. to flash LED_LORA.
static Observable<uint32_t> loraRxPacketObservable;
/**
* Coerce LoRa config fields (bandwidth/spread_factor) derived from presets.
* This is used during early bootstrapping so UIs that display these fields directly remain consistent.
+4
View File
@@ -614,6 +614,10 @@ void RadioLibInterface::handleReceiveInterrupt()
printPacket("Lora RX", mp);
#ifdef LED_LORA
loraRxPacketObservable.notifyObservers(mp->from);
#endif
airTime->logAirtime(RX_LOG, rxMsec);
deliverToReceiver(mp);
+4
View File
@@ -151,6 +151,10 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas
LOG_DEBUG("Received a %s for 0x%x, stopping retransmissions", ackId ? "ACK" : "NAK", ackId);
if (ackId) {
stopRetransmission(p->to, ackId);
// M3: an end-to-end ACK proves the directed route to the ACK's sender currently works,
// so clear its failure count and refresh freshness (keeps a good route pinned).
if (!isBroadcast(getFrom(p)))
noteRouteSuccess(getFrom(p), millis());
} else {
stopRetransmission(p->to, nakId);
}
+81 -53
View File
@@ -100,51 +100,31 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
return true;
}
#if HAS_TRAFFIC_MANAGEMENT
// When router_preserve_hops is enabled, preserve hops for decoded packets that are not
// position or telemetry (those have their own exhaust_hop controls).
if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled &&
moduleConfig.traffic_management.router_preserve_hops && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
p->decoded.portnum != meshtastic_PortNum_POSITION_APP && p->decoded.portnum != meshtastic_PortNum_TELEMETRY_APP) {
LOG_DEBUG("Router hop preserved: port=%d from=0x%08x (traffic_management)", p->decoded.portnum, getFrom(p));
if (trafficManagementModule) {
trafficManagementModule->recordRouterHopPreserved();
}
return false;
}
#endif
// router_preserve_hops: not suitable right now — removed from config until
// the right heuristics for when to preserve vs. exhaust hops are established.
// #if HAS_TRAFFIC_MANAGEMENT
// if (moduleConfig.has_traffic_management &&
// moduleConfig.traffic_management.router_preserve_hops && ...) { ... }
// #endif
// For subsequent hops, check if previous relay is a favorite router
// Optimized search for favorite routers with matching last byte
// Check ordering optimized for IoT devices (cheapest checks first)
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
if (!node)
continue;
// Check 1: is_favorite (cheapest - single bit test)
if (!nodeInfoLiteIsFavorite(node))
continue;
// Check 2: has_user (cheap - single bit test)
if (!nodeInfoLiteHasUser(node))
continue;
// Check 3: role check (moderate cost - multiple comparisons)
if (!IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
continue;
}
// Check 4: last byte extraction and comparison (most expensive)
if (nodeDB->getLastByteOfNodeNum(node->num) == p->relay_node) {
// Found a favorite router match
LOG_DEBUG("Identified favorite relay router 0x%x from last byte 0x%x", node->num, p->relay_node);
// For subsequent hops, preserve hop_limit only when the previous relay is UNAMBIGUOUSLY a favorite
// router. The relay_node byte is just the last byte of a 32-bit node number, so on a dense mesh it
// collides; the old "first matching node wins" scan could preserve hops for the wrong node
// (non-deterministic, depends on NodeDB order). resolveLastByte() reports a collision instead, and
// we re-check the favorite/router predicate on the single resolved node. On ambiguity/none we
// decrement (the safe default).
NodeNum resolved = 0;
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false, &resolved)) {
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(resolved);
if (node && nodeInfoLiteIsFavorite(node) && nodeInfoLiteHasUser(node) &&
IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
LOG_DEBUG("Identified unique favorite relay router 0x%x from last byte 0x%x", resolved, p->relay_node);
return false; // Don't decrement hop_limit
}
}
// No favorite router match found, decrement hop_limit
// No unambiguous favorite router match found, decrement hop_limit
return true;
}
@@ -485,14 +465,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
bool decrypted = false;
ChannelIndex chIndex = 0;
#if !(MESHTASTIC_EXCLUDE_PKI)
// Attempt PKI decryption first
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->getMeshNode(p->from) != nullptr &&
nodeDB->getMeshNode(p->from)->public_key.size > 0 && nodeDB->getMeshNode(p->to) != nullptr &&
nodeDB->getMeshNode(p->to)->public_key.size > 0 && rawSize > MESHTASTIC_PKC_OVERHEAD) {
// Attempt PKI decryption first. The sender's key may come from the hot
// store or the warm tier (nodes evicted from the hot store keep their key
// there), so DMs from long-tail nodes still decrypt.
meshtastic_NodeInfoLite_public_key_t fromKey = {0, {0}};
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) &&
nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 &&
rawSize > MESHTASTIC_PKC_OVERHEAD) {
LOG_DEBUG("Attempt PKI decryption");
if (crypto->decryptCurve25519(p->from, nodeDB->getMeshNode(p->from)->public_key, p->id, rawSize, p->encrypted.bytes,
bytes)) {
if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) {
LOG_INFO("PKI Decryption worked!");
meshtastic_Data decodedtmp;
@@ -503,7 +485,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
decrypted = true;
LOG_INFO("Packet decrypted using PKI!");
p->pki_encrypted = true;
memcpy(&p->public_key.bytes, nodeDB->getMeshNode(p->from)->public_key.bytes, 32);
memcpy(p->public_key.bytes, fromKey.bytes, 32);
p->public_key.size = 32;
p->decoded = decodedtmp;
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
@@ -559,6 +541,38 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
if (p->decoded.has_bitfield)
p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK;
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && node->public_key.size == 32) {
p->xeddsa_signed =
crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes);
if (p->xeddsa_signed) {
// Mark this node as a signer so future unsigned packets from it are rejected
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
} else {
LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from);
return DecodeState::DECODE_FAILURE;
}
} else {
LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from);
}
} else {
// Unsigned packet — only reject the class of packet a signing node always signs:
// an unencrypted broadcast small enough to also carry a signature (see perhapsEncode()).
// Unicast packets and oversized broadcasts are never signed, so they must not be
// hard-failed here even if this node has signed before.
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
if (node && nodeInfoLiteHasXeddsaSigned(node) && isBroadcast(p->to) &&
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
return DecodeState::DECODE_FAILURE;
}
}
#endif
/* Not actually ever used.
// Decompress if needed. jm
if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) {
@@ -629,6 +643,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
p->decoded.has_bitfield = true;
p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT);
p->decoded.bitfield |= (p->decoded.want_response << BITFIELD_WANT_RESPONSE_SHIFT);
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
// Sign broadcast packets if payload + signature fits within the max Data payload.
// The actual encoded size is checked after pb_encode (TOO_LARGE).
if (!p->pki_encrypted && isBroadcast(p->to) &&
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
p->decoded.xeddsa_signature.bytes)) {
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
LOG_DEBUG("XEdDSA signed packet 0x%08x", p->id);
}
}
#endif
}
size_t numbytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_Data_msg, &p->decoded);
@@ -676,7 +702,10 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
ChannelIndex chIndex = p->channel; // keep as a local because we are about to change it
#if !(MESHTASTIC_EXCLUDE_PKI)
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to);
// Destination key from the hot store or the warm tier (evicted
// long-tail nodes keep their key there)
meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}};
bool haveDestKey = nodeDB->copyPublicKey(p->to, destKey);
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
// is not in the local nodedb
// First, only PKC encrypt packets we are originating
@@ -699,18 +728,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
return meshtastic_Routing_Error_TOO_LARGE;
// Check for a known public key for the destination
if (node == nullptr || node->public_key.size != 32) {
if (!haveDestKey) {
LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to,
p->decoded.portnum);
return meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY;
}
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) &&
memcmp(p->public_key.bytes, node->public_key.bytes, 32) != 0) {
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) && memcmp(p->public_key.bytes, destKey.bytes, 32) != 0) {
LOG_WARN("Client public key differs from requested: 0x%02x, stored key begins 0x%02x", *p->public_key.bytes,
*node->public_key.bytes);
*destKey.bytes);
return meshtastic_Routing_Error_PKI_FAILED;
}
crypto->encryptCurve25519(p->to, getFrom(p), node->public_key, p->id, numbytes, bytes, p->encrypted.bytes);
crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes);
numbytes += MESHTASTIC_PKC_OVERHEAD;
p->channel = 0;
p->pki_encrypted = true;
+30 -27
View File
@@ -52,7 +52,8 @@ void StreamAPI::writeStream()
do {
// Send every packet we can
len = getFromRadio(txBuf + HEADER_LEN);
emitTxBuffer(len);
if (len != 0 && !emitTxBuffer(len))
break;
} while (len);
}
}
@@ -169,21 +170,36 @@ int32_t StreamAPI::readStream()
/**
* Send the current txBuffer over our stream
*/
void StreamAPI::emitTxBuffer(size_t len)
bool StreamAPI::writeFrame(uint8_t *buf, size_t len)
{
if (len != 0) {
txBuf[0] = START1;
txBuf[1] = START2;
txBuf[2] = (len >> 8) & 0xff;
txBuf[3] = len & 0xff;
if (len == 0 || !canWrite)
return false;
auto totalLen = len + HEADER_LEN;
// Serialize stream writes against `emitLogRecord` so a LOG_ firing
// mid-packet-emission can't interleave bytes on the wire.
concurrency::LockGuard guard(&streamLock);
stream->write(txBuf, totalLen);
buf[0] = START1;
buf[1] = START2;
buf[2] = (len >> 8) & 0xff;
buf[3] = len & 0xff;
auto totalLen = len + HEADER_LEN;
// Serialize write-readiness checks, writes and write-failure handling
// against concurrent stream writes/close.
concurrency::LockGuard guard(&streamLock);
if (!canWriteFrame(totalLen))
return false;
size_t written = stream->write(buf, totalLen);
if (written == totalLen) {
stream->flush();
return true;
}
onFrameWriteFailed(totalLen, written);
return false;
}
bool StreamAPI::emitTxBuffer(size_t len)
{
return writeFrame(txBuf, len);
}
void StreamAPI::emitRebooted()
@@ -221,20 +237,7 @@ void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src,
size_t len =
pb_encode_to_bytes(txBufLog + HEADER_LEN, meshtastic_FromRadio_size, &meshtastic_FromRadio_msg, &fromRadioScratchLog);
if (len != 0) {
txBufLog[0] = START1;
txBufLog[1] = START2;
txBufLog[2] = (len >> 8) & 0xff;
txBufLog[3] = len & 0xff;
auto totalLen = len + HEADER_LEN;
// Serialize stream writes against `emitTxBuffer` so a packet
// emission in flight on another task doesn't interleave bytes
// with this log record.
concurrency::LockGuard guard(&streamLock);
stream->write(txBufLog, totalLen);
stream->flush();
}
writeFrame(txBufLog, len);
}
/// Hookable to find out when connection changes
@@ -249,4 +252,4 @@ void StreamAPI::onConnectionChanged(bool connected)
// received a packet in a while
powerFSM.trigger(EVENT_SERIAL_DISCONNECTED);
}
}
}
+7 -2
View File
@@ -80,7 +80,7 @@ class StreamAPI : public PhoneAPI
/**
* Send the current txBuffer over our stream
*/
void emitTxBuffer(size_t len);
bool emitTxBuffer(size_t len);
/// Are we allowed to write packets to our output stream (subclasses can turn this off - i.e. SerialConsole)
bool canWrite = true;
@@ -91,7 +91,12 @@ class StreamAPI : public PhoneAPI
/// Low level function to emit a protobuf encapsulated log record
void emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg);
virtual bool canWriteFrame(size_t frameLen) { return true; }
virtual void onFrameWriteFailed(size_t frameLen, size_t writtenLen) {}
private:
bool writeFrame(uint8_t *buf, size_t len);
/// Dedicated scratch + tx buffer for LogRecord emission.
///
/// The main packet emission path (`writeStream` -> `getFromRadio` ->
@@ -113,4 +118,4 @@ class StreamAPI : public PhoneAPI
meshtastic_FromRadio fromRadioScratchLog = {};
uint8_t txBufLog[MAX_STREAM_BUF_SIZE] = {0};
concurrency::Lock streamLock;
};
};
+1
View File
@@ -18,6 +18,7 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo
info.is_ignored = nodeInfoLiteIsIgnored(lite);
info.is_key_manually_verified = nodeInfoLiteIsKeyManuallyVerified(lite);
info.is_muted = nodeInfoLiteIsMuted(lite);
info.has_xeddsa_signed = nodeInfoLiteHasXeddsaSigned(lite);
if (lite->has_hops_away) {
info.has_hops_away = true;
+617
View File
@@ -0,0 +1,617 @@
#include "WarmNodeStore.h"
#if WARM_NODE_COUNT > 0
#include "FSCommon.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "configuration.h"
#include "power/PowerHAL.h"
#include <ErriezCRC32.h>
#include <vector>
#if defined(NRF52840_XXAA)
#include "flash/flash_nrf5x.h"
#define WARM_RING_MAGIC 0x324E5257u // "WRN2" — v2: last_heard low bits carry role + protected category
#define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" — v1: last_heard was a plain timestamp.
// v1 pages are still read on upgrade: we keep each record's identity + public key but
// DISCARD its last_heard (the old timestamp would be misread as role/protected bits).
// Records re-rank and re-learn their role on the next contact. Legacy pages convert to
// v2 naturally as the ring rotates.
// A tombstone is an entry record whose last_heard is all-ones — getTime()
// (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is
// detected via num == 0xFFFFFFFF before last_heard is ever inspected.
#define WARM_RING_TOMBSTONE 0xFFFFFFFFu
#else
// warm.dat layout: this header followed by count packed WarmNodeEntry records.
struct WarmStoreHeader {
uint32_t magic; // WARM_STORE_MAGIC
uint32_t reserved; // 0; kept so the header stays 16 B
uint16_t count; // entries persisted
uint16_t entrySize; // sizeof(WarmNodeEntry), format guard
uint32_t crc; // crc32 over count * entrySize bytes
};
static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format");
#define WARM_STORE_MAGIC 0x324D5257u // "WRM2" — v2: last_heard low bits carry role + protected category
#define WARM_STORE_MAGIC_V1 \
0x314D5257u // "WRM1" — v1: last_heard was a plain timestamp. On upgrade we keep
// identity + key but discard last_heard, then rewrite as v2.
#ifdef FSCom
static const char *warmFileName = "/prefs/warm.dat";
#endif
#endif // NRF52840_XXAA
static inline bool keyIsSet(const uint8_t key[32])
{
for (int i = 0; i < 32; i++)
if (key[i])
return true;
return false;
}
WarmNodeStore::WarmNodeStore()
{
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
entries = static_cast<WarmNodeEntry *>(ps_calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
if (!entries) {
LOG_WARN("WarmStore: PSRAM alloc failed, using heap");
entries = static_cast<WarmNodeEntry *>(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
}
#else
entries = static_cast<WarmNodeEntry *>(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
#endif
#if defined(NRF52840_XXAA)
memset(pageOf, kNoPage, sizeof(pageOf));
#endif
}
WarmNodeStore::~WarmNodeStore()
{
free(entries); // always malloc-family (calloc / ps_calloc)
entries = nullptr;
}
WarmNodeEntry *WarmNodeStore::find(NodeNum num) const
{
if (!entries || !num)
return nullptr;
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (entries[i].num == num)
return &entries[i];
return nullptr;
}
// Slot placement with the keyed-first admission policy. Shared by absorb()
// and the ring replay, so the policy is applied identically in both paths.
WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8_t *key32)
{
if (!entries || !num)
return nullptr;
const bool candidateKeyed = key32 && keyIsSet(key32);
WarmNodeEntry *slot = find(num);
const bool sameNode = slot != nullptr;
if (!slot) {
// Pick a victim: any empty slot, else the oldest keyless entry, else
// (only for keyed candidates) the oldest keyed entry.
WarmNodeEntry *oldestKeyless = nullptr, *oldestKeyed = nullptr;
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
WarmNodeEntry &e = entries[i];
if (!e.num) {
slot = &e;
break;
}
// Compare on the time bits only — the low metadata bits (role/protected) must
// not perturb LRU victim selection.
if (keyIsSet(e.public_key)) {
if (!oldestKeyed || warmTimeOf(e) < warmTimeOf(*oldestKeyed))
oldestKeyed = &e;
} else {
if (!oldestKeyless || warmTimeOf(e) < warmTimeOf(*oldestKeyless))
oldestKeyless = &e;
}
}
if (!slot)
slot = oldestKeyless ? oldestKeyless : (candidateKeyed ? oldestKeyed : nullptr);
if (!slot)
return nullptr; // store full of keyed entries and the candidate has no key
}
slot->num = num;
slot->last_heard = lastHeard;
if (candidateKeyed)
memcpy(slot->public_key, key32, 32);
else if (!sameNode)
// Repurposing a victim slot for a different node: clear its stale key.
// A keyless refresh of a node already here keeps the key we learned.
memset(slot->public_key, 0, 32);
return slot;
}
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat)
{
// Pack role + protected category into the low bits of last_heard. place() and ring
// replay store the raw word verbatim, so the metadata round-trips through flash.
const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat);
const WarmNodeEntry *slot = place(num, packed, key32);
if (!slot)
return false;
persistEntry(*slot);
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u (now %u/%u)", (unsigned)num,
keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat,
(unsigned)count(), (unsigned)capacity());
return true;
}
bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const
{
const WarmNodeEntry *e = find(num);
if (!e)
return false;
role = warmRoleOf(*e);
protectedCat = warmProtOf(*e);
return true;
}
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
{
WarmNodeEntry *e = find(num);
if (!e)
return false;
out = *e;
const int idx = static_cast<int>(e - entries);
memset(e, 0, sizeof(*e));
persistRemove(num, idx);
LOG_MIGRATION("WarmStore take(rehydrate) 0x%08x key=%d (now %u/%u)", (unsigned)num, keyIsSet(out.public_key) ? 1 : 0,
(unsigned)count(), (unsigned)capacity());
return true;
}
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
void WarmNodeStore::dumpToLog(const char *reason) const
{
if (!entries) {
LOG_MIGRATION("WarmStore dump (%s): backend not allocated", reason);
return;
}
LOG_MIGRATION("WarmStore dump (%s): %u live / %u cap ==>", reason, (unsigned)count(), (unsigned)capacity());
unsigned shown = 0;
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
const WarmNodeEntry &e = entries[i];
if (e.num == 0)
continue;
LOG_MIGRATION(" warm[%3u] 0x%08x last_heard=%u key=%d", (unsigned)i, (unsigned)e.num, (unsigned)e.last_heard,
keyIsSet(e.public_key) ? 1 : 0);
shown++;
}
LOG_MIGRATION("WarmStore dump (%s): <== end (%u entries)", reason, shown);
}
#endif // MESHTASTIC_NODEDB_MIGRATION_VERBOSE
bool WarmNodeStore::copyKey(NodeNum num, uint8_t out[32]) const
{
const WarmNodeEntry *e = find(num);
if (!e || !keyIsSet(e->public_key))
return false;
memcpy(out, e->public_key, 32);
return true;
}
bool WarmNodeStore::contains(NodeNum num) const
{
return find(num) != nullptr;
}
void WarmNodeStore::remove(NodeNum num)
{
WarmNodeEntry *e = find(num);
if (e) {
const int idx = static_cast<int>(e - entries);
memset(e, 0, sizeof(*e));
persistRemove(num, idx);
}
}
void WarmNodeStore::clear()
{
if (!entries)
return;
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
#if defined(NRF52840_XXAA)
memset(pageOf, kNoPage, sizeof(pageOf));
#endif
persistClear();
}
size_t WarmNodeStore::count() const
{
size_t n = 0;
if (entries)
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (entries[i].num)
n++;
return n;
}
bool WarmNodeStore::saveIfDirty()
{
if (!dirty)
return true;
bool ok = save();
if (ok)
dirty = false;
return ok;
}
#if defined(NRF52840_XXAA)
// Raw-flash record-ring backend (nRF52840).
// 3 × 4 KB pages below LittleFS. Mutations append 40 B records (entry snapshot,
// or tombstone with last_heard == 0xFFFFFFFF) via the shared flash_nrf5x page
// cache; saveIfDirty() is the durability point. A full page reclaims the oldest
// (stranded live entries re-appended, then erased). Flash access holds spiLock —
// the page cache is shared with InternalFS/LittleFS.
bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy) const
{
flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h));
if (h.seq == 0xFFFFFFFFu)
return false; // erased page
if (h.magic == WARM_RING_MAGIC) {
if (legacy)
*legacy = false;
return true;
}
if (h.magic == WARM_RING_MAGIC_V1) {
if (legacy)
*legacy = true; // v1 page: replay it, but discard last_heard (see WARM_RING_MAGIC_V1)
return true;
}
return false;
}
// Caller holds spiLock.
void WarmNodeStore::ringOpenPage(uint8_t page)
{
// Drop any cached state for the page before the real erase, so a later
// cache flush can't resurrect stale bytes.
flash_nrf5x_flush();
flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(page));
WarmPageHeader h;
h.magic = WARM_RING_MAGIC;
h.seq = nextSeq++;
flash_nrf5x_write(WARM_FLASH_PAGE_ADDR(page), &h, sizeof(h));
activePage = page;
writeSlot = 0;
}
// Caller holds spiLock. May recurse once via ringAppend if the stranded set
// fills the fresh page exactly — bounded by WARM_NODE_COUNT <= 2*kRecordsPerPage.
void WarmNodeStore::ringRotate()
{
uint8_t target = 0;
if (activePage != kNoPage) {
// Lowest-seq valid page, preferring erased pages; never the active one
uint32_t bestSeq = 0;
bool found = false;
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
if (p == activePage)
continue;
WarmPageHeader h;
if (!ringReadHeader(p, h)) {
target = p; // erased/invalid page: free real estate, take it
found = true;
break;
}
if (!found || static_cast<int32_t>(h.seq - bestSeq) < 0) {
target = p;
bestSeq = h.seq;
found = true;
}
}
}
// Capture live entries stranded in the page we're about to erase
int stranded[WARM_NODE_COUNT] = {};
int nStranded = 0;
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
if (entries[i].num && pageOf[i] == target)
stranded[nStranded++] = static_cast<int>(i);
if (pageOf[i] == target)
pageOf[i] = kNoPage;
}
ringOpenPage(target);
for (int k = 0; k < nStranded; k++)
ringAppend(entries[stranded[k]], stranded[k]);
}
// Caller holds spiLock.
void WarmNodeStore::ringAppend(const WarmNodeEntry &rec, int storeSlot)
{
if (activePage == kNoPage || writeSlot >= kRecordsPerPage)
ringRotate();
const uint32_t addr =
WARM_FLASH_PAGE_ADDR(activePage) + sizeof(WarmPageHeader) + static_cast<uint32_t>(writeSlot) * sizeof(WarmNodeEntry);
flash_nrf5x_write(addr, &rec, sizeof(rec));
writeSlot++;
if (storeSlot >= 0)
pageOf[storeSlot] = activePage;
dirty = true;
}
void WarmNodeStore::persistEntry(const WarmNodeEntry &e)
{
concurrency::LockGuard g(spiLock);
ringAppend(e, static_cast<int>(&e - entries));
}
void WarmNodeStore::persistRemove(NodeNum num, int storeSlot)
{
if (storeSlot >= 0 && storeSlot < static_cast<int>(WARM_NODE_COUNT))
pageOf[storeSlot] = 0xFF;
WarmNodeEntry tomb;
memset(&tomb, 0, sizeof(tomb));
tomb.num = num;
tomb.last_heard = WARM_RING_TOMBSTONE;
concurrency::LockGuard g(spiLock);
ringAppend(tomb, -1);
}
void WarmNodeStore::persistClear()
{
concurrency::LockGuard g(spiLock);
flash_nrf5x_flush();
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++)
flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(p));
activePage = 0xFF;
writeSlot = 0;
nextSeq = 1;
dirty = false; // the erased ring already reflects the empty store
}
void WarmNodeStore::load()
{
if (!entries)
return;
concurrency::LockGuard g(spiLock);
// Order valid pages by ascending seq so replay applies oldest first
uint8_t order[WARM_FLASH_PAGES] = {};
uint32_t seqs[WARM_FLASH_PAGES] = {};
bool legacyOf[WARM_FLASH_PAGES] = {}; // per-page: v1 (WRNG) → discard last_heard on replay
uint8_t nValid = 0;
uint8_t nCorrupt = 0;
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
WarmPageHeader h;
bool legacy = false;
if (!ringReadHeader(p, h, &legacy)) {
// An erased page reads back all-ones; any other magic is a
// partially-written or bit-rotted header we're dropping, so flag it
// rather than silently treating the loss as a clean empty ring.
if (h.magic != 0xFFFFFFFFu)
nCorrupt++;
continue;
}
legacyOf[p] = legacy;
uint8_t pos = nValid;
while (pos > 0 && static_cast<int32_t>(h.seq - seqs[pos - 1]) < 0) {
order[pos] = order[pos - 1];
seqs[pos] = seqs[pos - 1];
pos--;
}
order[pos] = p;
seqs[pos] = h.seq;
nValid++;
}
if (nValid == 0) {
activePage = 0xFF;
writeSlot = 0;
nextSeq = 1;
if (nCorrupt)
LOG_WARN("WarmStore: ring unreadable (%u bad page(s)), empty", nCorrupt);
else
LOG_INFO("WarmStore: ring empty, starting fresh");
return;
}
uint32_t replayed = 0;
uint32_t migrated = 0;
for (uint8_t k = 0; k < nValid; k++) {
const uint8_t p = order[k];
const bool legacy = legacyOf[p];
uint16_t slot = 0;
for (; slot < kRecordsPerPage; slot++) {
WarmNodeEntry rec;
flash_nrf5x_read(&rec, WARM_FLASH_PAGE_ADDR(p) + sizeof(WarmPageHeader) + (uint32_t)slot * sizeof(rec), sizeof(rec));
if (rec.num == 0xFFFFFFFFu)
break; // erased space: end of this page's records (append-only)
if (rec.num == 0)
continue; // unexpected; skip defensively
replayed++;
if (rec.last_heard == WARM_RING_TOMBSTONE) {
WarmNodeEntry *e = find(rec.num);
if (e) {
pageOf[e - entries] = 0xFF;
memset(e, 0, sizeof(*e));
}
} else {
// v1 (legacy) record: keep identity + key, but discard the old timestamp —
// its low bits would otherwise be misread as role/protected metadata.
uint32_t lh = rec.last_heard;
if (legacy) {
lh = 0;
migrated++;
}
const WarmNodeEntry *e = place(rec.num, lh, rec.public_key);
if (e)
pageOf[e - entries] = p;
}
}
if (k == nValid - 1) { // newest page becomes the active head
activePage = p;
writeSlot = slot;
nextSeq = seqs[k] + 1;
// If the head is a v1 page, force the next append to rotate into a fresh v2 page,
// so new (v2) records never land in a page whose header says v1 (which would make
// a later load discard their last_heard — including the role/protected we just set).
if (legacy)
writeSlot = kRecordsPerPage;
}
}
if (nCorrupt)
LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt);
if (migrated)
LOG_INFO("WarmStore: migrated %u v1 record(s) (kept key, discarded last_heard)", (unsigned)migrated);
LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(),
activePage, writeSlot);
}
bool WarmNodeStore::save()
{
if (!powerHAL_isPowerLevelSafe()) {
LOG_ERROR("Error: trying to save WarmStore on unsafe device power level.");
return false;
}
concurrency::LockGuard g(spiLock);
flash_nrf5x_flush();
return true;
}
#else // !NRF52840_XXAA --------------------
void WarmNodeStore::persistEntry(const WarmNodeEntry &e)
{
(void)e;
dirty = true;
}
void WarmNodeStore::persistRemove(NodeNum num, int storeSlot)
{
(void)num;
(void)storeSlot;
dirty = true;
}
void WarmNodeStore::persistClear()
{
dirty = true;
}
#ifdef FSCom
// ---- File persistence: /prefs/warm.dat snapshots ----------------------------
// Compact occupied slots to the front of `dst`; returns the count.
static uint16_t packEntries(const WarmNodeEntry *src, WarmNodeEntry *dst)
{
uint16_t n = 0;
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (src[i].num)
dst[n++] = src[i];
return n;
}
void WarmNodeStore::load()
{
if (!entries)
return;
// Clear first — all failure paths below then correctly represent "empty",
// even if load() is called on an already-used instance.
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
concurrency::LockGuard g(spiLock);
auto f = FSCom.open(warmFileName, FILE_O_READ);
if (!f)
return;
WarmStoreHeader h;
if ((size_t)f.read((uint8_t *)&h, sizeof(h)) != sizeof(h)) {
f.close();
LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName);
return;
}
// v1 (WRM1) is still accepted: same record size, but its last_heard was a plain
// timestamp. We keep identity + key and discard last_heard on load (see below).
const bool legacy = (h.magic == WARM_STORE_MAGIC_V1);
if ((h.magic != WARM_STORE_MAGIC && !legacy) || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
f.close();
LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic,
h.entrySize, h.count);
return;
}
if (h.count) {
const size_t len = (size_t)h.count * sizeof(WarmNodeEntry);
const bool readOk = (size_t)f.read((uint8_t *)entries, len) == len;
f.close();
if (!readOk) {
LOG_WARN("WarmStore: %s entries read failed, starting empty", warmFileName);
return;
}
// CRC covers the bytes as written (v1 still has the old last_heard), so check before migrating.
if (crc32Buffer(entries, len) != h.crc) {
LOG_WARN("WarmStore: %s CRC mismatch, starting empty", warmFileName);
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
return;
}
if (legacy) {
// Migrate v1 → v2: discard the old last_heard (its low bits would be misread as
// role/protected); keep num + public_key. Mark dirty so save() rewrites as v2.
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
if (entries[i].num)
entries[i].last_heard = 0;
dirty = true;
}
} else {
f.close();
}
LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName,
legacy ? " (v1 migrated: discarded last_heard)" : "");
}
bool WarmNodeStore::save()
{
if (!entries)
return false;
if (!powerHAL_isPowerLevelSafe()) {
LOG_ERROR("Error: trying to save WarmStore on unsafe device power level.");
return false;
}
std::vector<WarmNodeEntry> packed(WARM_NODE_COUNT);
WarmStoreHeader h;
h.magic = WARM_STORE_MAGIC;
h.reserved = 0;
h.count = packEntries(entries, packed.data());
h.entrySize = sizeof(WarmNodeEntry);
h.crc = crc32Buffer(packed.data(), h.count * sizeof(WarmNodeEntry));
concurrency::LockGuard g(spiLock);
FSCom.mkdir("/prefs");
auto f = SafeFile(warmFileName, false);
f.write((const uint8_t *)&h, sizeof(h));
f.write((const uint8_t *)packed.data(), h.count * sizeof(WarmNodeEntry));
bool ok = f.close();
if (!ok)
LOG_ERROR("WarmStore: can't write %s", warmFileName);
else
LOG_DEBUG("WarmStore: saved %u warm nodes to %s", h.count, warmFileName);
return ok;
}
#else
void WarmNodeStore::load() {}
bool WarmNodeStore::save()
{
return true;
}
#endif // FSCom
#endif // NRF52840_XXAA
#endif // WARM_NODE_COUNT > 0
+176
View File
@@ -0,0 +1,176 @@
#pragma once
#include "MeshTypes.h"
#include "mesh-pb-constants.h"
#include <stdint.h>
#include <string.h>
// Verbose tracing for the warm-store migration + NodeDB self-care. Per-event /
// per-boot chatter routes through this so it can be silenced in one place (set
// to 0) once they're proven; genuine LOG_WARN anomalies stay unconditional.
#ifndef MESHTASTIC_NODEDB_MIGRATION_VERBOSE
#define MESHTASTIC_NODEDB_MIGRATION_VERBOSE 0
#endif
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
#define LOG_MIGRATION(...) LOG_INFO(__VA_ARGS__)
#else
#define LOG_MIGRATION(...) ((void)0)
#endif
#if WARM_NODE_COUNT > 0
/**
* Warm ("long-tail") node tier.
*
* Minimal identity record (NodeNum, last_heard, Curve25519 public key) for nodes
* evicted from the hot NodeInfoLite store, so DMs to/from them keep encrypting
* the key is expensive to re-learn, the rest rebuilds from traffic in seconds.
* Flat fixed array, linear scan (only on hot-store misses), LRU by last_heard
* with keyed entries outranking keyless.
*
* Persistence: nRF52840 uses a 12 KB raw-flash record-ring below LittleFS
* (append + replay + compact-on-rotate see the backend in WarmNodeStore.cpp,
* link-guarded by nrf52840_s140_v7.ld). Everywhere else: /prefs/warm.dat.
*/
struct WarmNodeEntry {
NodeNum num; // 0 = empty slot
uint32_t last_heard; // recency for LRU ordering — see the metadata steal below
uint8_t public_key[32]; // all-zero = no key (a real key is never all-zero)
};
static_assert(sizeof(WarmNodeEntry) == 40, "WarmNodeEntry must stay 40 B — persistence format depends on it");
// Metadata packed into the low bits of last_heard.
//
// The warm tier only uses last_heard to LRU-rank evicted (long-tail) nodes, so ~minute
// recency resolution is plenty. We reclaim the low WARM_META_BITS of that field to carry
// the evicted node's device role + a protected category, at zero cost to record size
// (entry stays 40 B; no RAM/flash growth). The high bits remain a real unix-seconds
// timestamp quantised to (1 << WARM_META_BITS) seconds.
//
// Safe because: a real timestamp can never be all-ones (the tombstone sentinel) before
// 2106, and tombstones/erased flash are detected via num before last_heard is read. Only
// the LOW bits are stolen — the high (era) bits are untouched, so the time range is intact.
static constexpr uint32_t WARM_META_BITS = 6; // role(4) + protected(2)
static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x3F → 64 s quantum
static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFFC0
static constexpr uint32_t WARM_ROLE_MASK = 0x0Fu; // bits [3:0] device role (0..12)
static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [5:4] protected category
static constexpr uint32_t WARM_PROT_MASK = 0x03u;
// Protected category cached alongside role so consumers needn't re-derive the mapping.
enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 };
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot)
{
return (lastHeard & WARM_TIME_MASK) | (static_cast<uint32_t>(role) & WARM_ROLE_MASK) |
((static_cast<uint32_t>(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT);
}
inline uint32_t warmTimeOf(const WarmNodeEntry &e)
{
return e.last_heard & WARM_TIME_MASK;
}
inline uint8_t warmRoleOf(const WarmNodeEntry &e)
{
return static_cast<uint8_t>(e.last_heard & WARM_ROLE_MASK);
}
inline uint8_t warmProtOf(const WarmNodeEntry &e)
{
return static_cast<uint8_t>((e.last_heard >> WARM_PROT_SHIFT) & WARM_PROT_MASK);
}
// Gated on NRF52840_XXAA: the ring sits at 0xEA000
// valid only on the 1 MB-flash nRF52840.
#if defined(NRF52840_XXAA)
#define WARM_FLASH_PAGE_SIZE 4096u
#define WARM_FLASH_PAGES 3u
#define WARM_FLASH_REGION_BASE (0xED000u - WARM_FLASH_PAGES * WARM_FLASH_PAGE_SIZE) // 0xEA000
#define WARM_FLASH_PAGE_ADDR(i) (WARM_FLASH_REGION_BASE + (i)*WARM_FLASH_PAGE_SIZE)
#endif
class WarmNodeStore
{
public:
WarmNodeStore();
~WarmNodeStore();
WarmNodeStore(const WarmNodeStore &) = delete;
WarmNodeStore &operator=(const WarmNodeStore &) = delete;
/// Remember an evicted hot node. Keyless candidates never displace keyed
/// entries; otherwise the oldest (keyless-first) entry is replaced.
/// @param role the node's device role (meshtastic_Config_DeviceConfig_Role, 0..12)
/// @param protectedCat WarmProtected category cached for the hop-trim path
/// @return true if the node was stored or updated
bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */, uint8_t role = 0,
uint8_t protectedCat = 0);
/// Look up the cached device role + protected category for a warm node.
/// @return false if the node is not in the warm tier.
bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const;
/// Find and remove an entry (used when the node is re-admitted to the hot store).
bool take(NodeNum num, WarmNodeEntry &out);
/// Copy the 32-byte public key for a node, if we have one.
bool copyKey(NodeNum num, uint8_t out[32]) const;
bool contains(NodeNum num) const;
void remove(NodeNum num);
void clear();
size_t count() const;
size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; }
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
/// Debug: dump every live warm entry (num / last_heard / has-key) to the
/// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE.
void dumpToLog(const char *reason = "dump") const;
#endif
/// Load persisted entries (called once at boot, after the node DB loads).
void load();
/// Durability point, piggybacked on the node-database save cadence. On the
/// ring backend this flushes the shared flash page cache; on the file
/// backend it writes the warm.dat snapshot.
bool saveIfDirty();
private:
WarmNodeEntry *entries = nullptr; // WARM_NODE_COUNT slots; PSRAM on ESP32 when available
bool dirty = false;
WarmNodeEntry *find(NodeNum num) const;
// Internal slot-placement shared by absorb() and ring replay: applies the
// keyed-first admission policy without touching persistence.
WarmNodeEntry *place(NodeNum num, uint32_t lastHeard, const uint8_t *key32);
// Persistence hooks called from the mutation paths. File backend: mark
// dirty. Ring backend: append an upsert/tombstone record (+ mark dirty).
void persistEntry(const WarmNodeEntry &e); // e must point into entries[]
void persistRemove(NodeNum num, int storeSlot);
void persistClear();
#if defined(NRF52840_XXAA)
// nRF52840 raw-flash record-ring state.
struct WarmPageHeader {
uint32_t magic; // WARM_RING_MAGIC
uint32_t seq; // page generation; 0xFFFFFFFF = erased/unused
};
static_assert(sizeof(WarmPageHeader) == 8, "page header is part of the flash format");
static constexpr uint16_t kRecordsPerPage = (WARM_FLASH_PAGE_SIZE - sizeof(WarmPageHeader)) / sizeof(WarmNodeEntry); // 102
static_assert(WARM_NODE_COUNT <= 2 * ((WARM_FLASH_PAGE_SIZE - 8) / 40), "live set must fit the ring with one page reclaimed");
static constexpr uint8_t kNoPage = 0xFF; // "no page" sentinel for activePage / pageOf[]
uint8_t activePage = kNoPage; // no page opened yet (fresh/erased ring)
uint16_t writeSlot = 0; // next free record slot in the active page
uint32_t nextSeq = 1; // seq for the next page opened
uint8_t pageOf[WARM_NODE_COUNT]; // flash page holding each RAM slot's newest record; kNoPage = none
void ringAppend(const WarmNodeEntry &rec, int storeSlot /* -1 for tombstones */);
void ringRotate(); // reclaim oldest page, compacting stranded live entries
void ringOpenPage(uint8_t page); // erase + write header (seq = nextSeq++)
bool ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy = nullptr) const;
#endif
bool save();
};
#endif // WARM_NODE_COUNT > 0
+28 -2
View File
@@ -22,12 +22,38 @@ template <typename T> void ServerAPI<T>::close()
StreamAPI::close();
}
/// Check the current underlying physical link to see if the client is currently connected
/// Check the current underlying physical link to see if the client is currently
/// connected
template <typename T> bool ServerAPI<T>::checkIsConnected()
{
return client.connected();
}
template <typename T> bool ServerAPI<T>::canWriteFrame(size_t)
{
// Only a dropped link is a reason to refuse a write up front. A full transmit
// buffer (availableForWrite() == 0) is normal backpressure, not a dead socket,
// so we must not close the connection on it. A genuinely failed write is
// detected after the fact in onFrameWriteFailed().
if (!client.connected()) {
canWrite = false;
enabled = false;
LOG_WARN("TCP client disconnected before write, closing API service");
close();
return false;
}
return true;
}
template <typename T> void ServerAPI<T>::onFrameWriteFailed(size_t frameLen, size_t writtenLen)
{
canWrite = false;
enabled = false;
LOG_WARN("TCP client write short (%lu/%lu bytes), closing API service", (unsigned long)writtenLen, (unsigned long)frameLen);
close();
}
template <class T> int32_t ServerAPI<T>::runOnce()
{
if (client.connected()) {
@@ -95,4 +121,4 @@ template <class T, class U> int32_t APIServerPort<T, U>::runOnce()
waitTime = 100;
#endif
return 100; // only check occasionally for incoming connections
}
}
+2
View File
@@ -29,6 +29,8 @@ template <class T> class ServerAPI : public StreamAPI, private concurrency::OSTh
/// We override this method to prevent publishing EVENT_SERIAL_CONNECTED/DISCONNECTED for wifi links (we want the board to
/// stay in the POWERED state to prevent disabling wifi)
virtual void onConnectionChanged(bool connected) override {}
virtual bool canWriteFrame(size_t frameLen) override;
virtual void onFrameWriteFailed(size_t frameLen, size_t writtenLen) override;
virtual int32_t runOnce() override; // Check for dropped client connections
};

Some files were not shown because too many files have changed in this diff Show More