diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index cdcd43f3ae..c99af3e1d6 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -31,6 +31,35 @@ reviews:
instructions: >
meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging.
Ensure configurations include metadata found in other configs.
+ - path: test/**
+ instructions: >
+ Native C++ unit tests. The camelCase convention used in src/ does NOT apply here,
+ and this is deliberate, not drift. Two separate rules. Suite directories are
+ strictly test_[a-z0-9_]+, lowercase only. Test functions take a test_ prefix
+ followed by underscore-separated segments, and the case WITHIN a segment is free:
+ test_5byte_sequence_rejected and test_getRegion_returnsCorrectRegion_US are both
+ correct and both common. Only two things are forbidden for a test function -
+ dropping the test_ prefix, and collapsing the segments into a single camelCase
+ identifier. Rationale: bin/run-tests.sh matches suite verdict lines against
+ test_[a-z0-9_]+, so an uppercase suite directory is reported as missing and
+ downgrades the run to AMBER; RUN_TEST in test/TestUtil.h passes #func to Unity, so
+ the function name is the only attribution a CI failure carries. Do NOT raise
+ naming-convention comments on suite directories or test_* functions, and do NOT
+ flag a camelCase segment inside an otherwise underscore-separated test name.
+ Helpers and fixtures inside a suite do follow the normal src/ conventions.
+ Authoritative rule, which this entry mirrors: the "Test naming" section of
+ .github/copilot-instructions.md.
+
+ Comment length is a second deliberate exception. The one-or-two-line comment limit
+ does NOT apply to a test's header comment. The header states what is under test by
+ symbol and file, why that behavior is required, and the regression that returns if
+ the assertions are deleted or relaxed; that routinely runs past two lines and is
+ correct at whatever length it needs. Do NOT ask for a test header to be shortened,
+ condensed, or moved to the commit message, and do NOT flag it as a multi-paragraph
+ block comment. Still DO flag narrative that carries no contract - debugging journey,
+ changelog prose, restating what the assertions do - and per-case comments that
+ merely repeat the test name. Authoritative rule, which this entry mirrors: the
+ "Test comments" section of .github/copilot-instructions.md.
- path: "**/*.md"
instructions: >
Documentation does not live in this repo; it lives in
diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml
index 99f9e62cfe..4c1a2ff792 100644
--- a/.github/actions/setup-base/action.yml
+++ b/.github/actions/setup-base/action.yml
@@ -16,7 +16,7 @@ runs:
sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev lsb-release
- name: Setup Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7.0.0
with:
python-version: 3.x
cache: pip
diff --git a/.github/actions/setup-native-test/action.yml b/.github/actions/setup-native-test/action.yml
new file mode 100644
index 0000000000..247832d722
--- /dev/null
+++ b/.github/actions/setup-native-test/action.yml
@@ -0,0 +1,52 @@
+name: Setup native test build
+description: >-
+ Minimal toolchain for the native test suites. Separate from setup-native, which is shared with
+ the firmware matrix and is not the place to trim things only the test path can spare.
+
+runs:
+ using: composite
+ steps:
+ # No checkout: the caller must already have one to reference this action at all.
+ - name: Setup Python
+ uses: actions/setup-python@v7
+ with:
+ python-version: 3.x
+ cache: pip
+ cache-dependency-path: |
+ .github/actions/**
+ **.ini
+
+ - name: Install build and test dependencies
+ shell: bash
+ # C libraries are the full setup-native list: they are real link deps of the portduino HAL,
+ # the web server and the config parser. cppcheck is check_tool for `pio check`, not `pio test`.
+ run: |
+ set -euo pipefail
+ sudo apt-get -y update --fix-missing
+ sudo apt-get install -y ccache lcov \
+ libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \
+ libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev libcurl4-gnutls-dev
+
+ - name: Install PlatformIO
+ shell: bash
+ # No adafruit-nrfutil (nRF52 DFU), poetry, or meshtastic (the client, needed only by the
+ # simulator job), and no `pio upgrade` right after installing the current release.
+ run: |
+ set -euo pipefail
+ python -m pip install --upgrade pip
+ pip install -U --no-build-isolation --no-cache-dir "setuptools<72"
+ pip install -U platformio --no-build-isolation
+
+ - name: Configure ccache
+ shell: bash
+ # /usr/lib/ccache holds compiler-named symlinks, so PATH is the whole wiring. time_macros:
+ # ccache otherwise refuses any TU mentioning __DATE__/__TIME__, which BUILD_EPOCH disables.
+ run: |
+ set -euo pipefail
+ echo "/usr/lib/ccache" >> "$GITHUB_PATH"
+ {
+ echo "CCACHE_DIR=$HOME/.ccache"
+ echo "CCACHE_COMPRESS=1"
+ echo "CCACHE_MAXSIZE=400M"
+ echo "CCACHE_SLOPPINESS=time_macros"
+ } >> "$GITHUB_ENV"
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 3d20ca974f..90286936f0 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -337,7 +337,7 @@ firmware/
- **Do not zero-pad one-byte values to 8.** `next_hop`, `relay_node`, and the next-hop hint are `uint8_t` last-byte route hints, and `channel` is a one-byte hash/index - log these as `0x%x` (or `%d`). Padding a byte to `0x000000ab` falsely implies a full node number. The same goes for I2C addresses, register values, flags/bitmasks, and error/reason codes: they are not IDs, so leave them `0x%x`.
- Use `assert()` for invariants that should never fail
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
-- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
+- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior. Code under `test/` is a deliberate exception - see [Test comments](#test-comments) below.
- **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow.
- **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison).
- `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown.
@@ -352,14 +352,58 @@ firmware/
**And decide which way the sentinel should fall.** "Inactive" does not always mean "suppress". At the GPS fix-hold site `fixHoldEnds == 0` means _no hold is in force_, which is exactly when a new hold must be armed - the naive comparison it replaced was `(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, always true when nothing was armed. Guarding it with `fixHoldEnds != 0 &&` looks like this rule and inverts the site: nothing re-arms, nothing publishes, and the receiver stays powered until the search timeout. Read the surrounding logic before adding the guard. `fixHoldInForce()` in `src/gps/GPS.cpp` is the worked example - state the predicate positively, so the sentinel has an honest answer, and derive both decisions from it - with `test/test_gps_fix_hold/` pinning both directions.
+
+
+#### Test comments - a test must justify what it pins and name the regression it guards
+
+**This section is the single authoritative statement of the rule. `AGENTS.md` and `CLAUDE.md` link here and must not restate it. The one permitted copy is the `test/**` entry in `.coderabbit.yaml`, because a YAML instruction cannot follow a link; keep it in sync with this section.**
+
+The limit above rests on "the diff and commit message carry the rationale". For a test that premise is false: it is read when it fails, long after that message is out of reach, by someone deciding whether the failure is a real regression or a stale expectation. **A review comment asking a test header to be cut to one or two lines is wrong, and should be rejected rather than acted on.**
+
+The header of `test_main.cpp` states three things, at whatever length they take:
+
+- **What is under test**, by symbol and file - `fixHoldInForce()` in `src/gps/GPS.cpp`, not "the GPS logic".
+- **Why that behavior is required** - the contract being pinned.
+- **The regression guarded** - the wrong behavior that returns if these assertions are deleted or relaxed.
+
+Per-case comments stay short; add one only where an assertion turns on something non-obvious. The allowance is for the argument, not for narrative: no debugging journey, no changelog prose, no restating what the assertions do. Worked example: `test/test_gps_fix_hold/test_main.cpp`.
+
### Naming Conventions
+These apply to firmware source under `src/`. Code under `test/` is a deliberate exception - see [Test naming](#test-naming) below.
+
- Classes: `PascalCase` (e.g., `PositionModule`, `NodeDB`)
- Functions/Methods: `camelCase` (e.g., `sendOurPosition`, `getNodeNum`)
- Constants/Defines: `UPPER_SNAKE_CASE` (e.g., `MAX_INTERVAL`, `ONE_DAY`)
- Member variables: `camelCase` (e.g., `lastGpsSend`, `nodeDB`)
- Config defines: `USERPREFS_*` for user-configurable options
+
+
+#### Test naming - `test_` prefix and underscores, never one `camelCase` identifier
+
+**This section is the single authoritative statement of the rule. `AGENTS.md` and `CLAUDE.md` link here and must not restate it. The one permitted copy is the `test/**` entry in `.coderabbit.yaml`, because a YAML instruction cannot follow a link; keep it in sync with this section.**
+
+Code under `test/` does not follow the `camelCase` rule above, and this is neither drift nor an oversight - the harness and Unity both depend on it. **A review comment asking for `camelCase` on a test suite directory or a `test_*` function is wrong, and should be rejected rather than acted on.**
+
+Two distinct rules, often conflated:
+
+| Thing | Rule | Examples |
+| ------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
+| Suite directory | Strictly `test_[a-z0-9_]+` - lowercase `snake_case`, no exceptions | `test_gps_fix_hold/`, `test_admin_radio/` |
+| Test function | `test_` prefix, then `_`-separated segments. Case _within_ a segment is free | `test_5byte_sequence_rejected()`, `test_getRegion_returnsCorrectRegion_US()` |
+| Helpers/fixtures inside a suite | normal `src/` conventions | `makeFakePacket()`, `class FakeRadio` |
+
+For functions, what is fixed is the `test_` prefix and the underscores between segments - not the case inside a segment. Both `test_validateConfigRegion_unsetRegionReturnsTrue` (segment mirrors the `camelCase` symbol under test) and `test_5byte_sequence_rejected` (all lowercase) are correct and both are common in the tree. What is forbidden is dropping the prefix or collapsing the segments into a single `camelCase` identifier (`testValidateConfigRegionUnsetRegionReturnsTrue`).
+
+Why it is fixed:
+
+- **The harness discovers suites by prefix and parses their verdicts by regex.** `bin/run-tests.sh` enumerates suites with `find test -maxdepth 1 -type d -name 'test_*'`, then matches PlatformIO's per-suite result lines against `test_[a-z0-9_]+` - lowercase only. A suite directory with an uppercase letter is enumerated but never matched, so it is reported as _missing_ and the whole run downgrades from GREEN to AMBER.
+- **The function name is the failure message.** `RUN_TEST` in `test/TestUtil.h` passes `#func` to `UnityDefaultTestRun()`, `testAssertEnvironmentIntact()` and `testStateCheckpoint()`, so the identifier is the only attribution a CI log carries for a failed assertion or a dirtied sandbox. The underscores are what make it readable there; a single `camelCase` run-on is not.
+- **It is Unity's own convention**, shared with every other PlatformIO C++ project.
+
+Renaming a suite directory to `camelCase` breaks the harness's suite accounting; renaming the functions destroys the readability of CI output. Leave both alone.
+
### Key Patterns
#### Module System
@@ -632,7 +676,7 @@ The project uses GitHub Actions extensively for CI/CD. Key workflows are in `.gi
- Includes native tests and hardware-in-the-loop testing
- **`test_native.yml`** - Native platform unit tests
- - Runs `pio test -e native`
+ - Runs the `test/test_*` suites under `[env:coverage]`, sharded across a matrix. `bin/test-shards.py` derives the matrix from the tree - it groups suites into named areas, splits an area too big for one runner and packs the ones too small to fill one - so adding a suite needs no CI change. The `generate-reports` job collects every shard's JUnit report, checks the union against the canonical `test/test_*` set, and states the verdict; `Native PlatformIO Tests` is the single required check over the fan-out.
### Release Workflows
@@ -719,7 +763,7 @@ Unit tests in `test/` directory. The canonical suite count is detected on the fl
**A signal name from the runner is not a crash.** `exit(UNITY_END())` returns the failure count, and PlatformIO's native runner renders a non-zero exit code as a POSIX signal - 4 failures prints `Program received signal SIGILL`, 5 prints `SIGTRAP`, and the suite is reported `[ERRORED]` instead of `[FAILED]`. Check the exit code against the failure count before theorising about memory bugs; confirm any real crash under a debugger.
-**Suite order is randomisable.** `./bin/run-tests.sh --shuffle` runs suites in a seeded random order; `--seed ` replays one. The seed defaults to the commit SHA (deterministic per commit, varied across commits), is printed at the start and on the `RESULT:` line, and the full order is printed on failure. CI shuffles its area order the same way, seeded from `GITHUB_SHA`. A single green seed is not evidence of order independence.
+**Suite order is randomisable.** `./bin/run-tests.sh --shuffle` runs suites in a seeded random order; `--seed ` replays one. The seed defaults to the commit SHA (deterministic per commit, varied across commits), is printed at the start and on the `RESULT:` line, and the full order is printed on failure. CI seeds from `GITHUB_SHA` the same way, but its shards run in parallel, so there the seed varies which suites _share_ a shard rather than the order they run in; `pull_request` keeps the declared arrangement so a contributor's PR never goes red for a pairing they did not choose. A single green seed is not evidence of order independence.
**`-f` is not a gate.** A filtered run can pass while a full run fails, because filtering removes the suites that _create_ the state a later suite trips over. Iterate with `-f`; gate on a full run.
diff --git a/.github/nightly/hintro.html b/.github/nightly/hintro.html
new file mode 100644
index 0000000000..c8737fd414
--- /dev/null
+++ b/.github/nightly/hintro.html
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+Meshtastic Nightly v%%VERSION%%
+
+
+
+
+
+
+
+
+
MeshtasticNightly Firmware
+
+
+
+ Firmware built straight from the tip of develop, published every night.
+ Grab a .bin, .uf2 or .hex for your board below, or point the
+ Web Flasher at the nightly channel and let it pick for you.
+
+ Nightlies are untested pre-release builds and can be unstable. Only flash hardware you can afford to
+ fully erase, and back up your config first. Start with the
+ release notes — they cover what changed and how to report what you find.
+
+
+
Files
+
diff --git a/.github/nightly/houtro.html b/.github/nightly/houtro.html
new file mode 100644
index 0000000000..02529e1be7
--- /dev/null
+++ b/.github/nightly/houtro.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/.github/nightly/release_notes.md b/.github/nightly/release_notes.md
new file mode 100644
index 0000000000..972431e2c9
--- /dev/null
+++ b/.github/nightly/release_notes.md
@@ -0,0 +1,161 @@
+# Help Test 2.8 - Flash a Nightly, Send Feedback
+
+We're preparing the **2.8** release, and we need your help shaking it out on real hardware. The web flasher now has a **built-in feedback form**. Flash a nightly, use your node like you normally would, and tell us what you find. Every report on a real board moves the release forward.
+
+⚠️ 2.8 nightlies are **experimental, pre-release builds** and can be unstable. Please read the warnings and only flash hardware you can afford to fully erase. Back up your config first.
+
+## Big changes
+
+There are a number of fundamental changes to 2.8 that require a heads-up. Please review some of these higher profile changes so that you are aware of them before installing:
+
+- Node numbers (ID) are now derived from the public-key identity of the node
+- XEdDSA based packet signing
+- Reduction of long-name to 25 bytes
+- Precise position no longer allowed on known-keys (public mesh. Please use private channels for this)
+- Telemetry and position are off-by-default and must be opted into
+- Ground-up redesigned NodeDB storage and traffic management
+- Completely new allocation of LoRa Regions and presets, including ham specific carve-outs
+
+---
+
+## How to help in 3 steps
+
+1. **Flash a 2.8 nightly** from the web flasher onto supported hardware.
+2. Make sure your apps / clients are up to date with the latest release in order to support the 2.8 firmware features like packet signing.
+3. **Use it normally** for a while - send messages, move around, let it mesh, sleep, and wake. Try the features you actually rely on.
+4. **Open the feedback form in the web flasher and tell us what happened** - good, bad, or broken. "Works great on my board" is genuinely useful data too.
+
+---
+
+## Where we most need coverage
+
+The more different boards and setups we hear from, the better. Especially valuable:
+
+- **A variety of supported boards**
+- **Both radios and roles** - routers, clients, repeaters, and low-power/sleep configurations.
+- **Different regions** and channel/modem-preset combinations.
+- **Bluetooth pairing and reconnection** from Android and iOS.
+- **Upgrade paths** - flashing 2.8 over an existing 2.x install and confirming your config/keys survive.
+- **Peripherals** - GPS, screens (OLED/E-Ink/TFT), sensors, and buttons.
+
+---
+
+## What makes a feedback report actionable
+
+The feedback form captures a lot automatically, but the more of the following you include, the faster we can act:
+
+| Include | Why it helps |
+| ------------------------- | --------------------------------------------------------------------------------------------- |
+| **Exact board / variant** | Behavior is often board-specific (e.g. `rak4631`, `heltec-v3`, `t1000-e`). |
+| **Build identifier** | The nightly version / commit / date you flashed, so we can reproduce against the right build. |
+| **What you did** | Concrete steps leading up to the problem - the shorter the repro, the better. |
+| **Expected vs. actual** | What you thought would happen, and what actually happened. |
+| **How often** | Every time? Once? Only after a reboot or after X hours? |
+| **Logs / screenshots** | Serial or app logs, and photos of the screen or error, when you have them. |
+| **Environment** | Region, phone OS + app version, and anything unusual about your setup. |
+
+### Especially flag these
+
+- **Boot loops, hangs, watchdog resets, or unexpected reboots**
+- **Config, channel, or key loss** after flashing or upgrading
+- **Regressions** - something that worked on your previous (stable) firmware but is now broken
+- **Meshing / DM problems** between a 2.8 node and nodes on other firmware
+- **Power regressions** - noticeably worse battery life
+
+---
+
+## Good feedback vs. vague feedback
+
+**Vague:** "It's broken, keeps rebooting."
+
+**Actionable:** "On a `rak4631` flashed with the 2.8 nightly from , the node reboots every time I open the Android app (v2.7.15) and tap Position. Happens every time. Serial log attached, region US."
+
+---
+
+> [!NOTE]
+> Thank you for testing. Reports from real hardware - including the boring "everything works" ones - are exactly what let us promote 2.8 from nightly preview to a stable release with confidence.
+
+# Changes
+
+### Radio & mesh protocol
+
+- **Packet Signing via XEdDSA** (#10478) plus a **BaseUI signing status UI** (#10841), unsigned-packet policy hardening with test coverage (#10858), and runtime-toggleable `MESHTASTIC_LOCKDOWN` hardening for nRF52 (#10349, opt-in via #10712).
+- **Traffic Management Module** for packet forwarding - dedup, rate limiting, role-aware policing (#9358 base, #10706 dedup/rate-limit expansion, #10745 next-hop cache overflow store, #9921 congestion-aware position interval/hop-exhaustion tuning).
+- **Automatic variable hop limits** based on live mesh activity and message-size estimation (#10176).
+- **Mesh beacon** feature and admin controls (#10618 base implementation, #10839 second pass adding beacon admin/config).
+- **Noise floor** tracking with a sliding-window estimate (#9347).
+- **Hash table index for O(1) packet history lookups** - improves routing/dedup performance on busy meshes (#9499).
+- New amateur-radio regions: 70cm (#10627), 1.25m/125cm (#10638), 2m/~144MHz (#10623); EU regions merge plus Narrow/Lite region enablement for EU (#10675, #10120); LoRa region preset map for cleaner per-region defaults (#10736).
+- **TinyFast and TinySlow modem presets** added to config and menu (#10597).
+- LoRa config changes now apply live without a reboot (#9962); LoRa settings expansion and validation improvements (#9878).
+- Position privacy: direct-send position packets are clamped to channel precision (#10383), and public/known-key position precision is clamped similarly (#10665) and honors explicit channel settings to prevent location leaks (#10513).
+- Licensed operators are now prevented from rebroadcasting packets to/from unlicensed users, for regulatory compliance (#9958).
+- Packets with missing/invalid `hop_start` (pre-hop firmware) are now deprecated/blocked (#9476).
+- Spoof detection added for UDP multicast packets (#9905).
+- Low-bandwidth conversion support added to MeshRadio (#10595).
+- ATAK Plugin V2 implemented (drops legacy unishox2 compression) (#10105), including TAKTALK voice/text chat message and room data structures.
+- Ethernet HTTP/HTTPS API server ported to RP2350 + W5500 boards (#10573), plus Ethernet OTA support for RP2350/W5500 (#10136).
+- Optional `LED_LORA` indicator to show LoRa TX activity (#10465), and an LED indicator for LoRa RX (#10674).
+- GPS time sync: device now sets its clock from GPS every 30 minutes (#10737); GPS is skipped at startup if the LoRa region is unset, to save battery (#10386); GPS model/baudrate now cached across reboots to skip a full sweep (#10544).
+- Config: **position & telemetry broadcast are now opt-in** rather than always-on (#10929).
+- **Extra-repeat tolerance** - device tolerates a configurable number of heard repeats before cancelling its own rebroadcast, with tolerance suppressed when the mesh is busy or dense (recent local branch work).
+
+### UI / display - BaseUI
+
+_BaseUI is the current default graphical UI (`src/graphics/Screen.cpp`, `draw/UIRenderer`, `draw/MenuHandler`, `draw/CompassRenderer`, `draw/NotificationRenderer`, `draw/NodeListRenderer`)._
+
+- **"Ham Mode"** - first implementation (#10663).
+- **Color support for TFT-equipped nodes** (#10233).
+- Status-message display added to Favorite/NodeList screens (#9504, #10197).
+- **Hex picker** UI added for entering hex values (#10650).
+- Save/restore of frame visibility state across sessions (#10576).
+- BLE pairing PIN now shown via a proper on-screen banner (#8902).
+- Compass rendering/behavior improvements (#10166).
+- Emote handling refactor (#9896).
+
+### UI / display - InkHUD
+
+_InkHUD is the e-ink-optimized UI (`src/graphics/niche/InkHUD`)._
+
+- **Offline map tiles with zoom controls** (#10785) and general GPS UX improvements (#10846).
+- **Full touch support** for the T5 E-Paper S3 (#10286) and a full InkHUD port for the LilyGo T5 E-Paper S3 Pro (#10211).
+- **"Wipe all messages"** option (#10721).
+- T-mini E-Ink S3 support added (#9856).
+
+### UI / display - MUI
+
+_MUI is the older/legacy graphical UI menu system, still selectable alongside BaseUI (`draw/MenuHandler`'s `MuiPicker`/`switchToMUIMenu`)._
+
+- WiFi map-tile download adapted for Heltec V4 (#10011).
+
+### UI / display - shared / cross-cutting
+
+_Touches the display driver layer or more than one UI system at once._
+
+- InkHUD and BaseUI **message store unified** into a shared implementation (#10596).
+- T-mini E-Ink S3 support landed for both InkHUD and BaseUI (#9856).
+- Board-specific TFT driver support and simplified TFT ifdef chains for easier porting (#10827, #10803); faster TFT color conversion (#10814); touchscreen variant flags (`VARIANT_TOUCHSCREEN`/`ENABLE_TOUCH_INT`) added for new touch boards (#10815).
+
+## Backend / developer-facing features
+
+### Memory & stability infrastructure
+
+- **MemClass.h** - a central memory-class ladder providing fail-safe-small defaults across constrained platforms (#10901).
+- **MemAudit** - per-subsystem heap accounting reported in the boot log (#10900).
+- nRF52 heap tiers and SoftDevice RAM reservation right-sized, freeing an extra ~8KB heap arena (#10898, #10903).
+- Native Portduino malloc shim added for more realistic memory behavior in simulation (#10677).
+- Bluetooth memory freed automatically when Wi-Fi is enabled or Bluetooth is disabled (#10398); Bluetooth wait is skipped entirely when disabled (#10571).
+- NodeDB "warm store" - new persistent warm-tier node storage layer (#10705 base, #10746/#10759 right-sizing for constrained platforms, #10809 fixing associated spiLock deadlocks).
+- 2.8 NodeDB shrink/decoupling/restructuring to reduce per-node memory footprint (#10413).
+- Flash hardening, filesystem platform unification, and a write-behind LFS cache for STM32WL/nRF52 - a storage-format break (#10171).
+
+### New APIs / module-author infrastructure
+
+- **MCP server** added for interacting with Meshtastic devices and driving a testing framework / TUI, later extracted to its own repo (#10194, #10861).
+- Native "sensors" simulation support for Portduino (#10748); `PortduinoSetOptions` to override the `realhardware` flag for tests (#10157).
+- Hardfault handler added for STM32, making crashes visibly obvious in logs (#10071).
+- `BinarySemaphorePosix` implemented with proper pthread synchronization for native builds (#9895).
+- NimBLE parameter overhaul, including a fix attempt for incompatible BLE bond cleanup (#10741).
+- STM32 ADC support added to `AnalogBatteryLevel` (#9369).
+- JSON library dependency removed from firmware builds while retaining full JSON support in meshtasticd (#10152) - reduces firmware footprint for module authors relying on the JSON path.
+- Improved manual build flow / developer build ergonomics (#8839).- External Notifications module logic fully reworked for more flexible notification rules (#10006).
diff --git a/.github/workflows/build_one_target.yml b/.github/workflows/build_one_target.yml
index b6ec92edb7..b66d701fb8 100644
--- a/.github/workflows/build_one_target.yml
+++ b/.github/workflows/build_one_target.yml
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- - uses: actions/setup-python@v6
+ - uses: actions/setup-python@v7.0.0
with:
python-version: 3.x
cache: pip
diff --git a/.github/workflows/build_windows_bin.yml b/.github/workflows/build_windows_bin.yml
index 3f0515ea6c..40d878cced 100644
--- a/.github/workflows/build_windows_bin.yml
+++ b/.github/workflows/build_windows_bin.yml
@@ -54,7 +54,7 @@ jobs:
git
- name: Setup Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7.0.0
with:
python-version: "3.14"
cache: pip
diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml
index 59cc44c8c8..10281b6bd0 100644
--- a/.github/workflows/main_matrix.yml
+++ b/.github/workflows/main_matrix.yml
@@ -35,7 +35,8 @@ on:
#- "**.yml"
schedule:
- # Nightly develop build, published to meshtastic.github.io firmware-nightly/ (no GitHub release).
+ # Nightly develop build, published to the meshtastic-firmware-nightly R2
+ # bucket (no GitHub release).
# Scheduled runs execute on the default branch (develop). 07:00 UTC avoids the 00:00 tests
# and 02:00 daily_packaging crons.
- cron: 0 7 * * * # Nightly develop build/publish (default branch is develop)
@@ -44,7 +45,7 @@ on:
inputs:
# trunk-ignore(checkov/CKV_GHA_7): intentional manual-test switch for the nightly publish path
nightly:
- description: "Nightly mode: build + publish develop to github.io firmware-nightly/ (skips creating a GitHub release)"
+ description: "Nightly mode: build + publish develop to the nightly R2 bucket (skips creating a GitHub release)"
type: boolean
default: false
@@ -58,7 +59,7 @@ jobs:
with:
# Needed to diff against the base branch for newly added variants.
fetch-depth: 0
- - uses: actions/setup-python@v6
+ - uses: actions/setup-python@v7.0.0
with:
python-version: 3.x
cache: pip
@@ -73,8 +74,11 @@ jobs:
# first env of each ADDED variant config. A new env in an existing one does not count.
DIFF_BASE=""
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
- git fetch --no-tags --depth=1 origin "$BASE_REF"
- DIFF_BASE=$(git merge-base FETCH_HEAD HEAD)
+ # checkout above already fetched every branch (fetch-depth: 0), so the base ref
+ # is local. Do not re-fetch it with --depth=1: that grafts the fetched tip as
+ # parentless, and merge-base then finds no common commit whenever the base
+ # branch has moved past this merge commit, which is every re-run of an older run.
+ DIFF_BASE=$(git merge-base "origin/$BASE_REF" HEAD)
elif [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
DIFF_BASE="$MERGE_GROUP_BASE_SHA"
fi
@@ -497,7 +501,7 @@ jobs:
fetch-depth: 0
- name: Setup Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7.0.0
with:
python-version: 3.x
@@ -564,8 +568,8 @@ jobs:
path: firmware-${{ needs.version.outputs.long }}.json
- name: Add sources to GitHub Release
- # Only run when targeting master branch with workflow_dispatch
- if: github.ref_name == 'master'
+ # Only run when targeting the default branch with workflow_dispatch
+ if: github.ref_name == github.event.repository.default_branch
run: |
gh release upload v${{ needs.version.outputs.long }} ./firmware-${{ needs.version.outputs.long }}.json
gh release upload v${{ needs.version.outputs.long }} ./output/meshtasticd-${{ needs.version.outputs.deb }}-src.zip
@@ -596,7 +600,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7.0.0
with:
python-version: 3.x
@@ -631,8 +635,8 @@ jobs:
run: ls -lR
- name: Add bins and debug elfs to GitHub Release
- # Only run when targeting master branch with workflow_dispatch
- if: github.ref_name == 'master'
+ # Only run when targeting the default branch with workflow_dispatch
+ if: github.ref_name == github.event.repository.default_branch
run: |
gh release upload v${{ needs.version.outputs.long }} ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip
gh release upload v${{ needs.version.outputs.long }} ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip
@@ -646,6 +650,7 @@ jobs:
env:
targets: |-
esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32
+ r2_bucket: meshtastic-firmware-release
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -653,7 +658,7 @@ jobs:
fetch-depth: 0
- name: Setup Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7.0.0
with:
python-version: 3.x
@@ -694,10 +699,39 @@ jobs:
commit_message: ${{ needs.version.outputs.long }}
enable_jekyll: true
- # Nightly publish: refresh the single, stable firmware-nightly/ folder on
- # meshtastic.github.io with the current develop build. Runs on the cron schedule
- # (or a manual nightly=true dispatch) and never creates a GitHub release. The
- # folder's release_notes.md is maintained by hand and deliberately left untouched.
+ # Mirror the same staged directory to Cloudflare R2, under a version prefix
+ # at the bucket root. Additive (no --delete), matching keep_files:true
+ # above: release_channels.yml later publishes an updated release_notes.md
+ # into this same prefix, and a --delete sync on a re-run would remove it.
+ - name: Publish firmware to Cloudflare R2
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+ # R2 is single-region; the S3 API still requires a region to be set.
+ AWS_DEFAULT_REGION: auto
+ # Cloudflare's documented aws-cli settings for R2. Left at the AWS
+ # default, CLI >= 2.23 attaches CRC32 request checksums that R2 can
+ # reject, so both of these must stay at when_required.
+ AWS_REQUEST_CHECKSUM_CALCULATION: when_required
+ AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
+ R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
+ # Same event/* prefixing as the github.io publish above.
+ DEST_PREFIX: ${{ contains(github.ref_name, 'event/') && format('{0}/', github.ref_name) || '' }}
+ VERSION: ${{ needs.version.outputs.long }}
+ run: |
+ set -euo pipefail
+ aws --version
+ # Cache for 1 day in browser, 1 month on CDN.
+ aws s3 sync ./publish "s3://${r2_bucket}/${DEST_PREFIX}${VERSION}/" \
+ --endpoint-url "$R2_ENDPOINT" \
+ --no-progress \
+ --metadata "commit=${{ github.sha }},run=${{ github.run_id }},version=${VERSION}" \
+ --cache-control 'public, max-age=86400, s-maxage=2592000'
+
+ # Nightly publish: refresh the root of the meshtastic-firmware-nightly R2
+ # bucket with the current develop build. Runs on the cron schedule (or a manual
+ # nightly=true dispatch) and never creates a GitHub release. The bucket's
+ # release_notes.md is published from .github/nightly/ in this repo.
publish-nightly:
runs-on: ubuntu-24.04
if: github.repository_owner == 'meshtastic' && (github.event_name == 'schedule' || github.event.inputs.nightly == 'true')
@@ -705,7 +739,13 @@ jobs:
env:
targets: |-
esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32
+ r2_bucket: meshtastic-firmware-nightly
steps:
+ # Only the index templates and release notes are needed here.
+ - uses: actions/checkout@v7
+ with:
+ sparse-checkout: .github/nightly
+
- name: Get firmware artifacts
uses: actions/download-artifact@v8
with:
@@ -728,43 +768,89 @@ jobs:
'{version: $ver, id: ("v" + $ver), title: ("Meshtastic Firmware " + $ver + " Nightly"), commit: $sha}' \
> ./stage/index.json
- - name: Preserve manually-maintained release notes
- # firmware-nightly/release_notes.md is edited by hand. Carry the current
- # copy into ./stage so the keep_files:false publish (which refreshes the
- # folder and clears stale nightly binaries) does not drop it. Seed a
- # placeholder on the first run (404); fail closed on any other error so a
- # transient fetch failure never clobbers the notes.
- run: |
- set -euo pipefail
- url=https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-nightly/release_notes.md
- code=$(curl -sSL -o ./stage/release_notes.md -w '%{http_code}' --retry 5 --retry-all-errors "$url" || echo 000)
- if [ "$code" = "200" ]; then
- echo "Preserved existing release_notes.md"
- elif [ "$code" = "404" ]; then
- echo "No existing release_notes.md; seeding placeholder"
- printf '# Nightly (develop)\n\nAutomated nightly build from the `develop` branch. Edit these notes by hand.\n' > ./stage/release_notes.md
- else
- echo "Unexpected HTTP $code fetching release_notes.md; refusing to publish to avoid clobbering manual notes"
- exit 1
- fi
+ # The notes are maintained in-tree and published to the bucket root.
+ - name: Stage the nightly release notes
+ run: cp .github/nightly/release_notes.md ./stage/release_notes.md
# For diagnostics
- name: Display structure of files to publish
run: ls -lR ./stage
- - name: Publish nightly to meshtastic.github.io
- uses: peaceiris/actions-gh-pages@v4
- with:
- deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }}
- external_repository: meshtastic/meshtastic.github.io
- publish_branch: master
- publish_dir: ./stage
- # keep_files:false is scoped to destination_dir, so this refreshes only
- # firmware-nightly/ (clearing stale nightly binaries) while sibling
- # release folders stay untouched; release_notes.md is carried in above.
- destination_dir: firmware-nightly
- keep_files: false
- user_name: github-actions[bot]
- user_email: github-actions[bot]@users.noreply.github.com
- commit_message: Nightly ${{ needs.version.outputs.long }}
- enable_jekyll: true
+ - name: Verify the staged nightly is not empty
+ # The publish below refreshes the bucket in place (the R2 sync runs with
+ # --delete), so an empty ./stage would clear the live nightly rather than
+ # replace it. A failed or pattern-mismatched artifact download is the way
+ # that happens, so fail closed here instead.
+ run: |
+ set -euo pipefail
+ images=$(find ./stage -type f \
+ \( -name 'firmware-*.bin' -o -name 'firmware-*.uf2' -o -name 'firmware-*.hex' \) | wc -l)
+ echo "Staged firmware images: $images"
+ if [ "$images" -eq 0 ]; then
+ echo "::error::No firmware images in ./stage; refusing to publish an empty nightly."
+ exit 1
+ fi
+
+ # tree renders the file listing; .github/nightly wraps it in the page
+ # chrome. tree silently skips an --hintro/--houtro file it cannot open, so
+ # the rendered page is checked for both markers below.
+ - name: Generate nightly html index
+ env:
+ VERSION: ${{ needs.version.outputs.long }}
+ COMMIT: ${{ github.sha }}
+ RUN_ID: ${{ github.run_id }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ working-directory: ./stage
+ run: |
+ set -euo pipefail
+ # Substitute placeholders in the nightly index header template.
+ sed -e "s|%%VERSION%%|$VERSION|g" \
+ -e "s|%%COMMIT%%|$COMMIT|g" \
+ -e "s|%%COMMIT_SHORT%%|${COMMIT:0:7}|g" \
+ -e "s|%%BUILD_DATE%%|$(date -u +'%Y-%m-%d %H:%M UTC')|g" \
+ -e "s|%%RUN_ID%%|$RUN_ID|g" \
+ -e "s|%%RUN_URL%%|$RUN_URL|g" \
+ "$GITHUB_WORKSPACE/.github/nightly/hintro.html" > "$RUNNER_TEMP/hintro.html"
+ # Render the html index using tree, with custom header and footer templates.
+ tree -H "." -h --noreport -I "index.html" --charset utf-8 \
+ --hintro "$RUNNER_TEMP/hintro.html" \
+ --houtro "$GITHUB_WORKSPACE/.github/nightly/houtro.html" \
+ > index.html
+
+ # Mirror the staged directory to Cloudflare R2. --delete at the bucket root
+ # clears stale nightly binaries, and is in scope for the whole bucket because
+ # this bucket holds nothing but the nightly build.
+ - name: Publish nightly to Cloudflare R2
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+ # R2 is single-region; the S3 API still requires a region to be set.
+ AWS_DEFAULT_REGION: auto
+ # Cloudflare's documented aws-cli settings for R2. Left at the AWS
+ # default, CLI >= 2.23 attaches CRC32 request checksums that R2 can
+ # reject, so both of these must stay at when_required.
+ AWS_REQUEST_CHECKSUM_CALCULATION: when_required
+ AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
+ R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
+ run: |
+ set -euo pipefail
+ aws --version
+ # Cache for 1 hour in browser, 1 day on CDN.
+ aws s3 sync ./stage "s3://${r2_bucket}/" \
+ --endpoint-url "$R2_ENDPOINT" \
+ --no-progress \
+ --delete \
+ --exclude 'index.json' \
+ --exclude 'index.html' \
+ --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \
+ --cache-control 'public, max-age=3600, s-maxage=86400'
+ # The indices point at whatever the current nightly is - index.json for
+ # clients, index.html for browsers - so they are excluded from the sync
+ # above and uploaded here with a 5 minute cache instead.
+ for index in index.json index.html; do
+ aws s3 cp "./stage/$index" "s3://${r2_bucket}/$index" \
+ --endpoint-url "$R2_ENDPOINT" \
+ --no-progress \
+ --metadata "commit=${{ github.sha }},run=${{ github.run_id }}" \
+ --cache-control 'public, max-age=300'
+ done
diff --git a/.github/workflows/package_pio_deps.yml b/.github/workflows/package_pio_deps.yml
index bf2576a53e..ec42634169 100644
--- a/.github/workflows/package_pio_deps.yml
+++ b/.github/workflows/package_pio_deps.yml
@@ -28,7 +28,7 @@ jobs:
submodules: recursive
- name: Setup Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7.0.0
with:
python-version: 3.x
diff --git a/.github/workflows/release_channels.yml b/.github/workflows/release_channels.yml
index f301030e42..719e0ce623 100644
--- a/.github/workflows/release_channels.yml
+++ b/.github/workflows/release_channels.yml
@@ -93,11 +93,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
with:
- # Always use master branch for version bumps
- ref: master
+ # Always use the default branch for version bumps
+ ref: ${{ github.event.repository.default_branch }}
- name: Setup Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7.0.0
with:
python-version: 3.x
diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml
index 2167e29564..4af381abd0 100644
--- a/.github/workflows/test_native.yml
+++ b/.github/workflows/test_native.yml
@@ -5,22 +5,32 @@ on:
inputs:
suite_order_seed:
description: >-
- Seed for shuffling the test-area order. Empty (the default) means: fixed declared order on
- pull_request, so a contributor's PR never turns red because of an order they did not
- choose; commit-SHA-derived elsewhere. Set a number to force that exact order anywhere -
- that is how you replay a shuffled failure.
+ Seed varying which suites share a shard. Empty (the default) means: the fixed declared
+ arrangement on pull_request, so a contributor's PR never turns red because of a pairing
+ they did not choose; commit-SHA-derived elsewhere. Set a number to force that exact
+ arrangement anywhere - that is how you replay a shuffled failure.
type: string
required: false
default: ""
+ max_suites_per_shard:
+ description: >-
+ Largest shard, in suites. Lower splits the matrix further: faster wall clock, more
+ runners. The floor per shard is checkout + toolchain + one src build, so below about 8
+ the fixed cost starts to dominate what is being parallelised.
+ type: number
+ required: false
+ default: 10
workflow_dispatch:
permissions: {}
env:
- # Only pushes to the default branch (develop) populate the cache; PR / merge_group runs
+ # Only pushes to the default branch (develop) populate the caches; PR / merge_group runs
# restore it but never save, so they stop filling up the repo's Actions cache storage.
SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }}
- LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}"
+ # No --directory: callers add it, since shards capture from .pio/build//src. Keeping the
+ # include/exclude filters shared is what stops a shard capturing a different file set.
+ LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --base-directory "${PWD}"
jobs:
# Tripwire against the native suite set shrinking by accident. `platformio test` discovers and
@@ -184,7 +194,7 @@ jobs:
shell: bash
run: |
sudo apt-get install -y lcov
- lcov ${{ env.LCOV_CAPTURE_FLAGS }} --initial --output-file coverage_base.info
+ lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory .pio/build/coverage/src --initial --output-file coverage_base.info
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
- name: Config check tests
@@ -234,12 +244,12 @@ jobs:
- name: Capture coverage information
if: always() # run this step even if previous step failed
run: |
- lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name integration --output-file coverage_integration.info
+ lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory .pio/build/coverage/src --test-name integration --output-file coverage_integration.info
sed -i -e "s#${PWD}#.#" coverage_integration.info # Make paths relative.
- name: Get release version string
if: always() # run this step even if previous step failed
- run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
+ run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT"
id: version
- name: Save coverage information
@@ -250,24 +260,102 @@ jobs:
overwrite: true
path: ./coverage_*.info
- platformio-tests:
- name: Native PlatformIO Tests
- runs-on: ubuntu-24.04-arm
+ # bin/test-shards.py derives the matrix from test/, so adding a suite needs no CI change.
+ # Cheap by design: a checkout and a python run, sitting on every shard critical path.
+ discover:
+ name: Native Test Shards
+ # ubuntu-latest, not the slim image: this needs a python3 to run bin/test-shards.py, and it is
+ # on the critical path of every shard, so it must not have to install one.
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ matrix: ${{ steps.shards.outputs.matrix }}
+ suites: ${{ steps.shards.outputs.suites }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
- submodules: recursive
+ persist-credentials: false
- - name: Setup native build
+ - name: Build the shard matrix
+ id: shards
+ shell: bash
+ # Both inputs reach the script through env: rather than ${{ }} inside run:, so nothing from
+ # the event payload is ever spliced into the shell text.
+ env:
+ SUITE_ORDER_SEED: ${{ inputs.suite_order_seed }}
+ MAX_SUITES: ${{ inputs.max_suites_per_shard || 10 }}
+ EVENT_NAME: ${{ github.event_name }}
+ run: |
+ set -euo pipefail
+
+ # pull_request keeps the declared arrangement so a PR never reds for a pairing its author did not
+ # choose; elsewhere the SHA seeds it. Varies co-location, not order within a shard.
+ if [ -n "${SUITE_ORDER_SEED:-}" ]; then
+ seed="$SUITE_ORDER_SEED"
+ echo "shard arrangement: shuffled with explicitly supplied seed $seed"
+ elif [ "${EVENT_NAME:-}" = "pull_request" ]; then
+ seed=""
+ echo "shard arrangement: declared order (pull_request)"
+ echo " to exercise a different arrangement, re-run this workflow with a suite_order_seed input"
+ else
+ seed=$((16#${GITHUB_SHA:0:8}))
+ echo "shard arrangement: shuffled with seed $seed (from ${GITHUB_SHA:0:8})"
+ fi
+
+ matrix=$(./bin/test-shards.py --max-suites "$MAX_SUITES" --seed "$seed" --summary)
+ # The canonical suite set, passed to the collector so its whole-run gate checks against
+ # the same walk this matrix was built from rather than a second one.
+ suites=$(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort | tr '\n' ' ')
+
+ # A newline in a value writes a second entry, setting outputs this step never declared. Both feed
+ # control flow, so assert single-line rather than assume it.
+ for value in "$matrix" "$suites"; do
+ if [ "$value" != "${value%%$'\n'*}" ]; then
+ echo "::error title=Multi-line step output::bin/test-shards.py or the suite walk produced a value spanning lines. Refusing to write it to \$GITHUB_OUTPUT - a newline there sets outputs this step did not declare."
+ exit 1
+ fi
+ done
+ [ -n "$matrix" ] && [ -n "$suites" ] || {
+ echo "::error title=Empty shard matrix::the matrix or the suite list came out empty; a downstream gate that expects nothing passes on anything."
+ exit 1
+ }
+
+ echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
+ echo "suites=$suites" >> "$GITHUB_OUTPUT"
+
+ # No build-then-run split: PlatformIO relinks each suite regardless, so the warm build bought a
+ # shared src build and ~75 throwaway links. ccache carries src objects between shards instead.
+ platformio-tests:
+ name: Suites (${{ matrix.shard }})
+ needs: discover
+ runs-on: ubuntu-24.04-arm
+ # Measured cold-cache shards run 5-12 minutes. Without this a hung suite, or a runner that
+ # stops reporting, holds a runner until GitHub's 6-hour default - times twelve shards.
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ # fail-fast off: a cancelled sibling stops the collector telling "failed" from "never ran".
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.discover.outputs.matrix) }}
+ steps:
+ # No submodules: src/mesh/generated is tracked and meshtestic is the hardware harness, so neither
+ # is reachable from .
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+
+ - name: Setup native test build
id: base
- uses: ./.github/actions/setup-native
+ uses: ./.github/actions/setup-native-test
- name: Get release version string
- run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
+ run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT"
id: version
- # Disable (comment-out) BUILD_EPOCH. It causes a full rebuild between tests and resets the
- # coverage information each time.
+ # Disable (comment-out) BUILD_EPOCH. It forces a full rebuild between tests, resets coverage each
+ # time, and would put a fresh timestamp in every TU, which is also what would defeat ccache.
- name: Disable BUILD_EPOCH
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
@@ -280,203 +368,163 @@ jobs:
restore-keys: |
pio-coverage-tests-
- - name: Warm the shared test build
- # Compiles src + every test program once so no single area absorbs the whole src build in
- # its reported duration; gcov then accumulates counts into this shared
- # .pio/build/coverage/src as the areas run. NOT a substitute for building in the run step:
- # PlatformIO links every test program to the one .pio/build/coverage/meshtasticd path, so a
- # --without-building run executes whichever suite was linked last under every suite's name.
- run: platformio test -e coverage --without-testing
+ # Every shard compiles the same ~450 src TUs; unshared, that is the cost of fanning out.
+ - name: Restore ccache
+ id: ccache-restore
+ uses: actions/cache/restore@v6
+ with:
+ path: ~/.ccache
+ # One lineage for all shards: src objects dominate and are identical. run_id only makes each save
+ # a fresh entry; the prefix restore-key selects the newest.
+ key: ccache-native-tests-${{ github.run_id }}
+ restore-keys: |
+ ccache-native-tests-
+
+ - name: Run this shard's suites
+ id: run
+ shell: bash
+ # Suite names come from bin/test-shards.py, which refuses any name outside
+ # ^test_[A-Za-z0-9_]+$ - so the word-split below cannot pick up shell metacharacters.
+ env:
+ PIO_ENV: ${{ matrix.env }}
+ SHARD: ${{ matrix.shard }}
+ SUITES: ${{ matrix.suites }}
+ run: |
+ set -uo pipefail
+ # read -ra, not an unquoted expansion: word-splits without letting a token glob against
+ # the workspace. bin/test-shards.py holds every name to ^test_[A-Za-z0-9_]+$ as well.
+ read -ra suites <<<"$SUITES"
+ filters=()
+ for suite in "${suites[@]}"; do filters+=(-f "$suite"); done
+ echo "shard $SHARD: ${#suites[@]} suite(s) under [env:$PIO_ENV] -> $SUITES"
+
+ # Log to a file for platformio real exit status, then drop the per-variant SKIPPED rows: suites
+ # outside this shard are reported SKIPPED by design.
+ rc=0
+ platformio test -e "$PIO_ENV" -v "${filters[@]}" \
+ --junit-output-path "testreport-$SHARD.xml" > shard.log 2>&1 || rc=$?
+ grep -v "[[:space:]]SKIPPED$" shard.log || true
+ exit $rc
+
+ - name: Verify this shard ran its own tests
+ # Not conditional on the run passing: a suite that reported another suite's test cases is a
+ # different, worse finding than a failing assertion, and it must not be hidden behind one.
+ if: always()
+ env:
+ SHARD: ${{ matrix.shard }}
+ SUITES: ${{ matrix.suites }}
+ run: ./bin/check-test-attribution.py --label "shard $SHARD" --expect "$SUITES" "testreport-$SHARD.xml"
+
+ - name: Capture coverage information
+ if: always() # run this step even if previous step failed
+ env:
+ PIO_ENV: ${{ matrix.env }}
+ SHARD: ${{ matrix.shard }}
+ run: |
+ sudo apt-get install -y lcov
+ # One tracefile per shard; the collector sums them with --add-tracefile into the union.
+ lcov ${{ env.LCOV_CAPTURE_FLAGS }} --directory ".pio/build/$PIO_ENV/src" \
+ --test-name "$SHARD" --output-file "coverage_tests_$SHARD.info"
+ sed -i -e "s#${PWD}#.#" "coverage_tests_$SHARD.info" # Make paths relative.
+
+ - name: ccache statistics
+ # Printed, not asserted. A cache that has silently stopped hitting shows up here as the
+ # shards getting slower, which is the symptom worth being able to explain.
+ if: always()
+ run: ccache --show-stats || true
+
+ - name: Save ccache
+ # Exactly one shard saves (cache_writer): every shard needs the same src objects, and letting all
+ # of them save would race for the key.
+ if: always() && env.SAVE_CACHE == 'true' && matrix.cache_writer
+ uses: actions/cache/save@v6
+ with:
+ path: ~/.ccache
+ key: ccache-native-tests-${{ github.run_id }}
- name: Save PlatformIO cache
- if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true'
+ if: env.SAVE_CACHE == 'true' && matrix.cache_writer && steps.pio-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: ~/.platformio/.cache
key: pio-coverage-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
- - name: Run tests one area at a time
- shell: bash
- # Both values reach the script through env: rather than ${{ }} inside run:, so nothing from
- # the event payload is ever spliced into the shell text.
- env:
- SUITE_ORDER_SEED: ${{ inputs.suite_order_seed }}
- EVENT_NAME: ${{ github.event_name }}
- run: |
- set -uo pipefail
- # One runner, no matrix, no concurrency. Group the test_* suites by area and run each
- # area sequentially, reusing the single build above (--without-building). Each area gets
- # its own JUnit report and its own collapsible log, so a failure lands in a small named
- # section instead of being buried past the log limit. Sequential runs share one build
- # dir, so gcov coverage accumulates and the single capture in the next step has the union.
-
- # Ordered area rules "name:ERE"; first match wins. Anything unmatched falls to "misc", so
- # a newly added suite always runs even before it is placed. Add a suite to an area by
- # extending that area's regex; add a new area by inserting a rule line.
- area_rules=(
- "admin:^test_(admin|pki)_"
- "crypto:^test_(crypto|packet_signing)$"
- "routing:^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_"
- "position:^test_position_"
- "fuzz:^test_fuzz_"
- "packets:^test_(packet|transmit|meshpacket)_"
- "io:^test_(serial|stream|xmodem|http|mqtt)"
- )
-
- mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
- declare -A group
- for s in "${suites[@]}"; do
- a="misc"
- for rule in "${area_rules[@]}"; do
- if [[ "$s" =~ ${rule#*:} ]]; then a="${rule%%:*}"; break; fi
- done
- group[$a]="${group[$a]:-} -f $s"
- done
-
- run_order=()
- for rule in "${area_rules[@]}"; do run_order+=("${rule%%:*}"); done
- run_order+=("misc")
-
- # Area order. The rule order above is an accident of how the areas were written, and
- # running it fixed forever means order dependence between areas is never observed - but
- # randomising it on a contributor's PR would turn their run red for an order they did not
- # choose, which is how a randomisation gets reverted instead of the coupling fixed.
- #
- # So: pull_request keeps the fixed declared order. Everywhere else (push, schedule) the
- # order is shuffled, seeded from the commit SHA - deterministic per commit, replayable,
- # attributable, and it never blocks someone else's PR. An explicit seed input overrides
- # both, which is how you replay a specific failing order anywhere.
- #
- # Intra-area order stays PlatformIO's: filters select suites, they do not order them
- # (list_test_names() walks test/ with os.walk()), so controlling it needs one invocation
- # per suite. bin/run-tests.sh --shuffle does exactly that locally.
- seed_input="${SUITE_ORDER_SEED:-}"
- if [ -n "$seed_input" ]; then
- seed="$seed_input"
- echo "area order: shuffled with explicitly supplied seed $seed"
- elif [ "${EVENT_NAME:-}" = "pull_request" ]; then
- seed=""
- echo "area order: fixed declared order (pull_request) - ${run_order[*]}"
- echo " to exercise a different order, re-run this workflow with a suite_order_seed input"
- else
- seed=$((16#${GITHUB_SHA:0:8}))
- echo "area order: shuffled with seed $seed (from ${GITHUB_SHA:0:8})"
- fi
-
- if [ -n "$seed" ]; then
- # Same shuffle_suites() bin/run-tests.sh uses, so the replay hint below is true by
- # construction rather than by two copies happening to agree.
- source bin/lib/shuffle.sh
- mapfile -t run_order < <(shuffle_suites "$seed" "${run_order[@]}")
- echo "area order: ${run_order[*]}"
- echo " replay locally: ./bin/run-tests.sh --shuffle --seed $seed"
- fi
-
- fail=0
- for a in "${run_order[@]}"; do
- [ -n "${group[$a]:-}" ] || continue
- echo "::group::area $a (${group[$a]# })"
- # Capture platformio's real exit status (not grep's) via a log file, then show the log
- # with the noisy per-variant SKIPPED rows filtered out.
- if ! platformio test -e coverage -v ${group[$a]# } \
- --junit-output-path "testreport-$a.xml" > "area-$a.log" 2>&1; then
- fail=1
- echo "::error::area $a had test failures"
- fi
- # Suites outside this area are reported SKIPPED by design (PlatformIO lists every suite
- # in the env and marks the unselected ones finished), so those rows are noise here. The
- # attribution check below is what catches a suite that was selected and did not run.
- grep -v "[[:space:]]SKIPPED$" "area-$a.log" || true
- # Per area, so a mismatch names the area it happened in rather than the whole run.
- if ! ./bin/check-test-attribution.py --label "area $a" \
- --expect "${group[$a]# }" "testreport-$a.xml"; then
- fail=1
- echo "::error::area $a ran suites that did not match their own test binaries"
- fi
- echo "::endgroup::"
- done
- exit $fail
-
- - name: Merge per-area reports into testreport.xml
- # Preserve the single-file JUnit contract that downstream consumers rely on
- # (pr_tests.yml's summary and generate-reports' Test Report both read testreport.xml).
- # The per-area split is only for readable logs; the report stays consolidated.
- if: always() # run even when a chunk failed, so the report captures the failures
- shell: bash
- run: |
- python3 - <<'PY'
- import glob, xml.etree.ElementTree as ET
- out = ET.Element('testsuites')
- for f in sorted(glob.glob('testreport-*.xml')):
- try:
- root = ET.parse(f).getroot()
- except ET.ParseError:
- continue
- # PlatformIO writes a root; fold in a bare too, just in case.
- out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root])
- ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True)
- PY
-
- - name: Verify every suite ran its own tests
- # Whole-run gate over the merged report: every test_* directory must appear with at least
- # one test case, and every case must come from the suite that reported it. The per-area
- # check above cannot see an area that never executed - this can.
- if: always() # a suite going missing is the finding; do not hide it behind an earlier failure
- shell: bash
- run: |
- set -euo pipefail
- mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
- ./bin/check-test-attribution.py --label "coverage (all areas)" \
- --expect "${suites[*]}" testreport.xml
-
- - name: Capture coverage information
- if: always() # run this step even if previous step failed
- run: |
- sudo apt-get install -y lcov
- lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info
- sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative.
-
- - name: Attribution canary
- # Guards the guard above: runs two suites the broken way (--without-building, so PlatformIO
- # does not relink and both execute the same leftover binary) and requires the checker to
- # catch it. Fails if the checker regressed, or if the reproduction stops reproducing - in
- # which case the reason both harnesses stopped passing that flag no longer holds.
- #
- # Lives in this job, not simulator-tests: it relinks $BUILD_DIR/$PROGNAME, and there that
- # replaced the daemon binary with a test suite, so the integration test waited for a socket
- # a test binary never opens. Here the binary is already per-suite and nothing later needs it.
- timeout-minutes: 15
- run: ./bin/test-attribution-canary.sh -e coverage
-
- - name: Event channel policy tests
- run: platformio test -e coverage-event-policy -v --junit-output-path event-policy-testreport.xml
-
- - name: Verify the event-policy suites ran their own tests
- # Expected set read through PlatformIO's own config parser, so it cannot drift from the
- # env's test_filter the way a second hand-maintained list would.
- run: |
- set -euo pipefail
- expect=$(python3 -c "from platformio.project.config import ProjectConfig; \
- print(' '.join(ProjectConfig().get('env:coverage-event-policy', 'test_filter', [])))")
- ./bin/check-test-attribution.py --label coverage-event-policy \
- --expect "$expect" event-policy-testreport.xml
-
- name: Save test results
if: always() # run this step even if previous step failed
uses: actions/upload-artifact@v7
with:
- name: platformio-test-report-${{ steps.version.outputs.long }}
+ name: platformio-test-report-${{ matrix.shard }}-${{ steps.version.outputs.long }}
overwrite: true
- path: ./*testreport.xml
+ # Named, not globbed: a test suite can write to the workspace, and the collector merges whatever
+ # arrives into the report its gate reads.
+ path: ./testreport-${{ matrix.shard }}.xml
- name: Save coverage information
- uses: actions/upload-artifact@v7
if: always() # run this step even if previous step failed
+ uses: actions/upload-artifact@v7
with:
- name: lcov-coverage-info-native-platformio-tests-${{ steps.version.outputs.long }}
+ name: lcov-coverage-info-native-shard-${{ matrix.shard }}-${{ steps.version.outputs.long }}
overwrite: true
- path: ./coverage_*.info
+ # Named exactly, for the same reason as the report above: everything uploaded here is
+ # merged into the published coverage report.
+ path: ./coverage_tests_${{ matrix.shard }}.info
+ # Reproduces the false green on purpose (--without-building) and requires the checker to catch it.
+ # Its own job because it relinks $BUILD_DIR/$PROGNAME, which no shard build dir can survive.
+ attribution-canary:
+ name: Attribution Canary
+ runs-on: ubuntu-24.04-arm
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ persist-credentials: false
+
+ - name: Setup native test build
+ uses: ./.github/actions/setup-native-test
+
+ - name: Disable BUILD_EPOCH
+ run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
+
+ - name: Restore PlatformIO cache
+ uses: actions/cache/restore@v6
+ with:
+ path: ~/.platformio/.cache
+ key: pio-coverage-tests-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }}
+ restore-keys: |
+ pio-coverage-tests-
+
+ - name: Restore ccache
+ uses: actions/cache/restore@v6
+ with:
+ path: ~/.ccache
+ key: ccache-native-tests-${{ github.run_id }}
+ restore-keys: |
+ ccache-native-tests-
+
+ - name: Attribution canary
+ timeout-minutes: 15
+ run: ./bin/test-attribution-canary.sh -e coverage
+
+ # Load-bearing name: branch protection matches it, and matrix rows are named per shard so none of
+ # them can carry it.
+ platformio-tests-gate:
+ name: Native PlatformIO Tests
+ needs: platformio-tests
+ if: ${{ !cancelled() }}
+ runs-on: ubuntu-slim
+ steps:
+ - name: Report the matrix result
+ env:
+ RESULT: ${{ needs.platformio-tests.result }}
+ run: |
+ set -euo pipefail
+ echo "shard matrix: $RESULT"
+ [ "$RESULT" = "success" ]
+
+ # The collector. A shard knows only its own suites and one that never started reports nothing, so
+ # only here can the union be checked against the canonical set.
generate-reports:
name: Generate Test Reports
runs-on: ubuntu-latest
@@ -485,32 +533,85 @@ jobs:
actions: read
checks: write
needs:
+ - discover
- simulator-tests
- platformio-tests
+ - attribution-canary
# Run this job even if the previous jobs failed, but skip if the workflow was cancelled.
if: ${{ !cancelled() }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Get release version string
- run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
+ run: echo "long=$(./bin/buildinfo.py long)" >> "$GITHUB_OUTPUT"
id: version
- - name: Download test artifacts
+ - name: Download per-shard test artifacts
uses: actions/download-artifact@v8
with:
- name: platformio-test-report-${{ steps.version.outputs.long }}
+ pattern: platformio-test-report-*-${{ steps.version.outputs.long }}
merge-multiple: true
+ - name: Merge the shard reports into testreport.xml
+ # Preserve the single-file JUnit contract downstream consumers rely on (pr_tests.yml summary, and
+ # the Test Report below). The split is only how the run executes; the report stays consolidated.
+ if: always() # run even when a shard failed, so the report captures the failures
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 - <<'PY'
+ import glob, xml.etree.ElementTree as ET
+ out = ET.Element('testsuites')
+ files = sorted(glob.glob('testreport-*.xml'))
+ for f in files:
+ try:
+ root = ET.parse(f).getroot()
+ except ET.ParseError:
+ print(f"WARNING: {f} is not parseable, skipping")
+ continue
+ # PlatformIO writes a root; fold in a bare too, just in case.
+ out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root])
+ ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True)
+ print(f"merged {len(files)} shard report(s) into testreport.xml")
+ PY
+
+ - name: Verdict - every suite ran, and ran its own tests
+ # Only here is the union compared against the canonical test_* set, which is what catches a shard
+ # that failed to start or was cancelled.
+ if: always() # a suite going missing is the finding; do not hide it behind an earlier failure
+ env:
+ SUITES: ${{ needs.discover.outputs.suites }}
+ run: |
+ set -euo pipefail
+ # --expect "" passes over anything, and it is empty exactly when discover failed, which is one of
+ # the situations this gate exists to catch.
+ if [ -z "${SUITES// /}" ]; then
+ echo "::error title=No expected suite set::the discover job produced no suite list, so the whole-run attribution gate has nothing to check against. Treating that as a failure - a gate with an empty expectation passes vacuously."
+ exit 1
+ fi
+ ./bin/check-test-attribution.py --label "all shards" --expect "$SUITES" testreport.xml
+
+ - name: Save merged test results
+ # Same artifact name the single-runner job used to publish, so pr_tests.yml's summary keeps
+ # finding it.
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: platformio-test-report-${{ steps.version.outputs.long }}
+ overwrite: true
+ path: ./testreport.xml
+
- name: Drop no-status testsuites from the report
# PlatformIO emits a self-closing 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.
+ if: always()
run: sed -i -E 's#]*tests="0"[^>]*/>##g' testreport.xml
- name: Test Report
+ if: always()
uses: dorny/test-reporter@v3.0.0
with:
name: PlatformIO Tests
@@ -518,6 +619,7 @@ jobs:
reporter: java-junit
- name: Download coverage artifacts
+ if: always()
uses: actions/download-artifact@v8
with:
pattern: lcov-coverage-info-native-*-${{ steps.version.outputs.long }}
@@ -526,9 +628,10 @@ jobs:
- name: Generate Code Coverage Report
# Merge every tracefile the jobs produced: coverage_base.info (zeroed baseline),
- # coverage_integration.info, and one coverage_tests_.info per chunk. lcov
- # sums hit counts across them, so the merged report is the union of all chunks -
+ # coverage_integration.info, and one coverage_tests_.info per shard. lcov
+ # sums hit counts across them, so the merged report is the union of all shards -
# identical to running the whole suite in one job.
+ if: always()
run: |
sudo apt-get install -y lcov
args=()
@@ -539,7 +642,54 @@ jobs:
genhtml --quiet --legend --prefix "${PWD}" code-coverage-report/coverage_src.info --output-directory code-coverage-report
- name: Save Code Coverage Report
+ if: always()
uses: actions/upload-artifact@v7
with:
name: code-coverage-report-${{ steps.version.outputs.long }}
path: code-coverage-report
+
+ - name: Final verdict
+ # States the run result in one place, rather than leaving it reconstructed from a dozen shard logs.
+ if: always()
+ env:
+ SHARDS: ${{ needs.platformio-tests.result }}
+ SIMULATOR: ${{ needs.simulator-tests.result }}
+ CANARY: ${{ needs.attribution-canary.result }}
+ run: |
+ set -uo pipefail
+ {
+ echo "## Native tests"
+ echo ""
+ echo "| Part | Result |"
+ echo "| --- | --- |"
+ echo "| Suite shards | \`$SHARDS\` |"
+ echo "| Simulator | \`$SIMULATOR\` |"
+ echo "| Attribution canary | \`$CANARY\` |"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+ import xml.etree.ElementTree as ET
+ cases = fails = skips = 0
+ failed = []
+ for suite in ET.parse('testreport.xml').getroot().iter('testsuite'):
+ n = int(suite.get('tests', '0'))
+ bad = int(suite.get('failures', '0')) + int(suite.get('errors', '0'))
+ cases += n
+ fails += bad
+ skips += int(suite.get('skipped', '0'))
+ if bad:
+ failed.append(f"{suite.get('name', '?')} ({bad})")
+ print("")
+ print(f"{cases} test case(s), {fails} failed, {skips} skipped.")
+ if failed:
+ print("")
+ print("Failing suites: " + ", ".join(sorted(failed)))
+ PY
+
+ for result in "$SHARDS" "$SIMULATOR" "$CANARY"; do
+ [ "$result" = "success" ] || {
+ echo "::error title=Native tests failed::shards=$SHARDS simulator=$SIMULATOR canary=$CANARY - see the job summary."
+ exit 1
+ }
+ done
+ echo "RESULT: GREEN - every shard, the simulator, and the canary passed."
diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml
index aec3fc6f87..765e2889c6 100644
--- a/.trunk/trunk.yaml
+++ b/.trunk/trunk.yaml
@@ -92,11 +92,30 @@ lint:
run: ${workspace}/bin/lint-unity-exit.sh ${target}
success_codes: [0]
read_output_from: stdout
+ # Flags a 0-means-unset deadline in src/ armed from a raw millis()/getMillis() read. The sum
+ # lands on 0 once per ~49.7-day wrap and every reader then decides the timer was never set, so
+ # a pending reboot, shutdown, DFU jump or banner expiry is silently dropped. The fix is
+ # Time::timerEndsAtMillis() / Time::skipZero() from src/UptimeClock.h. Unlike its neighbours
+ # this one is blocking: nothing catches it at run time, because the window is one tick in seven
+ # weeks. A site where 0 really is legal opts out with an `unset-sentinel-ok: ` comment on
+ # the write or the line above it; the reason is mandatory, so nothing can be muted silently. The
+ # field list lives in bin/lint-unset-sentinel-millis.sh; read the header there before adding to
+ # it, and re-run bin/test-lint-unset-sentinel-millis.sh after any change to the rule.
+ - name: unset-sentinel-millis
+ files: [cpp-sources]
+ commands:
+ - name: lint
+ output: regex
+ parse_regex: (?P.+):(?P\d+):(?P
\d+):(?P\w+):(?P.+):(?P[a-z-]+)
+ run: ${workspace}/bin/lint-unset-sentinel-millis.sh ${target}
+ success_codes: [0]
+ read_output_from: stdout
enabled:
- ascii-dash@SYSTEM
- too-many-defined@SYSTEM
- node-id-format@SYSTEM
- unity-exit@SYSTEM
+ - unset-sentinel-millis@SYSTEM
- checkov@3.3.8
- renovate@44.2.3
- prettier@3.9.6
@@ -161,6 +180,12 @@ lint:
- test/test_airtime/test_main.cpp
- test/test_throttle/test_main.cpp
- test/test_uptime_clock/test_main.cpp
+ # The nightly index templates are halves of one page, not whole documents:
+ # hintro.html stops mid-
and houtro.html starts with closing tags, so
+ # that `tree --hintro/--houtro` can splice the file listing between them.
+ - linters: [ALL]
+ paths:
+ - .github/nightly/*.html
runtimes:
enabled:
- python@3.14.4
diff --git a/AGENTS.md b/AGENTS.md
index 66a8ca6847..ec315c9dae 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,7 +80,9 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI - see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
- **Never edit or commit files under `src/mesh/generated/`.** They are regenerated from the [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) repo by the `update_protobufs.yml` workflow (entry point: `bin/regen-protos.sh`). Local edits will be overwritten and create merge conflicts. If a `.proto` change is needed, open a PR against the protobufs repo first, then let the workflow re-sync this repo.
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
+- **The `src/` naming rule does not apply under `test/`.** Suite directories and `test_*` functions follow their own rule and must not be renamed to match `src/`. What that rule is, and why: [**Test naming** in `.github/copilot-instructions.md`](.github/copilot-instructions.md#test-naming) - authoritative there, not restated here.
- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
+- **That limit does not bind a test's header comment.** A test header states what it pins and the regression it guards, at whatever length that takes, and must not be cut to two lines. What it must contain, and why: [**Test comments** in `.github/copilot-instructions.md`](.github/copilot-instructions.md#test-comments) - authoritative there, not restated here.
- **Documentation does not live in this repo. Do not add it here.** This repository holds firmware code. There is no `docs/` directory - the design documents that used to sit there were published to [meshtastic/meshtastic](https://github.com/meshtastic/meshtastic) in #11488 and the directory was deleted - and it must not come back. Do not create a `.md` file to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow.
- **Never compare against `millis()` directly. Use `Throttle`.** `src/mesh/Throttle.h` is the sanctioned way to ask about time, and CI enforces this (`millis-deadline-check` in `.github/workflows/test_native.yml` fails the PR on a new `millis() >` / `< millis()` comparison).
- `Throttle::isWithinTimespanMs(lastMs, intervalMs)` - true while still inside the cooldown.
diff --git a/CLAUDE.md b/CLAUDE.md
index a7dbf6991e..8869be9803 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -11,13 +11,15 @@
>
> **Need this? It's here.**
>
-> | | |
-> | --------------------------------------------------------- | ---------------------------------------------------------- |
-> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` |
-> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` |
-> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` |
-> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` |
-> | Observer / event wiring | `src/Observer.h` |
+> | | |
+> | --------------------------------------------------------- | -------------------------------------------------------------------------------------- |
+> | General helpers (clamp, UTF-8, string fmt…) | `src/meshUtils.h` |
+> | Logging macros (LOG_DEBUG / INFO / WARN…) | `src/DebugConfiguration.h` |
+> | Elapsed time / deadlines (never bare `millis()` compares) | `src/mesh/Throttle.h` |
+> | New module skeleton | inherit `ProtobufModule` in `src/mesh/ProtobufModule.h` |
+> | Observer / event wiring | `src/Observer.h` |
+> | Test naming (the `src/` rule does **not** apply) | [copilot-instructions.md#test-naming](.github/copilot-instructions.md#test-naming) |
+> | Test comments (the 1-2 line limit does **not** apply) | [copilot-instructions.md#test-comments](.github/copilot-instructions.md#test-comments) |
**Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions, the build system, CI/CD, the native C++ test suite, and the MCP Server & Hardware Test Harness. Read it top-to-bottom before starting any non-trivial change.
diff --git a/SECURITY.md b/SECURITY.md
index 34977e15a2..f9b59896fa 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,8 +4,8 @@
| Firmware Version | Supported |
| ---------------- | ------------------ |
-| 2.7.x | :white_check_mark: |
-| <= 2.6.x | :x: |
+| 2.8.x | :white_check_mark: |
+| <= 2.7.x | :x: |
## Reporting a Vulnerability
diff --git a/bin/bme680_iaq_replay.cpp b/bin/bme680_iaq_replay.cpp
index 62d0a5286e..aa65c43c94 100644
--- a/bin/bme680_iaq_replay.cpp
+++ b/bin/bme680_iaq_replay.cpp
@@ -1,5 +1,6 @@
// Replays a captured BME680 CSV trace (gas_ohms,rh[,bsec_iaq]) through
-// BME680IaqEstimator for offline tuning. See docs/bme680_iaq_replay.md.
+// BME680IaqEstimator for offline tuning. Build from the repo root:
+// c++ -std=c++17 -O2 -I src -o /tmp/iaq_replay bin/bme680_iaq_replay.cpp src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp
#include "modules/Telemetry/Sensor/BME680IaqEstimator.h"
diff --git a/bin/build-nrf54l15.sh b/bin/build-nrf54l15.sh
new file mode 100755
index 0000000000..9ff90384f7
--- /dev/null
+++ b/bin/build-nrf54l15.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+
+set -e
+
+VERSION=$(bin/buildinfo.py long)
+
+BUILDDIR=.pio/build/$1
+OUTDIR=release
+
+rm -f $OUTDIR/firmware*
+rm -r $OUTDIR/* || true
+
+# Important to pull latest version of libs into all device flavors, otherwise some devices might be stale
+platformio pkg install -e $1
+
+echo "Building for $1 with $PLATFORMIO_BUILD_FLAGS"
+rm -f $BUILDDIR/firmware*
+
+# The shell vars the build tool expects to find
+export APP_VERSION=$VERSION
+
+basename=firmware-$1-$VERSION
+ota_basename=${basename}-ota
+
+pio run --environment $1 -t dfu -t mtjson # -v
+
+cp $BUILDDIR/$basename.elf $OUTDIR/$basename.elf
+
+echo "Copying merged hex (bootloader + SoftDevice + application)"
+cp $BUILDDIR/$basename.hex $OUTDIR/$basename.hex
+
+echo "Copying nRF54L dfu (OTA) file"
+cp $BUILDDIR/$basename.zip $OUTDIR/$ota_basename.zip
+
+echo "Copying manifest"
+cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json || true
diff --git a/bin/check-all.sh b/bin/check-all.sh
index 3186899aa5..0b22069efa 100755
--- a/bin/check-all.sh
+++ b/bin/check-all.sh
@@ -50,7 +50,8 @@ trap 'rm -f "$LOG"' EXIT
# Keep streaming to the console so the CI log reads exactly as it did before; tee a copy for the
# post-mortem classification below.
-pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" "${CHECK[@]}" --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high 2>&1 | tee "$LOG"
+# define PROGMEM to avoid cppcheck reporting unknownMacro (--skip-packages excludes Arduino.h)
+pio check --flags "-DAPP_VERSION=${APP_VERSION} -DPROGMEM= --suppressions-list=suppressions.txt --inline-suppr" "${CHECK[@]}" --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high 2>&1 | tee "$LOG"
STATUS=${PIPESTATUS[0]}
if [[ $STATUS -eq 0 ]]; then
diff --git a/bin/check-test-attribution.py b/bin/check-test-attribution.py
index 2d3d258e46..311086f93f 100755
--- a/bin/check-test-attribution.py
+++ b/bin/check-test-attribution.py
@@ -55,8 +55,12 @@ def collect(paths):
cases = {}
for path in paths:
try:
- # The input is the JUnit report PlatformIO just wrote in this same run, not untrusted
- # data, and defusedxml is not installed for this job.
+ # The input is a JUnit report PlatformIO wrote, not untrusted data, and defusedxml is
+ # not installed for this job. In CI the collector reads these back from artifacts
+ # rather than off the same disk that produced them, so what keeps that true is the
+ # upload step naming the one file the shard was told to write instead of globbing:
+ # a test suite can write to the workspace, and under a glob its own XML would ride
+ # along into the merged report. See .github/workflows/test_native.yml.
# nosemgrep: python.lang.security.use-defused-xml-parse.use-defused-xml-parse
root = ET.parse(path).getroot()
except (ET.ParseError, OSError) as exc:
diff --git a/bin/config.d/lora-usb-rak19714.yaml b/bin/config.d/lora-usb-rak19714.yaml
new file mode 100644
index 0000000000..9d4a3fed28
--- /dev/null
+++ b/bin/config.d/lora-usb-rak19714.yaml
@@ -0,0 +1,18 @@
+Meta:
+ name: rak19714
+ support: official
+ compatible:
+ - usb
+
+Lora:
+ Module: sx1262
+ CS: 0
+ IRQ: 6
+ Reset: 2
+ Busy: 4
+ RXen: 1
+ DIO2_AS_RF_SWITCH: true
+ spidev: ch341
+ DIO3_TCXO_VOLTAGE: true
+ USB_PID: 0x5512
+ USB_VID: 0x1A86
diff --git a/bin/lib/test-state.sh b/bin/lib/test-state.sh
index 138d57e906..03b818394f 100644
--- a/bin/lib/test-state.sh
+++ b/bin/lib/test-state.sh
@@ -65,14 +65,20 @@ state_fingerprint() {
# Scoped to this user's processes: /proc//environ is unreadable for anyone else's anyway, and
# the narrower sweep costs ~270ms against ~460ms for all of /proc.
state_find_survivors() {
- local home="$1" pid
+ local home="$1" pid entry
[[ -n $home ]] || return 0
for pid in $(ps -u "$(id -u)" -o pid= 2>/dev/null); do
[[ $pid == "$$" ]] && continue
- # Grouped so the redirect's own open failure is silenced too, not just tr's stderr.
- if { tr '\0' '\n' <"/proc/$pid/environ"; } 2>/dev/null | grep -qxF "HOME=$home"; then
- printf '%s\n' "$pid"
- fi
+ # In-shell, not `tr | grep -qxF`: -q closes the pipe on the match, tr takes SIGPIPE, and
+ # under `set -o pipefail` the hit reports as a miss. Grouped so a vanished pid is silent.
+ {
+ while IFS= read -r -d '' entry; do
+ if [[ $entry == "HOME=$home" ]]; then
+ printf '%s\n' "$pid"
+ break
+ fi
+ done <"/proc/$pid/environ"
+ } 2>/dev/null
done
}
diff --git a/bin/lint-unset-sentinel-millis.sh b/bin/lint-unset-sentinel-millis.sh
new file mode 100755
index 0000000000..d3348cb8f4
--- /dev/null
+++ b/bin/lint-unset-sentinel-millis.sh
@@ -0,0 +1,443 @@
+#!/usr/bin/env bash
+# lint-unset-sentinel-millis.sh - flag a 0-means-unset deadline armed from a raw clock read.
+#
+# A family of fields in this tree uses 0 to mean "unarmed", and is then read as `if (field)` or
+# `field != 0` before the deadline is compared. src/main.h says so out loud:
+#
+# extern uint32_t enterDfuAtMsec; // 0 = unset; else millis() deadline for the deferred DFU jump
+#
+# Arming one of those with `field = millis() + delay` is correct for ~49.7 days and then wrong for
+# one tick: the sum lands on 0 exactly once per wrap, and at that instant every reader decides the
+# timer was never set. A pending reboot, shutdown, DFU jump or banner expiry is silently dropped.
+# `field = millis()` has the same hole for a stamp. The fix is Time::timerEndsAtMillis(delay) for a
+# countdown, or Time::skipZero(Time::getMillis()) for a stamp; both are in src/UptimeClock.h, whose
+# static_asserts pin the behaviour this rule steers people toward.
+#
+# Why a name list and not a pattern over every `millis() + x`: most sums are fine. A local
+# `const uint32_t deadline = millis() + timeoutMs;` that is compared a few lines later never stores
+# 0 for anything to misread, and src/ has nine such sites that are all correct. The 0 contract is
+# also declared in one file and enforced in six others - rebootAtMsec is written in AdminModule.cpp
+# and tested in Power.cpp, PowerFSM.cpp, main.cpp, Screen.cpp and portduino/USBHal.h - so no
+# single-file scan can infer it. The list below is therefore explicit.
+#
+# Two kinds of field are on it:
+#
+# * Fields whose 0 IS the unset state. These must be armed through the helpers.
+# * Fields whose unset state is a separate flag, so 0 is a value they may legally hold. These are
+# listed so that the rule notices them, and each arm site carries an opt-out comment naming the
+# flag that actually carries the armed state. Listing-plus-opt-out beats silent omission: if
+# someone later rewrites `if (haveSample && ...)` into `if (lastSampleMs && ...)`, the field has
+# quietly acquired the contract, and the opt-out comment is sitting right there at the write to
+# be reconsidered.
+#
+# OPTING OUT. Put `unset-sentinel-ok: ` in a comment, either on the line of the write or on
+# a comment line above it:
+#
+# // unset-sentinel-ok: busyTx carries the armed state, so 0 is a legal timestamp here
+# lastTxStart = Time::getMillis();
+#
+# The reason is mandatory - a bare `unset-sentinel-ok` with nothing after the colon is reported
+# rather than honoured, so a site cannot be muted without saying why. trunk-ignore works too, but
+# prefer this: it states the justification at the write, and it also applies when the script is run
+# directly rather than through trunk.
+#
+# Adding a field: append it to SENTINELS. If its 0 is the unset state, fix the arm sites; if a
+# separate flag carries the armed state, add an opt-out comment at each write. Verify which of the
+# two it is by reading every site that READS the field - one missed read is what makes this wrong.
+#
+# Three fields are absent on purpose, and NOT because 0 is safe there. Each was examined and came
+# back unresolved rather than exempt, so listing one would mean stamping an opt-out over a claim
+# that does not hold:
+#
+# * nagCycleCutoff. ExternalNotificationModule::handleInputEvent reads
+# `if (nagCycleCutoff != UINT32_MAX)` without consulting isNagging, so at that read the field is
+# its own armed flag with UINT32_MAX - not 0 - as the sentinel, and the arm site can land there.
+# skipZero() would not help: it lifts 0 to 1 and leaves UINT32_MAX alone, by design. The fix is
+# to gate that read on isNagging, a behaviour change that belongs in its own PR.
+# * TouchScreenBase::_start. Overloaded as both an event stamp and a `+ 30000` suppression
+# deadline compared by signed subtraction, so a near-zero value reads as "long ago" rather than
+# "armed 30s out", and LONG_PRESS re-fires. skipZero() does not fix that either - 1 reads as
+# long-ago exactly as 0 does. It needs the stamp and the deadline held separately.
+# * StoreForwardModule::retry_delay. Has no reads at all today, so nothing misbehaves yet;
+# exempting it now would pre-approve the raw arm for whoever implements the retry its own
+# comment promises.
+#
+# Also absent, for the ordinary reason that 0 carries no meaning there: locals such as
+# NodeInfoModule's lastNodeInfo, derived per call from TransmitHistory rather than stored.
+#
+# Emitted at "error" rather than the "note" its neighbours use, because nothing catches this at run
+# time: the window is one tick in seven weeks, so a test run, a soak and a bench session all pass.
+# The tree has zero unexplained violations, so blocking costs nothing and is the only thing that
+# actually prevents the next one.
+#
+# Not handled: a write through an alias (`uint32_t &d = rebootAtMsec; d = millis() + 5;`) or through
+# a pointer, and a sentinel armed by a helper that takes it by reference. None occur, and tracking
+# aliases is untested code guarding a case that does not exist. Raw string literals (R"(...)") are
+# not tokenised either, for the same reason as in bin/lint-unity-exit.sh.
+#
+# bin/test-lint-unset-sentinel-millis.sh is this rule's self-test; every false positive and false
+# negative found in review belongs there as a fixture.
+#
+# Emits one line per finding in the format
+# ::
:::
+# which trunk parses via parse_regex. Always exits 0; findings go to stdout.
+
+set -uo pipefail
+
+# Millisecond fields this rule watches. See the notes above before editing.
+SENTINELS='rebootAtMsec|shutdownAtMsec|enterDfuAtMsec|alertBannerUntil|pulseOffAt|delayedPulseAt|ntp_renew|tx_after|suppressTouchTapUntilMs|fixHoldEnds|lastChipRecoveryMs|activeReceiveStart|rxTimeMsec|lastInterruptTime|lastSentReply|lastSort|lastTxStart|lastHeartbeat|lastAveraged|lastSampleMs|lastIaqMs|last_format_ms|nextRepeatX|nextRepeatY|_cached_next_run|connect_time_ms|directionStartTime|downStartTime|fileage|keyDownStart|lastAuthFailure|lastContactMsec|lastDirectResponseMs|lastDiskSave|lastDownLongEventTime|lastDrawMsec|lastGpsSend|lastHeadingAtMs|lastHeapLogTime|lastHeapWarning|lastLfsFormatMs|lastMillis|lastPressLongEventTime|lastRemoteSessionMs|lastSentStatsToPhone|lastSentToPhone|lastSetFromPhoneNtpOrGps|lastTraceRouteTime|lastUpLongEventTime|lastUpdateMs|last_probe|last_report_to_map|lastrun_ntp|navBarLastShown|pressStartTime|startSendConditions|suppressFromMs|touchResumeAtMs|upStartTime'
+
+for target in "$@"; do
+ [[ -f $target ]] || continue
+
+ # Path is reported relative to the workspace so findings are clickable from the repo root.
+ rel="${target#"$PWD"/}"
+
+ # Firmware sources only. Tests construct raw wrap values on purpose - pinning what happens at
+ # 0xFFFFFFFF is the point of test_uptime_clock - and UptimeClock.h itself defines the helpers.
+ [[ $rel == src/* ]] || continue
+ [[ $rel == src/UptimeClock.h ]] && continue
+
+ awk -v path="$rel" -v sentinels="$SENTINELS" '
+ # Return the line with comments and string/char literals removed, carrying /* ... */ state
+ # across lines, and fill colmap[] mapping each position in the result back to its raw column.
+ # Character-level rather than layered regexes, for the reasons spelled out at length in
+ # bin/lint-unity-exit.sh: a /* inside a string literal flips comment state and hides real code,
+ # and an assignment quoted inside a log string reads as one. Literals collapse to a space so two
+ # tokens cannot be glued together.
+ #
+ # Also sets CMT to this line s comment text, which is where an opt-out has to live. Collecting
+ # it here rather than re-scanning the raw line is what stops `LOG_DEBUG("unset-sentinel-ok: x")`
+ # from muting anything: a string literal is not a comment.
+ function strip_noncode(s, out, i, n, c, two, q) {
+ n = length(s); i = 1; out = ""
+ delete colmap
+ CMT = ""
+ while (i <= n) {
+ if (in_block) {
+ if (substr(s, i, 2) == "*/") { in_block = 0; i += 2 }
+ else { CMT = CMT substr(s, i, 1); i++ }
+ continue
+ }
+ two = substr(s, i, 2)
+ if (two == "//") { CMT = CMT " " substr(s, i + 2); return out }
+ if (two == "/*") { in_block = 1; i += 2; continue }
+ c = substr(s, i, 1)
+ if (c == "\"" || c == "'"'"'") { # skip a whole literal, honouring backslash escapes
+ q = c
+ out = out " "; colmap[length(out)] = i
+ i++
+ while (i <= n) {
+ c = substr(s, i, 1)
+ if (c == "\\") { i += 2; continue }
+ i++
+ if (c == q) break
+ }
+ continue
+ }
+ out = out c; colmap[length(out)] = i; i++
+ }
+ return out
+ }
+
+ # An opt-out only counts with something after the colon. A bare marker is reported instead of
+ # honoured, so "shut this up" is not available without writing down why.
+ function has_reasoned_ok(t) { return (t ~ /unset-sentinel-ok:[ \t]*[^ \t]/) }
+ function has_bare_ok(t) { return (t ~ /unset-sentinel-ok/) && !has_reasoned_ok(t) }
+
+ # Is the character before position `at` part of an identifier? Used to require a token boundary,
+ # so myRebootAtMsec is not mistaken for rebootAtMsec. A `.`, `->` or `::` qualifier is a
+ # boundary on purpose: txp->tx_after and NotificationRenderer::alertBannerUntil are the real
+ # call sites and must still be seen.
+ function ident_before(s, at, c) {
+ if (at <= 1) return 0
+ c = substr(s, at - 1, 1)
+ return (c ~ /[A-Za-z0-9_]/)
+ }
+
+ # Brace depth, and which depths are a class/struct BODY rather than a function body. Needed
+ # because a typed declaration means opposite things in the two places: inside a function it is a
+ # throwaway local that shadows the field, but at class scope it IS the field, with an initializer
+ # that can read the clock - src/modules/SerialModule.h does exactly that. Treating the second as a
+ # local silently excused a real arm site.
+ function update_scope(s, i, c) {
+ for (i = 1; i <= length(s); i++) {
+ c = substr(s, i, 1)
+ if (c == "{") {
+ depth++
+ if (pending_class) { class_body[depth] = 1; pending_class = 0 }
+ } else if (c == "}") {
+ delete class_body[depth]
+ if (depth > 0) depth--
+ }
+ }
+ }
+
+ # Is the statement being judged sitting directly in a class body? `opened` is the net brace
+ # count seen earlier in this same statement, which update_scope() has not applied yet: a body
+ # opened on this very line (a one-line inline method) puts the statement inside a function, not
+ # in the class body.
+ function at_class_scope(opened) {
+ # The class body opened on this very statement, so its own brace is the class brace: exactly
+ # one unmatched brace means class scope, two or more means a method body inside it.
+ if (stmt_class_brace) return (opened == 1)
+ return ((depth in class_body) && opened <= 0)
+ }
+
+ # Net unmatched `{` in the first `at` characters of the statement being judged.
+ function braces_before(s, at, i, c, n) {
+ n = 0
+ for (i = 1; i < at && i <= length(s); i++) {
+ c = substr(s, i, 1)
+ if (c == "{") n++
+ else if (c == "}") n--
+ }
+ return n
+ }
+
+ # A local declaration that happens to reuse a sentinel name shadows the field and carries none
+ # of its contract, so it is not this rule business. Detected by a type-ish token immediately
+ # before the name - `uint32_t tx_after = millis() + d;` declares a local, `tx_after = ...` does
+ # not. Kept narrow: only the spellings this tree actually uses for a millis value.
+ #
+ # Only honoured inside a function body; see update_scope() for why class scope is different.
+ function is_declaration(s, at, head) {
+ if (at_class_scope(braces_before(s, at))) return 0
+ head = substr(s, 1, at - 1)
+ sub(/[ \t]*(\*|&)?[ \t]*$/, "", head)
+ return (head ~ /(^|[^A-Za-z0-9_])(uint32_t|uint64_t|int32_t|unsigned[ \t]+long|unsigned[ \t]+int|unsigned|long|int|auto|size_t|TickType_t)$/)
+ }
+
+ # Does this statement read a clock directly? Matches millis(), Time::getMillis() and any wrapper
+ # whose name ends in millis, which is how almost every clock read in this tree spells itself, plus
+ # Zephyr k_uptime_get_32() - the nRF54L15 BLE code has no millis() at all and wraps at 32 bits just
+ # the same, so a sentinel armed from it needs the same guard.
+ function reads_clock(s) { return (s ~ /[Mm]illis[ \t]*\(/ || s ~ /k_uptime_get_32[ \t]*\(/) }
+
+ # Already routed through a helper that dodges 0, so it is the fix rather than the defect. Note
+ # stampMillis() belongs here even though its name ends in millis: a local read through it holds a
+ # value that is already non-zero, so a write from that local is safe and must not be flagged.
+ function is_safe_arm(s) { return (s ~ /skipZero/ || s ~ /timerEndsAtMillis/ || s ~ /stampMillis/) }
+
+ # Does the expression apply + or - to an already-dodged value at the OUTERMOST level? A dodged
+ # value is safe to store or copy, but not to do arithmetic on: stampMillis() guarantees only its
+ # own result, and `now + 5000` can carry a non-zero stamp straight back onto 0 - 0xFFFFEC78 + 5000
+ # is exactly 0. That sum is what Time::timerEndsAtMillis() exists to dodge, so it has to be
+ # reported rather than excused.
+ #
+ # Depth-aware on purpose: the operator inside Time::skipZero(getMillis() - msAgo) is at depth 1
+ # and is fine, because the helper wraps the result. So is the `? :` in the ternary arming form,
+ # which has no top-level + or - at all.
+ function toplevel_arith(s, i, n, c, d, prev) {
+ n = length(s); d = 0; prev = ""
+ for (i = 1; i <= n; i++) {
+ c = substr(s, i, 1)
+ if (c == "(") d++
+ else if (c == ")") d--
+ else if (d == 0 && (c == "+" || c == "-")) {
+ # not a unary sign, and not part of -> or ++/--
+ if (prev != "" && prev != "(" && prev != "," && prev != "=" && prev != "+" &&
+ prev != "-" && prev != "*" && prev != "/" && prev != "?" && prev != ":" &&
+ substr(s, i + 1, 1) != ">")
+ return 1
+ }
+ if (c != " " && c != "\t") prev = c
+ }
+ return 0
+ }
+
+ # Remember a local that was just assigned from a clock, so `field = now` a few lines later is
+ # recognised as the raw arm it really is. Without this the rule is blind to the commonest shape
+ # in the tree - `unsigned long now = millis();` at the top of a runOnce(), then half a dozen
+ # `xStartTime = now;` writes below it - and listing those fields would buy no protection at all.
+ #
+ # Deliberately shallow: one hop, within one function, name-based. It records ` = `
+ # and ` = `, and it FORGETS the name when the same local is
+ # reassigned from anything else, so a variable reused for something unrelated stops matching.
+ # Taint is dropped at every function boundary (see the reset below), because a name that means a
+ # clock in one function usually means nothing in the next.
+ function note_taint(s, lhs, rhs, eqp, semi) {
+ eqp = index(s, "=")
+ if (eqp == 0) return
+ if (substr(s, eqp + 1, 1) == "=") return # `==` is a comparison
+ if (substr(s, eqp - 1, 1) ~ /[-+*\/%&|^!<>=]/) return # `+=`, `!=`, ... are not plain
+ lhs = substr(s, 1, eqp - 1)
+ rhs = substr(s, eqp + 1)
+ # This assignment only. Without the cut, a second statement on the same line teaches taint
+ # for the first - the same defect the judging path was fixed for.
+ semi = index(rhs, ";")
+ if (semi > 0) rhs = substr(rhs, 1, semi - 1)
+ # Take the last identifier on the left, which skips any type and `*`/`&` decoration.
+ if (!match(lhs, /[A-Za-z_][A-Za-z0-9_]*[ \t]*$/)) return
+ lhs = substr(lhs, RSTART, RLENGTH)
+ sub(/[ \t]+$/, "", lhs)
+ if (lhs == "") return
+ if (is_safe_arm(rhs) && !toplevel_arith(rhs)) {
+ delete tainted[lhs]
+ normalized[lhs] = 1 # holds a value that has already dodged 0
+ } else if (reads_clock(rhs) || rhs_is_tainted(rhs)) {
+ tainted[lhs] = 1
+ delete normalized[lhs]
+ } else {
+ delete tainted[lhs] # reused for something else - stop trusting the name
+ delete normalized[lhs]
+ }
+ }
+
+ # Is any tainted local read in this expression, as a whole token? Token-bounded so a tainted
+ # `now` does not match `nowMs` or `snowfall`. Local names are plain identifiers, so using one
+ # as a match() pattern carries no regex metacharacters.
+ function rhs_is_normalized(s, name, t, p, before, after) {
+ for (name in normalized) {
+ t = s
+ while (match(t, name)) {
+ p = RSTART
+ before = (p == 1) ? " " : substr(t, p - 1, 1)
+ after = substr(t, p + length(name), 1)
+ if (before !~ /[A-Za-z0-9_]/ && after !~ /[A-Za-z0-9_]/) return 1
+ t = substr(t, p + length(name))
+ if (t == "") break
+ }
+ }
+ return 0
+ }
+
+ function rhs_is_tainted(s, name, t, p, before, after) {
+ for (name in tainted) {
+ t = s
+ while (match(t, name)) {
+ p = RSTART
+ before = (p == 1) ? " " : substr(t, p - 1, 1)
+ after = substr(t, p + length(name), 1)
+ if (before !~ /[A-Za-z0-9_]/ && after !~ /[A-Za-z0-9_]/) return 1
+ t = substr(t, p + length(name))
+ if (t == "") break
+ }
+ }
+ return 0
+ }
+
+ BEGIN { LINE_CAP = 12 } # give up accumulating a statement after this many lines
+
+ {
+ code = strip_noncode($0)
+
+ # A class or struct header whose body opens on this line or the next. Anchored at the start
+ # of the line, because the keyword appears mid-line in shapes that are not class bodies at
+ # all: `template ` on a function, and an elaborated type in a parameter list such as
+ # `void g(struct Bar *b)`. Both used to mark the following FUNCTION body as class scope, which
+ # then reported every typed local in it. Not a forward declaration either, which ends in a
+ # semicolon with no brace.
+ stmt_class_brace = 0
+ if (code ~ /^[ \t]*(class|struct)[ \t]+[A-Za-z_][A-Za-z0-9_]*/) {
+ # The body may open on this line or the next. `class Foo;` is a forward declaration and
+ # opens nothing; `class Foo { uint32_t t = millis(); };` opens AND closes here, so the
+ # trailing semicolon cannot be used to rule it out.
+ if (code ~ /\{/) {
+ # Body opens on this line. stmt_class_brace judges THIS statement (a one-liner
+ # whose member sits after the brace); pending_class is still needed so
+ # update_scope() registers the body for the lines that follow.
+ stmt_class_brace = 1
+ pending_class = 1
+ } else if (code !~ /;[ \t]*$/) {
+ pending_class = 1 # body opens on a later line
+ }
+ }
+
+ # A closing brace in column 1 is the end of a function as this tree formats code, and a
+ # local called `now` there has nothing to do with the one in the next function. clang-format
+ # is enforced repo-wide, so this is reliable enough for a one-hop heuristic.
+ if ($0 ~ /^\}/) delete tainted
+
+ # An opt-out is sticky until the next statement that actually contains code is judged. That
+ # is what lets it sit on its own line above the write, however many comment lines intervene,
+ # without leaking past the statement it was written for.
+ if (has_reasoned_ok(CMT)) pending_ok = 1
+ if (has_bare_ok(CMT)) pending_bare = 1
+
+ if (stmt == "") { start = NR; nhits = 0 }
+ base = length(stmt) + 1 # the leading space added below shifts everything by one
+ stmt = stmt " " code
+
+ # Record every ` =` on this line, with its position in the accumulated statement
+ # so the RHS can be judged once the statement is whole.
+ rest = code; off = 0
+ while (match(rest, "(" sentinels ")[ \t]*=")) {
+ p = RSTART; len = RLENGTH
+ off += p
+ # A plain assignment only: ==, !=, <=, >=, +=, -= and friends are reads or updates,
+ # not an arming write, and `=` must not be the head of `==`.
+ eq = off + len - 1
+ prev = (eq - 1 >= 1) ? substr(code, eq - 1, 1) : " "
+ nxt = (eq + 1 <= length(code)) ? substr(code, eq + 1, 1) : " "
+ if (prev !~ /[-+*\/%&|^!<>=]/ && nxt != "=" && !ident_before(code, off) &&
+ !is_declaration(code, off)) {
+ nhits++
+ hit_at[nhits] = base + eq # index of the `=` within stmt
+ hit_line[nhits] = NR
+ hit_col[nhits] = colmap[off]
+ hit_name[nhits] = substr(code, off, len - 1)
+ sub(/[ \t]*$/, "", hit_name[nhits])
+ }
+ rest = substr(rest, p + len - 1)
+ off += len - 2
+ }
+
+ # End of statement: judge each recorded write against its own right-hand side.
+ if (code ~ /;/ || NR - start >= LINE_CAP) {
+ for (k = 1; k <= nhits; k++) {
+ # The right-hand side of THIS write only, cut at its own semicolon. Without the
+ # cut, rhs ran on to the end of the accumulated statement and judged a neighbour
+ # as if it belonged to this write - wrongly in both directions. On
+ # rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10);
+ # the later helper call suppressed a genuine raw arm, and on
+ # rebootAtMsec = otherDeadline; shutdownAtMsec = millis();
+ # the later millis() reported a safe copy. Both are fixtures now.
+ rhs = substr(stmt, hit_at[k] + 1)
+ semi = index(rhs, ";")
+ if (semi > 0) rhs = substr(rhs, 1, semi - 1)
+
+ # Only a raw clock read is a finding. `= 0` disarms, a copy from another
+ # variable inherits whatever that one did, and anything already routed through
+ # the helpers is the fix rather than the defect. Matching `millis` loosely
+ # covers millis(), Time::getMillis() and any wrapper ending in millis.
+ # Arithmetic applied on top of an already-dodged value can wrap it back onto 0,
+ # so it is reported even though a helper appears in the expression.
+ if ((is_safe_arm(rhs) || rhs_is_normalized(rhs)) && !toplevel_arith(rhs)) {
+ continue # stored or copied straight through - safe
+ }
+ if (is_safe_arm(rhs) && !toplevel_arith(rhs))
+ continue # already routed through the helpers
+ if (!reads_clock(rhs) && !rhs_is_tainted(rhs) && !rhs_is_normalized(rhs))
+ continue # not a clock read, directly or via a local holding one
+ if (pending_ok)
+ continue # opted out, with a reason, at the write
+ if (pending_bare)
+ printf "%s:%d:%d:%s:%s:%s\n", path, hit_line[k], hit_col[k], "error",
+ "unset-sentinel-ok needs a reason after the colon saying why 0 is legal for " hit_name[k] " (see bin/lint-unset-sentinel-millis.sh)",
+ "unset-sentinel-millis"
+ else
+ printf "%s:%d:%d:%s:%s:%s\n", path, hit_line[k], hit_col[k], "error",
+ hit_name[k] " is 0-means-unset - arm it with Time::timerEndsAtMillis(delay), or Time::skipZero(Time::getMillis()) for a stamp (see src/UptimeClock.h)",
+ "unset-sentinel-millis"
+ }
+ # Learn from this statement before dropping it: `now = millis()` here is what makes
+ # `field = now` below recognisable. Done after judging so a sentinel write cannot
+ # taint its own name.
+ if (nhits == 0) note_taint(stmt)
+
+ # Comment-only lines carry an opt-out toward the write below them, so they must not
+ # clear it; a statement with real code in it consumes it.
+ if (stmt ~ /[^ \t]/) { pending_ok = 0; pending_bare = 0 }
+ stmt = ""
+ nhits = 0
+ }
+
+ # Last, so every brace on this line counts toward the scope of the NEXT line: a declaration
+ # sits at the depth its own line opened with.
+ update_scope(code)
+ }
+ ' "$target"
+done
+
+exit 0
diff --git a/bin/optional-modules.py b/bin/optional-modules.py
new file mode 100644
index 0000000000..443a608084
--- /dev/null
+++ b/bin/optional-modules.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+# trunk-ignore-all(ruff/F821)
+# trunk-ignore-all(flake8/F821): For SConstruct imports
+#
+# Registers optional modules dropped into src/modules/optional/.
+#
+# A module is a directory / holding .h, which declares `void setup();`. This
+# writes $BUILD_DIR/OptionalModules.h with an include and a setup call for each one found;
+# Modules.cpp picks that header up through __has_include and calls OPTIONAL_MODULES_SETUP().
+#
+# src/modules/optional/ does not exist in a stock checkout, so a stock build generates a header that
+# defines nothing and registers nothing. Sources under the directory are compiled by the default
+# recursive build_src_filter, so dropping a module in needs no platformio.ini edit.
+import os
+import re
+
+Import("env")
+
+# The directory name becomes a C++ call, so it has to be a usable identifier.
+identifier = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+
+optionalDir = os.path.join(env["PROJECT_DIR"], "src", "modules", "optional")
+
+names = []
+if os.path.isdir(optionalDir):
+ for entry in sorted(os.listdir(optionalDir)):
+ entryDir = os.path.join(optionalDir, entry)
+ if not os.path.isdir(entryDir):
+ continue
+ if not identifier.match(entry):
+ print(f"optional-modules: skipping {entry}/, setup{entry}() is not a valid identifier")
+ elif os.path.isfile(os.path.join(entryDir, entry + ".h")):
+ names.append(entry)
+ else:
+ print(f"optional-modules: skipping {entry}/, no {entry}.h")
+
+lines = ["// Generated by bin/optional-modules.py. Do not edit.", "#pragma once", ""]
+for name in names:
+ lines.append(f'#include "modules/optional/{name}/{name}.h"')
+if names:
+ lines.append("")
+ lines.append("#define OPTIONAL_MODULES_SETUP() \\")
+ lines.append(" do { \\")
+ for name in names:
+ lines.append(f" setup{name}(); \\")
+ lines.append(" } while (0)")
+lines.append("")
+content = "\n".join(lines)
+
+buildDir = env.subst("$BUILD_DIR")
+os.makedirs(buildDir, exist_ok=True)
+header = os.path.join(buildDir, "OptionalModules.h")
+
+# Rewrite only on a change, so an unchanged set of modules does not keep rebuilding Modules.cpp.
+previous = None
+if os.path.isfile(header):
+ with open(header, encoding="utf-8") as f:
+ previous = f.read()
+if previous != content:
+ with open(header, "w", encoding="utf-8") as f:
+ f.write(content)
+
+env.Append(CPPPATH=[buildDir])
+
+if names:
+ print("optional-modules: " + ", ".join(names))
diff --git a/bin/org.meshtastic.meshtasticd.desktop b/bin/org.meshtastic.meshtasticd.desktop
index 215c7ee054..00b59f3c83 100644
--- a/bin/org.meshtastic.meshtasticd.desktop
+++ b/bin/org.meshtastic.meshtasticd.desktop
@@ -1,6 +1,6 @@
[Desktop Entry]
-Name=Meshtastic
-Comment=Meshtastic App
+Name=MeshtasticD
+Comment=Meshtastic Daemon + MUI
Exec=meshtasticd
Icon=org.meshtastic.meshtasticd
Terminal=true
diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml
index ed5338af64..9b762720bc 100644
--- a/bin/org.meshtastic.meshtasticd.metainfo.xml
+++ b/bin/org.meshtastic.meshtasticd.metainfo.xml
@@ -2,7 +2,7 @@
org.meshtastic.meshtasticd
- Meshtastic
+ MeshtasticDDecentralized mesh communicationCC-BY-4.0
@@ -13,6 +13,9 @@
+
+ MeshtasticD is an app that allows you to use your Linux computer as a Meshtastic node (using a CH341 USB LoRa radio).
+
Meshtastic is an open source project for creating off-grid, affordable, and resilient communication with LoRa mesh networks.
@@ -87,6 +90,18 @@
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.8.1
+
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.8.0
+
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.26
+
+
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.25
+ https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24
diff --git a/bin/platformio-custom.py b/bin/platformio-custom.py
index 08d010e086..7ded02b121 100644
--- a/bin/platformio-custom.py
+++ b/bin/platformio-custom.py
@@ -41,8 +41,11 @@ def infer_architecture(board_cfg):
return "rp2350"
if "nrf52" in mcu_l or "nrf52840" in mcu_l:
return "nrf52840"
+ if "nrf54l15" in mcu_l:
+ return "nrf54l15"
if "stm32" in mcu_l:
return "stm32"
+ print(f"mtjson: could not infer architecture from MCU '{mcu_l}'")
return None
def run_size_tool(env, flag, purpose):
@@ -255,8 +258,12 @@ def manifest_write(files, env, ram_bytes=None, flash_bytes=None):
if parsed is not None and parsed != "":
device_meta[manifest_key] = parsed
- # Determine architecture once; if we can't infer it, skip manifest generation
- board_arch = device_meta.get("architecture") or infer_architecture(env.BoardConfig())
+ # Board MCU wins over a hand-typed custom_meshtastic_architecture: only the
+ # spellings infer_architecture emits are recognized downstream.
+ declared = device_meta.get("architecture")
+ board_arch = infer_architecture(env.BoardConfig()) or declared
+ if declared and declared != board_arch:
+ print(f"{pioenv}: architecture '{declared}' overridden with '{board_arch}'")
if not board_arch:
print(f"Skipping mtjson write for unknown architecture (env={env.get('PIOENV')})")
return
@@ -299,6 +306,30 @@ with open(jsonLoc) as f:
jsonStr = re.sub("//.*","", f.read(), flags=re.MULTILINE)
userPrefs = json.loads(jsonStr)
+# Channels::initDefaultChannel() applies a configured index as a whole, so resolve per-field
+# optionality here: any field the vendor left out gets the value that function would have kept.
+MAX_NUM_CHANNELS = 8
+CHANNEL_FIELD_DEFAULTS = {
+ "PSK": "{ 0x01 }", # short-form index into the well-known default PSK
+ "NAME": "",
+ "PRECISION": "0",
+ "IS_MUTED": "false",
+ "UPLINK_ENABLED": "false",
+ "DOWNLINK_ENABLED": "false",
+}
+channelsToWriteRaw = userPrefs.get("USERPREFS_CHANNELS_TO_WRITE", "1")
+channelsToWrite = int(channelsToWriteRaw, 16 if channelsToWriteRaw.lower().startswith("0x") else 10)
+if channelsToWrite > MAX_NUM_CHANNELS:
+ sys.exit(
+ f"userPrefs.jsonc: USERPREFS_CHANNELS_TO_WRITE is {channelsToWrite}, "
+ f"the channel table holds {MAX_NUM_CHANNELS}"
+ )
+for i in range(MAX_NUM_CHANNELS):
+ prefix = f"USERPREFS_CHANNEL_{i}_"
+ if any(k.startswith(prefix) for k in list(userPrefs)):
+ for field, default in CHANNEL_FIELD_DEFAULTS.items():
+ userPrefs.setdefault(prefix + field, default)
+
pref_flags = []
# Pre-process the userPrefs
for pref in userPrefs:
@@ -306,6 +337,8 @@ for pref in userPrefs:
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
elif userPrefs[pref].lstrip("-").replace(".", "").isdigit():
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
+ elif re.fullmatch(r"0[xX][0-9a-fA-F]+", userPrefs[pref]):
+ 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_"):
diff --git a/bin/test-lint-unset-sentinel-millis.sh b/bin/test-lint-unset-sentinel-millis.sh
new file mode 100755
index 0000000000..ab0923182f
--- /dev/null
+++ b/bin/test-lint-unset-sentinel-millis.sh
@@ -0,0 +1,481 @@
+#!/usr/bin/env bash
+# test-lint-unset-sentinel-millis.sh - self-test for bin/lint-unset-sentinel-millis.sh.
+#
+# The scanner has to tell an arming write apart from a read, a disarm, a shadowing local, a quoted
+# string and an already-fixed site. Each case below is a fixture: a snippet, the lines that must be
+# reported, and nothing else. Run it after any change to the rule.
+#
+# Exit 0 = all cases pass, 1 = at least one case failed.
+
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+LINT="$ROOT_DIR/bin/lint-unset-sentinel-millis.sh"
+
+WORK=$(mktemp -d)
+trap 'rm -rf "$WORK"' EXIT
+
+FAILURES=0
+
+# run_case
+run_case() {
+ local name="$1" expect="$2" body="$3"
+ # The rule only looks at src/, and reports paths relative to $PWD, so the fixture has to live
+ # under a src/ directory that is also the working directory's child.
+ local dir="$WORK/case"
+ rm -rf "$dir"
+ mkdir -p "$dir/src"
+ printf '%s\n' "$body" >"$dir/src/fixture.cpp"
+
+ local got
+ got=$(cd "$dir" && "$LINT" src/fixture.cpp | awk -F: '{print $2}' | paste -sd, -)
+ local want
+ want=$(printf '%s' "$expect" | paste -sd, -)
+
+ if [[ $got == "$want" ]]; then
+ echo "PASS $name"
+ else
+ echo "FAIL $name: expected lines [$want], got [$got]"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+# run_case_h - same, but the fixture is a HEADER, so class-scope
+# cases can be pinned. A typed declaration means opposite things in a class body and a function body.
+run_case_h() {
+ local name="$1" expect="$2" body="$3"
+ local dir="$WORK/case_h"
+ rm -rf "$dir"
+ mkdir -p "$dir/src"
+ printf '%s\n' "$body" >"$dir/src/fixture.h"
+
+ local got want
+ got=$(cd "$dir" && "$LINT" src/fixture.h | awk -F: '{print $2}' | paste -sd, -)
+ want=$(printf '%s' "$expect" | paste -sd, -)
+
+ if [[ $got == "$want" ]]; then
+ echo "PASS $name"
+ else
+ echo "FAIL $name: expected lines [$want], got [$got]"
+ FAILURES=$((FAILURES + 1))
+ fi
+}
+
+# --- must be reported ---------------------------------------------------------
+
+run_case "bare millis() sum" "2" 'void f() {
+ rebootAtMsec = millis() + 5000;
+}'
+
+run_case "bare millis() stamp" "2" 'void f() {
+ shutdownAtMsec = millis();
+}'
+
+run_case "Time::getMillis() sum" "2" 'void f() {
+ enterDfuAtMsec = Time::getMillis() + 25;
+}'
+
+run_case "qualified and arrow targets" "2
+3" 'void f(MeshPacket *txp) {
+ NotificationRenderer::alertBannerUntil = millis() + durationMs;
+ txp->tx_after = millis() + delay;
+}'
+
+run_case "statement split across lines" "2" 'void f() {
+ ntp_renew =
+ millis() + 43200 * 1000;
+}'
+
+run_case "parenthesised sum" "2" 'void f() {
+ rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+}'
+
+run_case "two writes on one line" "2
+2" 'void f() {
+ rebootAtMsec = millis() + 5; shutdownAtMsec = millis();
+}'
+
+run_case "code after a block comment on the same line" "2" 'void f() {
+ /* arm it */ pulseOffAt = millis() + durationMs;
+}'
+
+# --- must NOT be reported ----------------------------------------------------
+
+run_case "already fixed - timerEndsAtMillis" "" 'void f() {
+ rebootAtMsec = Time::timerEndsAtMillis(5000);
+}'
+
+run_case "already fixed - skipZero stamp" "" 'void f() {
+ shutdownAtMsec = Time::skipZero(Time::getMillis());
+}'
+
+run_case "already fixed - ternary keeping the 0 arm" "" 'void f() {
+ NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs);
+}'
+
+run_case "disarm" "" 'void f() {
+ rebootAtMsec = 0;
+ shutdownAtMsec = 0;
+}'
+
+run_case "reads and comparisons" "" 'void f() {
+ if (rebootAtMsec && Throttle::deadlinePassed(rebootAtMsec)) {}
+ if (shutdownAtMsec == 0 && millis() > 5) {}
+ if (tx_after != 0) {}
+}'
+
+run_case "shadowing local declaration" "" 'void f() {
+ uint32_t tx_after = millis() + 100;
+ unsigned long rxTimeMsec = millis();
+}'
+
+run_case "longer identifier containing a sentinel name" "" 'void f() {
+ myRebootAtMsec = millis() + 5000;
+ lastRxTimeMsec = millis();
+}'
+
+run_case "inside a line comment" "" 'void f() {
+ // rebootAtMsec = millis() + 5000;
+}'
+
+run_case "inside a block comment" "" 'void f() {
+/*
+ rebootAtMsec = millis() + 5000;
+*/
+}'
+
+run_case "inside a string literal" "" 'void f() {
+ LOG_DEBUG("rebootAtMsec = millis() + 5000");
+}'
+
+run_case "copy from another variable" "" 'void f() {
+ rebootAtMsec = otherDeadline;
+}'
+
+run_case "compound assignment" "" 'void f() {
+ rebootAtMsec += millis();
+}'
+
+# A field that is simply not on the list, and a local that never persists. nagCycleCutoff is NOT
+# used as the example here: it is off the list because its exemption was rejected, not because 0 is
+# safe for it, so pinning it as a negative fixture would encode the opposite of what the header says.
+run_case "unlisted field and a local deadline" "" 'void f() {
+ someUnrelatedDeadline = millis() + durationMs;
+ const uint32_t deadline = millis() + BODY_TIMEOUT_MS;
+}'
+
+# --- opt-out comments --------------------------------------------------------
+
+run_case "opt-out on the same line" "" 'void f() {
+ lastSort = millis(); // unset-sentinel-ok: sortingIsPaused gates it, 0 is legal
+}'
+
+run_case "opt-out on the line above" "" 'void f() {
+ // unset-sentinel-ok: busyTx carries the armed state
+ tx_after = millis() + d;
+}'
+
+run_case "opt-out above, separated by more comment lines" "" 'void f() {
+ // unset-sentinel-ok: a separate flag carries the armed state
+ // and here is some more explanation spilling onto another line
+ // and another
+ tx_after = millis() + d;
+}'
+
+run_case "opt-out in a block comment" "" 'void f() {
+ /* unset-sentinel-ok: a separate flag carries the armed state */
+ tx_after = millis() + d;
+}'
+
+run_case "opt-out in a multi-line block comment" "" 'void f() {
+ /*
+ * unset-sentinel-ok: a separate flag carries the armed state
+ */
+ tx_after = millis() + d;
+}'
+
+# A bare marker is reported rather than honoured, so nothing can be muted silently.
+run_case "bare opt-out with no reason" "2" 'void f() {
+ rebootAtMsec = millis() + 5000; // unset-sentinel-ok
+}'
+
+run_case "bare opt-out with a colon but nothing after it" "2" 'void f() {
+ rebootAtMsec = millis() + 5000; // unset-sentinel-ok:
+}'
+
+# Must not be mutable from data. A marker inside a string literal is not a comment.
+run_case "marker inside a string literal does not mute" "3" 'void f() {
+ LOG_DEBUG("unset-sentinel-ok: pretend this counts");
+ rebootAtMsec = millis() + 5000;
+}'
+
+run_case "marker in a trailing string on the same line does not mute" "2" 'void f() {
+ rebootAtMsec = millis() + 5000; LOG_DEBUG("unset-sentinel-ok: nope");
+}'
+
+# The opt-out is consumed by the statement it was written for and must not leak onward.
+run_case "opt-out does not leak to the next write" "3" 'void f() {
+ lastSort = millis(); // unset-sentinel-ok: legal here
+ rebootAtMsec = millis() + 5000;
+}'
+
+run_case "opt-out attached to an unrelated statement does not leak" "3" 'void f() {
+ int x = 1; // unset-sentinel-ok: nothing to do with the line below
+ rebootAtMsec = millis() + 5000;
+}'
+
+run_case "opt-out covers both writes on its own line only" "3" 'void f() {
+ tx_after = millis() + 1; lastSort = millis(); // unset-sentinel-ok: both legal
+ rebootAtMsec = millis() + 5000;
+}'
+
+# --- clock held in a local ---------------------------------------------------
+#
+# The commonest shape in the tree: one `now = millis()` at the top of a runOnce(), then several
+# writes from it. Without these the rule is blind to every such field and listing one buys nothing.
+
+run_case "stamp copied from a tainted local" "3" 'void f() {
+ unsigned long now = millis();
+ rebootAtMsec = now;
+}'
+
+run_case "deadline built from a tainted local" "3" 'void f() {
+ uint32_t now = Time::getMillis();
+ tx_after = now + delay;
+}'
+
+run_case "several writes from one tainted local" "3
+4
+5" 'void f() {
+ unsigned long now = millis();
+ pulseOffAt = now;
+ rebootAtMsec = now + 5000;
+ lastSort = now;
+}'
+
+run_case "taint carried one hop through another local" "4" 'void f() {
+ uint32_t now = millis();
+ uint32_t alsoNow = now;
+ lastSort = alsoNow;
+}'
+
+run_case "tainted local still fixable via the helpers" "" 'void f() {
+ unsigned long now = millis();
+ rebootAtMsec = Time::skipZero(now);
+}'
+
+run_case "tainted local with an opt-out" "" 'void f() {
+ unsigned long now = millis();
+ // unset-sentinel-ok: heldX carries the armed state
+ nextRepeatX = now + JOY_REPEAT_INTERVAL_MS;
+}'
+
+# --- the taint must NOT spread further than one function, one name -----------
+
+run_case "untainted local is not flagged" "" 'void f() {
+ uint32_t now = packet->rx_time;
+ rebootAtMsec = now;
+}'
+
+run_case "similarly named local is not tainted" "" 'void f() {
+ uint32_t now = millis();
+ rebootAtMsec = nowMs;
+}'
+
+run_case "taint dropped when the local is reassigned from something else" "" 'void f() {
+ uint32_t now = millis();
+ now = packet->rx_time;
+ rebootAtMsec = now;
+}'
+
+run_case "taint does not cross a function boundary" "" 'void f() {
+ uint32_t now = millis();
+}
+void g() {
+ rebootAtMsec = now;
+}'
+
+run_case "taint from a comparison is not recorded" "" 'void f() {
+ if (now == millis()) {}
+ rebootAtMsec = now;
+}'
+
+run_case "compound assignment does not taint" "" 'void f() {
+ now += millis();
+ rebootAtMsec = now;
+}'
+
+# --- one write must not be judged by its neighbour on the same line ----------
+#
+# rhs used to run to the end of the accumulated statement, so a neighbour decided this write.
+
+run_case "raw write is not excused by a helper call later on the line" "2" 'void f() {
+ rebootAtMsec = millis() + 5; shutdownAtMsec = Time::timerEndsAtMillis(10);
+}'
+
+run_case "safe copy is not blamed for a raw write later on the line" "2" 'void f() {
+ rebootAtMsec = otherDeadline; shutdownAtMsec = millis();
+}'
+
+run_case "helper call is not blamed for a raw write later on the line" "2" 'void f() {
+ rebootAtMsec = Time::skipZero(Time::getMillis()); shutdownAtMsec = millis();
+}'
+
+run_case "two raw writes on one line are both reported" "2
+2" 'void f() {
+ rebootAtMsec = millis() + 5; shutdownAtMsec = millis();
+}'
+
+run_case "two helper writes on one line are both quiet" "" 'void f() {
+ rebootAtMsec = Time::timerEndsAtMillis(5); shutdownAtMsec = Time::skipZero(Time::getMillis());
+}'
+
+run_case "multi-line statement still sees its whole right-hand side" "2" 'void f() {
+ ntp_renew =
+ millis() + 43200 * 1000;
+}'
+
+# --- stampMillis() is the read-side dodge, not a raw clock -------------------
+#
+# Its name ends in millis, so the clock-read test matches it. It must still count as safe, or every
+# site that normalises at the read and then stores the local gets flagged.
+
+run_case "storing a dodged local straight through is safe" "" 'void f() {
+ uint32_t now = Time::stampMillis();
+ lastSort = now;
+}'
+
+# A dodged value is safe to store or copy, NOT to do arithmetic on: stampMillis() guarantees only its
+# own result, and 0xFFFFEC78 + 5000 is exactly 0. That sum is what timerEndsAtMillis() is for.
+run_case "arithmetic on a dodged local can wrap back onto 0" "3" 'void f() {
+ uint32_t now = Time::stampMillis();
+ rebootAtMsec = now + 5000;
+}'
+
+run_case "arithmetic on a direct helper call is reported too" "2" 'void f() {
+ rebootAtMsec = Time::stampMillis() + 5000;
+}'
+
+run_case "an operator INSIDE the helper call is fine" "" 'void f() {
+ lastSort = Time::skipZero(Time::getMillis() - msAgo);
+}'
+
+run_case "copying a dodged local one more hop stays safe" "" 'void f() {
+ uint32_t now = Time::stampMillis();
+ uint32_t alsoNow = now;
+ lastSort = alsoNow;
+}'
+
+run_case "stampMillis directly in the write is safe" "" 'void f() {
+ lastSort = Time::stampMillis();
+}'
+
+run_case "a raw read after a safe one re-taints the local" "5" 'void f() {
+ uint32_t now = Time::stampMillis();
+ lastSort = now;
+ now = millis();
+ rebootAtMsec = now;
+}'
+
+# --- class scope versus function scope ---------------------------------------
+#
+# A typed declaration is a shadowing local inside a function, but AT CLASS SCOPE it is the field
+# itself, with an initializer that can read the clock - src/modules/SerialModule.h does that today.
+# Excusing the second as a local silently skipped a real arm site.
+
+run_case_h "class member initialised from the clock is reported" "2" 'class Foo {
+ uint32_t lastSort = millis();
+};'
+
+run_case_h "local inside an inline method is still excused" "6" 'class Foo {
+ void tick()
+ {
+ uint32_t lastSort = millis();
+ }
+ uint32_t lastDrawMsec = millis();
+};'
+
+run_case_h "member already routed through the helpers is quiet" "" 'class Foo {
+ uint32_t lastSort = Time::stampMillis();
+};'
+
+run_case_h "forward declaration does not open a class body" "3" 'class Foo;
+void f() {
+ lastSort = millis();
+}'
+
+# --- class scope must not swallow ordinary function bodies --------------------
+#
+# The keyword appears mid-line in shapes that are not class bodies, and a body opened on the same
+# line puts the statement inside a function. All three reported every typed local in the body.
+
+run_case "template on a function is not a class body" "" 'template void f(T x) {
+ uint32_t lastSort = millis();
+}'
+
+run_case "struct in a parameter list is not a class body" "" 'void g(struct Bar *b) {
+ uint32_t lastSort = millis();
+}'
+
+run_case_h "one-line inline method is a function body" "" 'class Foo {
+ void tick() { uint32_t lastSort = millis(); }
+};'
+
+run_case_h "class with a multi-line method: member yes, local no" "6" 'class Foo {
+ void tick()
+ {
+ uint32_t lastSort = millis();
+ }
+ uint32_t lastDrawMsec = millis();
+};'
+
+# note_taint gets the same per-write cut the judging path has.
+run_case "taint is not learned from a neighbour on the same line" "" 'void f() {
+ uint32_t a = 0; uint32_t now = packet->rx_time;
+ rebootAtMsec = now;
+}'
+
+# --- a class body that opens and closes on one line ---------------------------
+#
+# The trailing semicolon cannot be used to rule out a class header, because the whole body fits on
+# the line; and that line\'s own brace is the CLASS brace, not a function body.
+
+run_case_h "one-line class body reports its member initialiser" "1" 'class Foo { uint32_t lastSort = millis(); };'
+
+run_case_h "one-line class with a one-line method excuses the local" "" 'class Foo { void tick() { uint32_t lastSort = millis(); } };'
+
+run_case_h "forward declaration opens nothing" "3" 'class Foo;
+void f() {
+ lastSort = millis();
+}'
+
+# --- scope -------------------------------------------------------------------
+
+# test/ builds raw wrap values on purpose, so the rule must not reach into it.
+mkdir -p "$WORK/scope/test"
+printf 'void f() { rebootAtMsec = millis() + 5000; }\n' >"$WORK/scope/test/test_main.cpp"
+if [[ -z $(cd "$WORK/scope" && "$LINT" test/test_main.cpp) ]]; then
+ echo "PASS test/ is out of scope"
+else
+ echo "FAIL test/ is out of scope: expected no findings"
+ FAILURES=$((FAILURES + 1))
+fi
+
+# The helpers' own header must not report itself.
+mkdir -p "$WORK/self/src"
+printf 'void f() { rebootAtMsec = millis() + 5000; }\n' >"$WORK/self/src/UptimeClock.h"
+if [[ -z $(cd "$WORK/self" && "$LINT" src/UptimeClock.h) ]]; then
+ echo "PASS src/UptimeClock.h is exempt"
+else
+ echo "FAIL src/UptimeClock.h is exempt: expected no findings"
+ FAILURES=$((FAILURES + 1))
+fi
+
+echo
+if [[ $FAILURES -eq 0 ]]; then
+ echo "RESULT: PASS"
+ exit 0
+fi
+echo "RESULT: FAIL ($FAILURES case(s))"
+exit 1
diff --git a/bin/test-shards.py b/bin/test-shards.py
new file mode 100755
index 0000000000..4e02093942
--- /dev/null
+++ b/bin/test-shards.py
@@ -0,0 +1,267 @@
+#!/usr/bin/env python3
+"""Emit the native-test CI matrix: one shard per matrix row, derived from test/.
+
+Shards are safe to run in parallel because isolation is per suite, not per run: every suite gets
+its own scratch $HOME via bin/pio-test-isolate.sh.
+
+Two kinds of row come out:
+
+ * general - a slice of the test_* tree under [env:coverage]. AREA_RULES place each suite, first
+ match wins, unmatched to "misc". Areas over --max-suites split, smaller ones pack together.
+
+ * fixed-env - one row per SPECIAL_ENVS entry, whose suite list is read from its test_filter in
+ platformio.ini rather than restated here.
+
+Usage:
+ bin/test-shards.py # matrix JSON on stdout
+ bin/test-shards.py --summary # ... plus a human-readable table on stderr
+ bin/test-shards.py --max-suites 8 # smaller shards, more of them
+ bin/test-shards.py --seed 12345 # vary which suites share a shard
+
+Exit: 0 ok, 2 on a malformed tree or a fixed env whose test_filter went missing.
+"""
+
+from __future__ import annotations
+
+import argparse
+import configparser
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+
+# Ordered "area name" -> regex; first match wins. Extend an area by widening its regex, add an area
+# by inserting a line. Anything unmatched lands in FALLBACK_AREA.
+AREA_RULES = [
+ ("admin", r"^test_(admin|pki)_"),
+ ("crypto", r"^test_(crypto|packet_signing)$"),
+ ("routing", r"^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_"),
+ ("position", r"^test_position_"),
+ ("fuzz", r"^test_fuzz_"),
+ ("packets", r"^test_(packet|transmit|meshpacket)_"),
+ ("io", r"^test_(serial|stream|xmodem|http|mqtt)"),
+]
+FALLBACK_AREA = "misc"
+
+# Envs that rebuild a fixed set of suites with different build flags. Their test_filter lives in
+# the ini and is read from there.
+NATIVE_INI = REPO / "variants" / "native" / "portduino" / "platformio.ini"
+SPECIAL_ENVS = ["coverage-event-policy", "coverage-channel-table"]
+
+# Suite names reach a shell as `-f `. Constrained here, the one place the list is produced,
+# so a creatively named directory cannot become shell text.
+SUITE_RE = re.compile(r"^test_[A-Za-z0-9_]+$")
+
+
+def discover_suites():
+ """Every test_* directory directly under test/, sorted. The canonical set."""
+ suites = sorted(
+ p.name for p in (REPO / "test").iterdir() if p.is_dir() and p.name.startswith("test_")
+ )
+ bad = [s for s in suites if not SUITE_RE.match(s)]
+ if bad:
+ sys.exit(f"test-shards: refusing to shard, unusable suite name(s): {' '.join(bad)}")
+ if not suites:
+ sys.exit("test-shards: no test_* directories under test/ - the tree is not what it should be")
+ return suites
+
+
+def shuffle(seed, items):
+ """Reorder via bin/lib/shuffle.sh, the one implementation of the seeded shuffle.
+
+ Two copies of a Fisher-Yates would drift, and announce it as a replay reproducing a different
+ arrangement. The repo path goes in as $1 so a checkout directory never becomes shell source.
+ """
+ script = 'source "$1"; shift; shuffle_suites "$@"'
+ out = subprocess.run(
+ ["bash", "-c", script, "_", str(REPO / "bin" / "lib" / "shuffle.sh"), seed, *items],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return out.stdout.split()
+
+
+def areas_of(suites):
+ """Bucket suites into areas, preserving AREA_RULES order and putting misc last."""
+ grouped = {name: [] for name, _ in AREA_RULES}
+ grouped[FALLBACK_AREA] = []
+ for suite in suites:
+ area = next((name for name, rule in AREA_RULES if re.search(rule, suite)), FALLBACK_AREA)
+ grouped[area].append(suite)
+ return {name: members for name, members in grouped.items() if members}
+
+
+def split(members, cap):
+ """Split into the fewest chunks of at most `cap`, sized as evenly as the count allows.
+
+ Wall clock is the slowest shard, so 11 suites at cap 10 becomes 6+5, not 10+1.
+ """
+ chunks = -(-len(members) // cap) # ceil
+ base, extra = divmod(len(members), chunks)
+ out, start = [], 0
+ for i in range(chunks):
+ size = base + (1 if i < extra else 0)
+ out.append(members[start : start + size])
+ start += size
+ return out
+
+
+def pack(areas, cap):
+ """Pack whole areas into the fewest shards of at most `cap`, keeping the loads even.
+
+ Longest-processing-time-first: within 4/3 of optimal, and unlike first-fit it will not leave
+ one shard holding a single two-suite area.
+ """
+ load = lambda b: sum(len(areas[a]) for a in b) # noqa: E731
+ ranked = sorted(areas, key=lambda a: len(areas[a]), reverse=True)
+ # ceil(total / cap) is a lower bound, not a guarantee - whole areas do not divide, so three
+ # areas of 6 at cap 10 would put 12 in one of two bins. Grow the count until every bin fits.
+ for count in range(-(-sum(len(m) for m in areas.values()) // cap), len(areas) + 1):
+ bins = [[] for _ in range(count)]
+ for area in ranked:
+ min(bins, key=load).append(area)
+ if all(load(b) <= cap for b in bins):
+ break
+ # Report each shard's areas in declared order, so a name reads the same way the rules do.
+ order = list(areas)
+ return [sorted(b, key=order.index) for b in bins if b]
+
+
+def build(areas, cap):
+ """Lay the areas out into shards of at most `cap` suites. Returns (rows, suites placed)."""
+ rows, placed, small = [], [], {}
+ # Oversized areas become numbered shards of their own; what is left is packed together.
+ for area, members in areas.items():
+ if len(members) <= cap:
+ small[area] = members
+ continue
+ for i, chunk in enumerate(split(members, cap), start=1):
+ rows.append({"shard": f"{area}-{i}", "env": "coverage", "suites": " ".join(chunk)})
+ placed += chunk
+ for group in pack(small, cap):
+ members = [suite for area in group for suite in small[area]]
+ rows.append({"shard": "+".join(group), "env": "coverage", "suites": " ".join(members)})
+ placed += members
+ return rows, placed
+
+
+def fixed_env_filter(env):
+ """The suites [env:] pins in its own test_filter."""
+ # interpolation=None: platformio.ini interpolates with ${section.option}, not configparser's
+ # %(name)s, so a bare % in any value elsewhere in the file would otherwise abort the parse.
+ parser = configparser.ConfigParser(strict=False, interpolation=None)
+ parser.read(NATIVE_INI, encoding="utf-8")
+ section = f"env:{env}"
+ if not parser.has_option(section, "test_filter"):
+ sys.exit(
+ f"test-shards: [{section}] in {NATIVE_INI.name} has no test_filter. It had one when this "
+ f"matrix was written; either restore it or drop {env} from SPECIAL_ENVS - silently "
+ f"emitting an empty filter would run every suite under the wrong build flags."
+ )
+ # test_filter accepts globs, and these tokens reach the same unquoted word-split and the same
+ # attribution gate as discovered names. Hold them to SUITE_RE too, at the producer.
+ names = parser.get(section, "test_filter").split()
+ bad = [n for n in names if not SUITE_RE.match(n)]
+ if bad:
+ sys.exit(
+ f"test-shards: [{section}] test_filter names something that is not a literal suite: "
+ f"{' '.join(bad)}. The matrix and the attribution gate both need exact names."
+ )
+ return names
+
+
+def main():
+ ap = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
+ )
+ ap.add_argument(
+ "--max-suites",
+ type=int,
+ default=10,
+ help="largest shard, in suites (default: 10). Lower is faster and costs more runners; the "
+ "per-shard floor is checkout + toolchain + one src build, so past ~8 the fixed cost wins.",
+ )
+ ap.add_argument(
+ "--max-shards",
+ type=int,
+ default=24,
+ help="hard ceiling on matrix rows (default: 24). A budget, not a preference: --max-suites "
+ "is raised until the matrix fits, so no branch can size the fan-out by adding directories.",
+ )
+ ap.add_argument(
+ "--seed",
+ default="",
+ help="vary which suites share a shard. Co-location, not order - PlatformIO picks the order "
+ "within a shard either way. Empty means the declared alphabetical arrangement.",
+ )
+ ap.add_argument("--summary", action="store_true", help="also print the shard table to stderr")
+ args = ap.parse_args()
+
+ if args.max_suites < 1:
+ sys.exit("test-shards: --max-suites must be at least 1")
+ if args.max_shards <= len(SPECIAL_ENVS):
+ sys.exit(f"test-shards: --max-shards must leave room for the {len(SPECIAL_ENVS)} fixed envs")
+
+ suites = discover_suites()
+ areas = areas_of(suites)
+ if args.seed:
+ areas = {area: shuffle(args.seed, members) for area, members in areas.items()}
+
+ # Shard size is a preference, shard count is a budget: without this a branch could size the
+ # fan-out by adding directories. --max-suites gives way so the runner count stays bounded.
+ cap = args.max_suites
+ while True:
+ rows, placed = build(areas, cap)
+ if len(rows) + len(SPECIAL_ENVS) <= args.max_shards:
+ break
+ cap += 1
+ if cap != args.max_suites:
+ print(
+ f"test-shards: {len(suites)} suites would need more than {args.max_shards} shards at "
+ f"--max-suites {args.max_suites}; using {cap} per shard instead.",
+ file=sys.stderr,
+ )
+
+ # Prove nothing fell out, rather than discovering an unrun suite from a coverage graph later.
+ if sorted(placed) != suites:
+ missing = sorted(set(suites) - set(placed))
+ sys.exit(f"test-shards: {len(missing)} suite(s) reached no shard: {' '.join(missing)}")
+
+ for env in SPECIAL_ENVS:
+ rows.append(
+ {
+ "shard": env.removeprefix("coverage-"),
+ "env": env,
+ "suites": " ".join(fixed_env_filter(env)),
+ }
+ )
+
+ # Exactly one shard writes the shared compiler cache: all of them compile the same src/ tree,
+ # and letting each save would race for the key and store the same objects a dozen times.
+ for row in rows:
+ row["cache_writer"] = False
+ rows[0]["cache_writer"] = True
+
+ if args.summary:
+ width = max(len(row["shard"]) for row in rows)
+ for row in rows:
+ count = len(row["suites"].split())
+ print(
+ f" {row['shard']:<{width}} {row['env']:<24} {count:>2} suite(s)", file=sys.stderr
+ )
+ print(
+ f" {len(rows)} shard(s), {len(suites)} suite(s) in test/, "
+ f"largest shard {max(len(row['suites'].split()) for row in rows)}",
+ file=sys.stderr,
+ )
+
+ print(json.dumps({"include": rows}))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/bin/test-state-check.sh b/bin/test-state-check.sh
index 14dad9d447..250bf1a9f1 100755
--- a/bin/test-state-check.sh
+++ b/bin/test-state-check.sh
@@ -19,6 +19,8 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR" || exit 1
+# shellcheck source=bin/lib/test-state.sh
+source "$SCRIPT_DIR/lib/test-state.sh"
WORK="$(mktemp -d -t meshstatecheck.XXXXXX)"
trap 'rm -rf "$WORK"' EXIT
@@ -97,18 +99,32 @@ mkdir -p "$HOME/.portduino/default/prefs"
# Detached from this shell's stdout so the wrapper's `| tee` sees EOF and the pipeline returns -
# the survivor outlives the suite exactly as a spun loop() does.
setsid sleep 300 >/dev/null 2>&1 &
-printf '%s\n' "$!" > "$HOME/../survivor.pid"
+survivor=$!
+# A real survivor has been running for the whole suite; this one is a microsecond old, and the
+# wrapper scans the instant this shell exits - so wait for the exec before reporting it up.
+for _ in {1..500}; do [[ "$(cat "/proc/$survivor/comm" 2>/dev/null)" == sleep ]] && break; sleep 0.01; done
+# Recheck rather than trust the loop: falling out of it on the timeout would stage a pid the
+# fixture never saw reach exec, which is the race this wait exists to close.
+[[ "$(cat "/proc/$survivor/comm" 2>/dev/null)" == sleep ]] || { echo "fixture: survivor never reached exec" >&2; exit 1; }
+printf '%s\n' "$survivor" > "$HOME/../survivor.pid"
exit 0
EOF
chmod +x "$LEAKY"
survivor_dir="$WORK/state-survivor"
mkdir -p "$survivor_dir"
+# KEEP_STATE so the sandbox stays whatever the verdict: without it a missed survivor also deletes
+# the pid file staged inside it, and the reap assertion below fails for the wrong reason.
FIXTURE_SUITE=test_fixture_survivor \
MESHTASTIC_TEST_STATE_DIR="$survivor_dir" \
MESHTASTIC_TEST_STATE_SUMMARY="$survivor_dir/summary.tsv" \
MESHTASTIC_TEST_STATE_MANIFEST="$MANIFEST" \
- "$SCRIPT_DIR/pio-test-isolate.sh" "$LEAKY" >/dev/null 2>&1
+ MESHTASTIC_TEST_KEEP_STATE=1 \
+ "$SCRIPT_DIR/pio-test-isolate.sh" "$LEAKY" >/dev/null 2>"$survivor_dir/wrapper.err"
+
+# Find the pid file by search, not by a glob that assumes a directory depth: the wrapper renames
+# its mktemp'd sandbox to the suite name when it keeps it, so the path is not fixed.
+leaked_pid="$(head -1 "$(find "$survivor_dir" -name survivor.pid -print -quit 2>/dev/null)" 2>/dev/null)"
recorded="$(awk -F'\t' '$1 == "test_fixture_survivor" { print $6; exit }' "$survivor_dir/summary.tsv" 2>/dev/null)"
if [[ -n ${recorded// /} ]]; then
@@ -116,14 +132,28 @@ if [[ -n ${recorded// /} ]]; then
PASSES=$((PASSES + 1))
else
echo " FAIL a process outliving the suite went unreported"
+ # Name the cause here rather than spending a CI round-trip on it: whether the fixture's
+ # process exists at all, whether the scan can see it, and what the wrapper recorded instead.
+ visible_pids="$(ps -u "$(id -u)" -o pid= 2>/dev/null | tr -d ' ')"
+ if [[ -z $leaked_pid ]]; then
+ alive="no pid staged"
+ listed="n/a"
+ its_home=""
+ else
+ kill -0 "$leaked_pid" 2>/dev/null && alive="alive" || alive="gone"
+ grep -Fxq "$leaked_pid" <<<"$visible_pids" && listed="yes" || listed="no"
+ its_home="$(tr '\0' '\n' <"/proc/$leaked_pid/environ" 2>/dev/null | grep -m1 '^HOME=')"
+ fi
+ echo " summary line: $(awk -F'\t' '$1 == "test_fixture_survivor"' "$survivor_dir/summary.tsv" 2>/dev/null | tr '\t' '|')"
+ echo " staged pid ${leaked_pid:-}: $alive, listed by ps: $listed, its ${its_home:-}"
+ echo " ps -u $(id -u) listed $(grep -c . <<<"$visible_pids") pids; the sandbox is under $survivor_dir"
+ echo " wrapper stderr: $(tr '\n' '|' <"$survivor_dir/wrapper.err" 2>/dev/null)"
+ echo " same scan, run again now: [$(state_find_survivors "${its_home#HOME=}" | tr '\n' ' ')]"
FAILURES=$((FAILURES + 1))
fi
-# Find the pid file by search, not by a glob that assumes a directory depth: the wrapper renames
-# its mktemp'd sandbox to the suite name when it keeps it, so the path is not fixed. Assert the
-# file was found BEFORE asserting the process is gone - otherwise an empty pid takes the "not
-# running" branch and the check passes without having checked anything.
-leaked_pid="$(cat "$(find "$survivor_dir" -name survivor.pid -print -quit 2>/dev/null)" 2>/dev/null | head -1)"
+# Assert the pid file was found BEFORE asserting the process is gone - otherwise an empty pid takes
+# the "not running" branch and the check passes without having checked anything.
if [[ -z $leaked_pid ]]; then
echo " FAIL no survivor pid recorded - the reap assertion would pass vacuously"
FAILURES=$((FAILURES + 1))
@@ -140,8 +170,6 @@ fi
# it; exercise the assertion the wrapper actually calls instead - same function, same code path.
echo
echo "Before-empty assertion (state_assert_empty):"
-# shellcheck source=bin/lib/test-state.sh
-source "$SCRIPT_DIR/lib/test-state.sh"
seeded="$WORK/seeded"
mkdir -p "$seeded/$PREFS"
diff --git a/boards/nrf54l15dk.json b/boards/nrf54l15dk.json
deleted file mode 100644
index 863ad290b4..0000000000
--- a/boards/nrf54l15dk.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "build": {
- "cpu": "cortex-m33",
- "f_cpu": "128000000L",
- "mcu": "nrf54l15",
- "zephyr": {
- "variant": "nrf54l15dk/nrf54l15/cpuapp"
- }
- },
- "connectivity": ["bluetooth"],
- "debug": {
- "default_tools": ["jlink"],
- "jlink_device": "nRF54L15_M33",
- "svd_path": "nrf54l15.svd"
- },
- "frameworks": ["zephyr"],
- "name": "Nordic nRF54L15-DK (PCA10156)",
- "upload": {
- "maximum_ram_size": 262144,
- "maximum_size": 1572864,
- "protocol": "jlink",
- "protocols": ["jlink"]
- },
- "url": "https://www.nordicsemi.com/Products/nRF54L15",
- "vendor": "Nordic Semiconductor"
-}
diff --git a/boards/seeed-sensecap-indicator.json b/boards/seeed-sensecap-indicator.json
index 37a97cdf19..861b5bafe9 100644
--- a/boards/seeed-sensecap-indicator.json
+++ b/boards/seeed-sensecap-indicator.json
@@ -15,7 +15,6 @@
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
- "f_boot": "120000000L",
"boot": "qio",
"flash_mode": "qio",
"psram_type": "opi",
diff --git a/boards/seeed_wio_tracker_L2.json b/boards/seeed_wio_tracker_L2.json
new file mode 100644
index 0000000000..1421c19a76
--- /dev/null
+++ b/boards/seeed_wio_tracker_L2.json
@@ -0,0 +1,42 @@
+{
+ "build": {
+ "arduino": {
+ "ldscript": "esp32s3_out.ld",
+ "memory_type": "qio_opi"
+ },
+ "core": "esp32",
+ "extra_flags": [
+ "-D BOARD_HAS_PSRAM",
+ "-D ARDUINO_USB_CDC_ON_BOOT=1",
+ "-D ARDUINO_USB_MODE=1",
+ "-D ARDUINO_RUNNING_CORE=1",
+ "-D ARDUINO_EVENT_RUNNING_CORE=1"
+ ],
+ "f_cpu": "240000000L",
+ "f_flash": "80000000L",
+ "flash_mode": "qio",
+ "psram_type": "opi",
+ "hwids": [["0x303A", "0x1001"]],
+ "mcu": "esp32s3",
+ "variant": "esp32s3"
+ },
+ "connectivity": ["wifi", "bluetooth", "lora"],
+ "debug": {
+ "default_tool": "esp-builtin",
+ "onboard_tools": ["esp-builtin"],
+ "openocd_target": "esp32s3.cfg"
+ },
+ "frameworks": ["arduino", "espidf"],
+ "name": "seeed_wio_tracker_L2 (16 MB FLASH, 8 MB PSRAM)",
+ "upload": {
+ "flash_size": "16MB",
+ "maximum_ram_size": 327680,
+ "maximum_size": 16777216,
+ "use_1200bps_touch": true,
+ "wait_for_upload_port": true,
+ "require_upload_port": true,
+ "speed": 921600
+ },
+ "url": "https://www.seeedstudio.com/",
+ "vendor": "Seeed Studio"
+}
diff --git a/boards/t-connect-pro.json b/boards/t-connect-pro.json
new file mode 100644
index 0000000000..a10c9183ad
--- /dev/null
+++ b/boards/t-connect-pro.json
@@ -0,0 +1,40 @@
+{
+ "build": {
+ "arduino": {
+ "ldscript": "esp32s3_out.ld",
+ "memory_type": "qio_opi"
+ },
+ "core": "esp32",
+ "extra_flags": [
+ "-D BOARD_HAS_PSRAM",
+ "-D ARDUINO_USB_CDC_ON_BOOT=1",
+ "-D ARDUINO_USB_MODE=1",
+ "-D ARDUINO_RUNNING_CORE=1",
+ "-D ARDUINO_EVENT_RUNNING_CORE=1"
+ ],
+ "f_cpu": "240000000L",
+ "f_flash": "80000000L",
+ "flash_mode": "qio",
+ "psram_type": "opi",
+ "hwids": [["0x303A", "0x1001"]],
+ "mcu": "esp32s3",
+ "variant": "esp32s3"
+ },
+ "connectivity": ["wifi", "bluetooth", "ethernet", "lora"],
+ "debug": {
+ "default_tool": "esp-builtin",
+ "onboard_tools": ["esp-builtin"],
+ "openocd_target": "esp32s3.cfg"
+ },
+ "frameworks": ["arduino", "espidf"],
+ "name": "LilyGo T-Connect-Pro (16 MB FLASH, 8 MB PSRAM)",
+ "upload": {
+ "flash_size": "16MB",
+ "maximum_ram_size": 327680,
+ "maximum_size": 16777216,
+ "require_upload_port": true,
+ "speed": 921600
+ },
+ "url": "https://lilygo.cc/products/t-connect-pro",
+ "vendor": "LilyGo"
+}
diff --git a/debian/changelog b/debian/changelog
index 6b9d0668ef..ab4b5c11a1 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,27 @@
+meshtasticd (2.8.1.0) unstable; urgency=medium
+
+ * Version 2.8.1
+
+ -- GitHub Actions Tue, 01 Sep 2026 10:59:38 +0000
+
+meshtasticd (2.8.0.0) unstable; urgency=medium
+
+ * Version 2.8.0
+
+ -- GitHub Actions Wed, 24 Jun 2026 11:20:05 +0000
+
+meshtasticd (2.7.26.0) unstable; urgency=medium
+
+ * Version 2.7.26
+
+ -- GitHub Actions Wed, 10 Jun 2026 00:19:23 +0000
+
+meshtasticd (2.7.25.0) unstable; urgency=medium
+
+ * Version 2.7.25
+
+ -- GitHub Actions Sat, 23 May 2026 01:16:20 +0000
+
meshtasticd (2.7.24.0) unstable; urgency=medium
* Version 2.7.24
@@ -73,14 +97,6 @@ meshtasticd (2.7.13.0) unstable; urgency=medium
meshtasticd (2.7.12.0) unstable; urgency=medium
- [ Austin Lane ]
- * Initial packaging
- * Version 2.5.19
-
- [ ]
- * GitHub Actions Automatic version bump
-
- [ GitHub Actions ]
* Version 2.7.12
-- GitHub Actions Wed, 01 Oct 2025 19:51:41 +0000
diff --git a/docs/bme680_iaq_replay.md b/docs/bme680_iaq_replay.md
deleted file mode 100644
index 5fc8d96df3..0000000000
--- a/docs/bme680_iaq_replay.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# BME680 IAQ replay harness
-
-`bin/bme680_iaq_replay.cpp` replays a captured sensor trace through the in-tree
-`BME680IaqEstimator` on a dev machine, for tuning the estimator's constants
-against recorded Bosch BSEC output. The estimator is pure math with no platform
-dependencies, so a trace replays in milliseconds - edit the constants in
-`src/modules/Telemetry/Sensor/BME680IaqEstimator.h`, recompile, rerun.
-
-## Build
-
-From the repo root:
-
-```bash
-c++ -std=c++17 -O2 -I src -o /tmp/iaq_replay \
- bin/bme680_iaq_replay.cpp src/modules/Telemetry/Sensor/BME680IaqEstimator.cpp
-```
-
-## Input
-
-CSV on stdin or as a file argument, one sample per line:
-
-```text
-gas_ohms,relative_humidity[,bsec_iaq]
-```
-
-Lines starting with `#` are ignored; a single non-numeric header row is
-tolerated; any other malformed line is reported on stderr and skipped.
-
-## Capturing a trace
-
-On a firmware build that still links BSEC (any release tag before the BSEC
-removal), add one log line to `BME680Sensor::getMetrics` in the BSEC branch:
-
-```cpp
-LOG_INFO("IAQCSV,%.0f,%.2f,%.0f", bme680.getData(BSEC_OUTPUT_RAW_GAS).signal,
- bme680.getData(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY).signal,
- bme680.getData(BSEC_OUTPUT_IAQ).signal);
-```
-
-then extract the columns from the serial log:
-
-```bash
-grep -o 'IAQCSV,.*' serial.log | cut -d, -f2- > trace.csv
-```
-
-BSEC's `RAW_GAS` and heat-compensated humidity are exactly the estimator's
-inputs, so one physical sensor feeds both algorithms identically.
-
-## Output
-
-Per-sample CSV `n,gas_ohms,rh,est_iaq,bsec_iaq` on stdout (empty `est_iaq`
-during the estimator's warm-up/burn-in window), plus a stderr summary with the
-mean absolute error and UI-band agreement against the `bsec_iaq` column, using
-the same 0-500 band thresholds the device screen applies.
diff --git a/extra_scripts/esp32_fatfs_exfat.py b/extra_scripts/esp32_fatfs_exfat.py
new file mode 100644
index 0000000000..1fc5636a99
--- /dev/null
+++ b/extra_scripts/esp32_fatfs_exfat.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+# trunk-ignore-all(ruff/F821)
+# trunk-ignore-all(flake8/F821): For SConstruct imports
+import re
+from os.path import exists, join
+
+Import("env")
+
+# ---------------------------------------------------------------------------
+# exFAT for the IDF FatFs component.
+#
+# ESP-IDF exposes no Kconfig symbol for exFAT (checked 5.5.x and master):
+# components/fatfs/src/ffconf.h hardcodes "#define FF_FS_EXFAT 0", so
+# CONFIG_FATFS_FS_EXFAT=y in custom_sdkconfig is silently dropped by kconfgen
+# and an exFAT card fails to mount with ESP_FAIL from esp_vfs_fat_sdmmc_mount.
+# This script turns that inert symbol into a real ffconf.h patch.
+#
+# Two headers must agree, because FF_FS_EXFAT changes the FATFS/FIL/DIR layout
+# in ff.h:
+# 1. framework-espidf - compiled into libfatfs.a by the HybridCompile
+# IDF-libs rebuild (arduino.py -> espidf.py). Patched in the pre: pass.
+# 2. framework-arduinoespressif32-libs//include - used when the
+# application and the Arduino SD_MMC library are compiled. espidf.py's
+# idf_lib_copy() copies back archives and sdkconfig.h but no headers, and
+# a custom_sdkconfig hash change wipes the whole package and reinstalls
+# it, so this copy is patched again in the post: pass.
+#
+# framework-espidf is shared by every ESP32 env while this script is registered
+# only on the SDIO variants, so the patch there is undone once the build is
+# done: an env that never asked for exFAT must not rebuild its IDF libs against
+# a header the rest of its build does not see. The per-chip -libs copy stays
+# patched, matching the libfatfs.a it was built with.
+#
+# The libs rebuild is keyed on the md5 of custom_sdkconfig, which the patch
+# state does not otherwise reach, so the state is appended as a comment line
+# (inert in kconfig). Keep the marker lowercase: arduino.py greps
+# custom_sdkconfig for "PSRAM" and "CONFIG_SPIRAM=y".
+# ---------------------------------------------------------------------------
+
+SWITCH = "CONFIG_FATFS_FS_EXFAT=y"
+MARKER_KEY = "meshtastic_fatfs_exfat"
+PATTERN = re.compile(r"^(#define\s+FF_FS_EXFAT\s+)([01])", re.MULTILINE)
+
+
+def wants_exfat(env):
+ config = env.GetProjectConfig()
+ section = "env:" + env["PIOENV"]
+ if not config.has_option(section, "custom_sdkconfig"):
+ return False
+ return any(
+ line.strip() == SWITCH
+ for line in env.GetProjectOption("custom_sdkconfig").splitlines()
+ )
+
+
+def ffconf_paths(env, phase):
+ platform = env.PioPlatform()
+ board = env.BoardConfig()
+ chip = board.get("build.chip_variant", "").lower() or board.get(
+ "build.mcu", "esp32"
+ )
+
+ paths = []
+ if phase == "pre":
+ idf = idf_ffconf(env)
+ if idf:
+ paths.append(idf)
+ libs_dir = platform.get_package_dir("framework-arduinoespressif32-libs")
+ if libs_dir:
+ paths.append(join(libs_dir, chip, "include", "fatfs", "src", "ffconf.h"))
+ return [p for p in paths if exists(p)]
+
+
+def idf_ffconf(env):
+ idf_dir = env.PioPlatform().get_package_dir("framework-espidf")
+ return join(idf_dir, "components", "fatfs", "src", "ffconf.h") if idf_dir else None
+
+
+def set_ff_fs_exfat(path, enable):
+ with open(path) as src:
+ content = src.read()
+ patched = PATTERN.sub(r"\g<1>%d" % (1 if enable else 0), content, count=1)
+ if patched == content:
+ return False
+ with open(path, "w") as dst:
+ dst.write(patched)
+ print("*** FF_FS_EXFAT=%d: %s ***" % (1 if enable else 0, path))
+ return True
+
+
+def tag_sdkconfig_state(env, enable):
+ config = env.GetProjectConfig()
+ section = "env:" + env["PIOENV"]
+ if not config.has_option(section, "custom_sdkconfig"):
+ return
+ current = env.GetProjectOption("custom_sdkconfig")
+ if MARKER_KEY in current:
+ return
+ marker = "# %s: %d" % (MARKER_KEY, 1 if enable else 0)
+ config.set(section, "custom_sdkconfig", current.rstrip("\n") + "\n" + marker)
+
+
+phase = "post" if env.get("MESHTASTIC_EXFAT_PATCHED") else "pre"
+enable = wants_exfat(env)
+
+for path in ffconf_paths(env, phase):
+ set_ff_fs_exfat(path, enable)
+
+if phase == "pre":
+ # revert as well as enable, so an env without the switch never links a
+ # FatFs built with a different struct layout than the headers it compiles against
+ tag_sdkconfig_state(env, enable)
+ env["MESHTASTIC_EXFAT_PATCHED"] = True
+
+ if enable:
+
+ def restore_idf_ffconf(target, source, env):
+ idf = idf_ffconf(env)
+ if idf and exists(idf):
+ set_ff_fs_exfat(idf, False)
+
+ # the IDF libs are compiled during the build phase, so this is the first
+ # point at which the shared package can be handed back unpatched
+ env.AddPostAction("checkprogsize", restore_idf_ffconf)
diff --git a/extra_scripts/esp32_pre.py b/extra_scripts/esp32_pre.py
index b2c4171e7e..d9884862ae 100755
--- a/extra_scripts/esp32_pre.py
+++ b/extra_scripts/esp32_pre.py
@@ -3,7 +3,8 @@
# trunk-ignore-all(flake8/F821): For SConstruct imports
import json
import sys
-from os.path import isfile
+from os import remove
+from os.path import getmtime, isfile, join
Import("env")
@@ -167,3 +168,22 @@ def tag_sdkconfig_cache_key(env):
tag_sdkconfig_cache_key(env)
+
+
+# The platform writes its cache-key hash into sdkconfig.defaults before compiling
+# the IDF libs, so an aborted pass leaves it describing libs that were never built.
+def drop_stale_sdkconfig_defaults(env):
+ defaults = join(env.subst("$PROJECT_DIR"), "sdkconfig.defaults")
+ mcu = env.BoardConfig().get("build.mcu", "esp32")
+ try:
+ libs = env.PioPlatform().get_package_dir("framework-arduinoespressif32-libs")
+ # Rewritten last by a completed compile, so it marks "libs built".
+ if getmtime(join(libs, mcu, "sdkconfig")) >= getmtime(defaults):
+ return
+ except (OSError, TypeError):
+ return
+ print("*** Stale %s IDF libs; forcing a HybridCompile rebuild ***" % mcu)
+ remove(defaults)
+
+
+drop_stale_sdkconfig_defaults(env)
diff --git a/extra_scripts/nrf54l15_linker.py b/extra_scripts/nrf54l15_linker.py
deleted file mode 100644
index 824aae7ce3..0000000000
--- a/extra_scripts/nrf54l15_linker.py
+++ /dev/null
@@ -1,140 +0,0 @@
-#!/usr/bin/env python3
-# trunk-ignore-all(ruff/F821)
-# trunk-ignore-all(flake8/F821): For SConstruct imports
-#
-# post:extra_scripts/nrf54l15_linker.py
-#
-# Fix for Zephyr two-pass link on nRF54L15:
-# platformio-build.py registers env.Depends("$PROG_PATH", final_ld_script) but
-# the SCons dependency chain is broken (final_ld_script Command never runs).
-# This script adds a PreAction on the final firmware binary that runs the gcc
-# preprocessing command directly (extracted from build.ninja) to generate
-# zephyr/linker.cmd before the link step.
-#
-# PlatformIO bundles an old Ninja that can't handle multi-output depslog rules,
-# so we parse the COMMAND line from build.ninja and run just the gcc -E part,
-# skipping the cmake_transform_depfile step (only needed for Ninja deps tracking).
-
-import os
-import re
-import subprocess
-
-Import("env")
-
-if env.get("PIOENV") != "nrf54l15dk":
- pass # Only for the nrf54l15dk environment
-else:
-
- def _extract_gcc_command(ninja_build):
- """Parse build.ninja to find the gcc -E command that generates linker.cmd.
-
- The rule format depends on the host:
- Windows (CMake's RunCMake wraps every command):
- COMMAND = cmd.exe /C "cd /D DIR && arm-none-eabi-gcc.exe ... -o linker.cmd && cmake.exe -E cmake_transform_depfile ..."
- POSIX (Linux/macOS - no wrapper):
- COMMAND = cd DIR && arm-none-eabi-gcc ... -o linker.cmd && cmake -E cmake_transform_depfile ...
-
- Returns (gcc_cmd_string, cwd_path) or raises RuntimeError.
- """
- in_rule = False
- with open(ninja_build, "r", encoding="utf-8", errors="replace") as f:
- for line in f:
- # Detect start of the linker.cmd custom command rule
- if not in_rule:
- if "build zephyr/linker.cmd" in line and "CUSTOM_COMMAND" in line:
- in_rule = True
- continue
-
- stripped = line.strip()
- if not stripped.startswith("COMMAND = "):
- continue
-
- command_val = stripped[len("COMMAND = ") :]
-
- # On Windows the value is wrapped in `cmd.exe /C "..."` - strip
- # the wrapper. On POSIX hosts the inner sequence is the value
- # itself (no quoting layer).
- m = re.search(r'/C\s+"(.*)"\s*$', command_val)
- inner = m.group(1) if m else command_val
- parts = inner.split(" && ")
-
- cwd = None
- gcc_cmd = None
- for part in parts:
- part = part.strip()
- if part.startswith("cd /D "): # Windows form
- cwd = part[len("cd /D ") :]
- elif part.startswith("cd "): # POSIX form
- cwd = part[len("cd ") :]
- elif "arm-none-eabi-gcc" in part:
- gcc_cmd = part
-
- if not gcc_cmd:
- raise RuntimeError(
- "nRF54L15 linker fix: arm-none-eabi-gcc command not found in:\n%s"
- % inner[:400]
- )
-
- return gcc_cmd, cwd
-
- raise RuntimeError(
- "nRF54L15 linker fix: 'build zephyr/linker.cmd' rule not found in build.ninja"
- )
-
- def _generate_linker_cmd(target, source, env):
- """Generate zephyr/linker.cmd via direct gcc invocation before the final link."""
- build_dir = env.subst("$BUILD_DIR")
- zephyr_dir = os.path.join(build_dir, "zephyr")
- linker_cmd = os.path.join(zephyr_dir, "linker.cmd")
-
- if os.path.exists(linker_cmd):
- return # Already present - nothing to do
-
- ninja_build = os.path.join(build_dir, "build.ninja")
- if not os.path.exists(ninja_build):
- raise RuntimeError(
- "nRF54L15 linker fix: build.ninja not found at %s\n"
- "Run a full build first so CMake generates the Ninja files."
- % ninja_build
- )
-
- gcc_cmd, cwd = _extract_gcc_command(ninja_build)
- run_cwd = cwd if cwd else zephyr_dir
-
- print(
- "==> nRF54L15: Generating zephyr/linker.cmd (LINKER_ZEPHYR_FINAL) via GCC"
- )
- # gcc_cmd comes verbatim from our own build.ninja (never user input) and
- # contains Windows-style paths with spaces that cannot be safely argv-split
- # with shlex, so we run it via the platform shell. nosec/nosemgrep below
- # acknowledge this deliberate, scoped use of shell=True.
- result = subprocess.run( # nosec B602
- gcc_cmd,
- shell=True, # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
- cwd=run_cwd,
- capture_output=True,
- text=True,
- )
- if result.returncode != 0:
- print("GCC stdout:", result.stdout[:2000])
- print("GCC stderr:", result.stderr[:2000])
- raise RuntimeError(
- "nRF54L15 linker fix: GCC failed to generate linker.cmd (rc=%d)"
- % result.returncode
- )
- if not os.path.exists(linker_cmd):
- raise RuntimeError(
- "nRF54L15 linker fix: GCC returned 0 but linker.cmd was not created at %s"
- % linker_cmd
- )
- print("==> linker.cmd generated successfully")
-
- # Use PIOMAINPROG (set by ZephyrBuildProgram) to get the exact SCons node
- prog = env.get("PIOMAINPROG")
- if prog:
- env.AddPreAction(prog, _generate_linker_cmd)
- else:
- print(
- "[nrf54l15_linker] WARNING: PIOMAINPROG not set, falling back to $PROG_PATH"
- )
- env.AddPreAction(env.subst("$PROG_PATH"), _generate_linker_cmd)
diff --git a/platformio.ini b/platformio.ini
index 2300847467..aa3e8f9ed6 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -25,8 +25,8 @@ build_flags =
test_build_src = true
extra_scripts =
pre:bin/platformio-pre.py
+ pre:bin/optional-modules.py
bin/platformio-custom.py
- post:extra_scripts/nrf54l15_linker.py
; note: we add src to our include search path so that lmic_project_config can override
; note: TINYGPS_OPTION_NO_CUSTOM_FIELDS is VERY important. We don't use custom fields and somewhere in that pile
; of code is a heap corruption bug!
@@ -89,7 +89,7 @@ lib_deps =
# renovate: datasource=git-refs depName=meshtastic-ArduinoThread packageName=https://github.com/meshtastic/ArduinoThread gitBranch=master
https://github.com/meshtastic/ArduinoThread/archive/b841b0415721f1341ea41cccfb4adccfaf951567.zip
# renovate: datasource=github-tags depName=Nanopb packageName=nanopb/nanopb
- https://github.com/nanopb/nanopb/archive/refs/tags/nanopb-0.4.9.1.zip
+ https://github.com/nanopb/nanopb/archive/0.4.92.zip
# renovate: datasource=github-tags depName=ErriezCRC32 packageName=Erriez/ErriezCRC32
https://github.com/Erriez/ErriezCRC32/archive/refs/tags/1.0.1.zip
@@ -98,6 +98,8 @@ check_tool = cppcheck
check_skip_packages = yes
check_flags =
-DAPP_VERSION=1.0.0
+ ; define PROGMEM to avoid cppcheck reporting unknownMacro (--skip-packages excludes Arduino.h)
+ -DPROGMEM=
--suppressions-list=suppressions.txt
--inline-suppr
@@ -137,10 +139,13 @@ lib_deps =
[device-ui_base]
lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
- https://github.com/meshtastic/device-ui/archive/27443d05de4678cdf6a9f4ef35aa30c0aa5f4e29.zip
+ https://github.com/meshtastic/device-ui/archive/776ab044345fcd41c4d9eda0eb86c9ad2206b2e1.zip
custom_sdkconfig =
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
+ ; not an IDF symbol; read by extra_scripts/esp32_fatfs_exfat.py to patch ffconf.h
+ CONFIG_FATFS_FS_EXFAT=y
+ CONFIG_FATFS_LFN_STACK=y
; Common libs for environmental measurements in telemetry module
[environmental_base]
diff --git a/protobufs b/protobufs
index aca181b97b..723a31e420 160000
--- a/protobufs
+++ b/protobufs
@@ -1 +1 @@
-Subproject commit aca181b97b7db047d76e9f000220a11a234cd389
+Subproject commit 723a31e42013f155b529929e675db8caef20c534
diff --git a/src/AudioThread.h b/src/AudioThread.h
index f4f5781fcf..6d4b26dad4 100644
--- a/src/AudioThread.h
+++ b/src/AudioThread.h
@@ -1,5 +1,6 @@
#pragma once
#include "PowerFSM.h"
+#include "SPILock.h"
#include "concurrency/OSThread.h"
#include "configuration.h"
#include "main.h"
@@ -15,9 +16,9 @@
// A board with an I2S amplifier opts in by defining AUDIO_AMP_ENABLE(on) in its variant.h to power the
// amp on/off around playback (e.g. an enable pin on an I/O expander). The includes below expose the
// expander instances (io / mcpIoExpander) those macros typically reference.
-#ifdef USE_XL9555
-#include "ExtensionIOXL9555.hpp"
-extern ExtensionIOXL9555 io;
+#ifdef USE_PCA95X5
+#include PCA95X5_INC
+extern PCA95X5_CLS io;
#endif
#ifdef USE_MCP23017
@@ -33,9 +34,7 @@ class AudioThread : public concurrency::OSThread
void beginRttl(const void *data, uint32_t len)
{
-#ifdef AUDIO_AMP_ENABLE
- AUDIO_AMP_ENABLE(true);
-#endif
+ ampEnable(true);
setCPUFast(true);
rtttlFile = std::unique_ptr(new AudioFileSourcePROGMEM(data, len));
i2sRtttl = std::unique_ptr(new AudioGeneratorRTTTL());
@@ -61,9 +60,7 @@ class AudioThread : public concurrency::OSThread
rtttlFile = nullptr;
setCPUFast(false);
-#ifdef AUDIO_AMP_ENABLE
- AUDIO_AMP_ENABLE(false);
-#endif
+ ampEnable(false);
}
void readAloud(const char *text)
@@ -73,16 +70,12 @@ class AudioThread : public concurrency::OSThread
i2sRtttl = nullptr;
}
-#ifdef AUDIO_AMP_ENABLE
- AUDIO_AMP_ENABLE(true);
-#endif
+ ampEnable(true);
auto sam = std::unique_ptr(new ESP8266SAM);
sam->Say(audioOut.get(), text);
setCPUFast(false);
audioOut->stop();
-#ifdef AUDIO_AMP_ENABLE
- AUDIO_AMP_ENABLE(false);
-#endif
+ ampEnable(false);
}
protected:
@@ -97,6 +90,21 @@ class AudioThread : public concurrency::OSThread
}
private:
+ // Amps like the NS4150 need time to leave shutdown, longer when the enable is an I/O expander write.
+ // Without a variant's AUDIO_AMP_SETTLE_MS the short system tones are over before any audio gets out.
+ static void ampEnable(bool on)
+ {
+#ifdef AUDIO_AMP_ENABLE
+ AUDIO_AMP_ENABLE(on);
+#ifdef AUDIO_AMP_SETTLE_MS
+ if (on)
+ delay(AUDIO_AMP_SETTLE_MS);
+#endif
+#else
+ (void)on;
+#endif
+ }
+
void initOutput()
{
audioOut = std::unique_ptr(new AudioOutputI2S(1, AudioOutputI2S::EXTERNAL_I2S));
diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp
index ef0d5841ad..b6269141b6 100644
--- a/src/FSCommon.cpp
+++ b/src/FSCommon.cpp
@@ -40,7 +40,11 @@ size_t fsUsedBytes()
return 0;
size_t blocks = 0;
FSCom._lockFS();
+#if LFS_VERSION_MAJOR >= 2
+ int err = lfs_fs_traverse(fs, fsCountBlockCb, &blocks);
+#else
int err = lfs_traverse(fs, fsCountBlockCb, &blocks);
+#endif
FSCom._unlockFS();
if (err < 0)
return fsTotalBytes(); // report "full" so capacity checks fail safe
@@ -133,9 +137,6 @@ bool renameFile(const char *pathFrom, const char *pathTo)
#include
#include
#include
-#ifdef ARCH_ESP32
-#include
-#endif
/**
* @brief Platform-agnostic filesystem format / wipe.
@@ -253,12 +254,6 @@ void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vec
} // namespace
#endif
-#ifdef ARCH_ESP32
-// Headroom kept below the allocator's largest free block when sizing the manifest: the block reported
-// includes the allocator's own bookkeeping, and other tasks keep allocating while the SPI lock is held.
-static constexpr size_t FILES_MANIFEST_HEAP_MARGIN = 1024;
-#endif
-
/**
* @brief Get the list of files in a directory.
*
@@ -286,32 +281,24 @@ std::vector getFiles(const char *dirname, uint8_t levels, s
// Cap at what a vector of FileInfo can hold at all: it keeps the probe's byte count from wrapping
// for a huge maxCount, and it is also the bound reserve() would otherwise reject with a throw.
size_t reservedCount = std::min(maxCount, filenames.max_size());
-#ifdef ARCH_ESP32
- // Ask the allocator for the largest contiguous block malloc() could hand out. MALLOC_CAP_DEFAULT
- // is the capability heap_caps_malloc_default() (what operator new resolves to) falls back to
- // across every region, internal and PSRAM alike, so this is the "will new succeed" question
- // asked directly. Nothing is freed before the reserve, so there is no hole for another task to
- // take between the probe and the allocation.
- const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT);
- // Leave a margin below the largest block: the allocator's own overhead sits inside it, and other
- // threads keep allocating while we hold the SPI lock.
- const size_t usable = largest > FILES_MANIFEST_HEAP_MARGIN ? largest - FILES_MANIFEST_HEAP_MARGIN : 0;
- reservedCount = std::min(reservedCount, usable / sizeof(meshtastic_FileInfo));
-#else
- // Other targets have no largest-block query. Probe with malloc() - the allocation that returns
- // nullptr on failure under every build (new(std::nothrow) is not that: libstdc++ implements it as
- // a try/catch around the throwing form) - free the probe, and reserve the size that fit. Not
- // airtight against a concurrent allocator, but the SPI lock the caller holds serialises the usual
- // competitors and it is strictly better than letting reserve() be the first to find out.
+ // Probe with malloc() - the allocation that returns nullptr on failure under every build (new(std::nothrow)
+ // is not that: libstdc++ implements it as a try/catch around the throwing form) - free the probe, and
+ // reserve the size that fit. Not airtight against a concurrent allocator, but the SPI lock the caller holds
+ // serialises the usual competitors and it is strictly better than letting reserve() be the first to find
+ // out. On ESP32 do NOT replace this with heap_caps_get_largest_free_block(): it walks every TLSF block of
+ // every matching heap while holding the allocator lock, and on PSRAM boards that walk blocks wifi_malloc()
+ // on the other core long enough to trip the interrupt watchdog (#11666).
while (reservedCount > 0) {
void *probe = malloc(reservedCount * sizeof(meshtastic_FileInfo));
if (probe) {
+ // Observable access so LTO cannot elide the malloc()/free() pair and turn the probe
+ // into a compile-time yes.
+ *static_cast(probe) = 0;
free(probe);
break;
}
reservedCount /= 2;
}
-#endif
if (reservedCount == 0) {
if (wasLimited)
*wasLimited = true;
diff --git a/src/FSCommon.h b/src/FSCommon.h
index 7daa57ad90..de4322579e 100644
--- a/src/FSCommon.h
+++ b/src/FSCommon.h
@@ -48,14 +48,6 @@ using namespace STM32_LittleFS_Namespace;
using namespace Adafruit_LittleFS_Namespace;
#endif
-#if defined(ARCH_NRF54L15)
-// nRF54L15 - Zephyr LittleFS on 36 KB storage_partition (internal RRAM)
-#include "InternalFileSystem.h"
-#define FSCom InternalFS
-#define FSBegin() FSCom.begin()
-using namespace Adafruit_LittleFS_Namespace;
-#endif
-
// Filesystem capacity, in bytes. Only ESP32's LittleFS and the nRF54L15 wrapper expose totalBytes()/usedBytes()
// directly; the other backends need per-platform work (littlefs v1 traversal, FSInfo, statvfs), so callers must
// use these helpers rather than reaching into FSCom.
diff --git a/src/Observer.h b/src/Observer.h
index 6e1ec44c81..9c445e2b5b 100644
--- a/src/Observer.h
+++ b/src/Observer.h
@@ -81,6 +81,7 @@ template class Observable
// Not called directly, instead call observer.observe
void addObserver(Observer *o) { observers.push_back(o); }
+ // cppcheck-suppress constParameterPointer ; std::list::remove() needs a non-const pointer
void removeObserver(Observer *o) { observers.remove(o); }
};
diff --git a/src/Pca9555.h b/src/Pca9555.h
new file mode 100644
index 0000000000..b29cbdda82
--- /dev/null
+++ b/src/Pca9555.h
@@ -0,0 +1,97 @@
+#pragma once
+#include
+
+// Lightweight Wire-based TCA9555/PCA9555/XL9555 16-bit I/O expander driver.
+// Opt in via USE_PCA95X5 / PCA95X5_CLS / PCA95X5_INC in the board's variant.h.
+class Pca9555
+{
+ public:
+ bool begin(TwoWire &wire, uint8_t addr, int sda = -1, int scl = -1)
+ {
+ _wire = &wire;
+ _addr = addr;
+ if (sda >= 0 && scl >= 0)
+ wire.begin(sda, scl);
+ wire.beginTransmission(addr);
+ return wire.endTransmission() == 0;
+ }
+
+ bool pinMode(int pin, int mode)
+ {
+ if (pin < 0 || pin > 15 || !_wire)
+ return false;
+ uint8_t port = pin / 8, bit = pin % 8;
+ uint8_t cfg;
+ uint8_t res = readReg(0x06 + port, cfg);
+ if (res) {
+ bool isInput = (mode == INPUT || mode == INPUT_PULLUP);
+ if (isInput)
+ cfg |= (1u << bit);
+ else
+ cfg &= ~(1u << bit);
+ return writeReg(0x06 + port, cfg);
+ }
+ return res;
+ }
+
+ bool digitalWrite(int pin, int value)
+ {
+ if (pin < 0 || pin > 15 || !_wire)
+ return false;
+ uint8_t port = pin / 8, bit = pin % 8;
+ uint8_t out;
+ bool res = readReg(0x02 + port, out);
+ if (res) {
+ if (value)
+ out |= (1u << bit);
+ else
+ out &= ~(1u << bit);
+ return writeReg(0x02 + port, out);
+ }
+ return res;
+ }
+
+ bool digitalRead(int pin)
+ {
+ if (pin < 0 || pin > 15 || !_wire)
+ return false;
+ uint8_t port = pin / 8, bit = pin % 8;
+ uint8_t reg;
+ if (readReg(0x00 + port, reg))
+ return (reg & (1u << bit)) != 0;
+ else
+ return 0;
+ }
+
+ private:
+ TwoWire *_wire = nullptr;
+ uint8_t _addr = 0x20;
+
+ bool readReg(uint8_t reg, uint8_t &out)
+ {
+ _wire->beginTransmission(_addr);
+ _wire->write(reg);
+ if (_wire->endTransmission(false) != 0) {
+ _wire->end();
+ _wire->begin();
+ return false;
+ }
+ if (_wire->requestFrom((uint8_t)_addr, (uint8_t)1) != 1)
+ return false;
+ out = _wire->read();
+ return true;
+ }
+
+ bool writeReg(uint8_t reg, uint8_t val)
+ {
+ _wire->beginTransmission(_addr);
+ _wire->write(reg);
+ _wire->write(val);
+ if (_wire->endTransmission() != 0) {
+ _wire->end();
+ _wire->begin();
+ return false;
+ }
+ return true;
+ }
+};
diff --git a/src/Power.cpp b/src/Power.cpp
index 2cb73296b7..ee14b3c639 100644
--- a/src/Power.cpp
+++ b/src/Power.cpp
@@ -19,9 +19,12 @@
#include "NodeDB.h"
#include "PowerFSM.h"
#include "Throttle.h"
+#include "UptimeClock.h"
+#include "WaypointStore.h"
#include "buzz/buzz.h"
#include "configuration.h"
#include "main.h"
+#include "memory/MemAudit.h"
#include "meshUtils.h"
#include "power/PowerHAL.h"
#include "power/SGM41562.h"
@@ -40,6 +43,10 @@
#include "input/LinuxJoystick.h"
#endif
+#ifdef HAS_ADS1115
+#include
+#endif
+
// Working USB detection for powered/charging states on the RAK platform
#ifdef NRF_APM
#include "nrfx_power.h"
@@ -594,13 +601,8 @@ class AnalogBatteryLevel : public HasBatteryLevel
return (rak9154Sensor.isCharging()) ? OptTrue : OptFalse;
}
#endif
-#if defined(ELECROW_ThinkNode_M6)
- return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn();
-#elif defined(EXT_CHRG_DETECT)
- return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
-#elif defined(BATTERY_CHARGING_INV)
- return !digitalRead(BATTERY_CHARGING_INV);
-#else
+ // A configured INA outranks the board's own charge-status pin, as it does BATTERY_PIN in
+ // getBattVoltage(): an external charger leaves that pin idle, reading "not charging" forever.
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !defined(DISABLE_INA_CHARGING_DETECTION)
if (hasINA()) {
// get current flow from INA sensor - negative value means power flowing
@@ -613,6 +615,16 @@ class AnalogBatteryLevel : public HasBatteryLevel
return getINACurrent() < 0;
#endif
}
+#endif
+#if defined(ELECROW_ThinkNode_M6)
+ return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE || isVbusIn();
+#elif defined(EXT_CHRG_DETECT)
+ return digitalRead(EXT_CHRG_DETECT) == EXT_CHRG_DETECT_VALUE;
+#elif defined(BATTERY_CHARGING_INV)
+ return !digitalRead(BATTERY_CHARGING_INV);
+#else
+#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !defined(DISABLE_INA_CHARGING_DETECTION)
+ // No charge-status pin and no INA: infer from battery presence plus external power.
return isBatteryConnect() && isVbusIn();
#endif
#endif
@@ -679,6 +691,9 @@ class AnalogBatteryLevel : public HasBatteryLevel
} else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA226].first ==
config.power.device_battery_ina_address) {
return ina226Sensor.getCurrentMa();
+ } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA260].first ==
+ config.power.device_battery_ina_address) {
+ return ina260Sensor.getCurrentMa();
} else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA3221].first ==
config.power.device_battery_ina_address) {
return ina3221Sensor.getCurrentMa();
@@ -686,30 +701,29 @@ class AnalogBatteryLevel : public HasBatteryLevel
return 0;
}
+ // Open the sensor if it isn't open yet, then report whether it is actually running. runOnce()
+ // answers with a poll interval, so only isRunning() tells us the device replied.
+ static bool sensorReady(TelemetrySensor &sensor)
+ {
+ if (!sensor.isInitialized())
+ sensor.runOnce();
+ return sensor.isRunning();
+ }
+
bool hasINA()
{
- if (!config.power.device_battery_ina_address) {
+ const uint8_t inaAddress = config.power.device_battery_ina_address;
+ if (!inaAddress) {
return false;
}
- if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA219].first == config.power.device_battery_ina_address) {
- if (!ina219Sensor.isInitialized())
- return ina219Sensor.runOnce() > 0;
- return ina219Sensor.isRunning();
- } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA226].first ==
- config.power.device_battery_ina_address) {
- if (!ina226Sensor.isInitialized())
- return ina226Sensor.runOnce() > 0;
- return ina226Sensor.isRunning();
- } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA260].first ==
- config.power.device_battery_ina_address) {
- if (!ina260Sensor.isInitialized())
- return ina260Sensor.runOnce() > 0;
- return ina260Sensor.isRunning();
- } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA3221].first ==
- config.power.device_battery_ina_address) {
- if (!ina3221Sensor.isInitialized())
- return ina3221Sensor.runOnce() > 0;
- return ina3221Sensor.isRunning();
+ if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA219].first == inaAddress) {
+ return sensorReady(ina219Sensor);
+ } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA226].first == inaAddress) {
+ return sensorReady(ina226Sensor);
+ } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA260].first == inaAddress) {
+ return sensorReady(ina260Sensor);
+ } else if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_INA3221].first == inaAddress) {
+ return sensorReady(ina3221Sensor);
}
return false;
}
@@ -718,6 +732,135 @@ class AnalogBatteryLevel : public HasBatteryLevel
static AnalogBatteryLevel analogLevel;
+#ifdef HAS_ADS1115
+#include "SPILock.h"
+#include
+
+/**
+ * @brief Battery level sensor using an ADS1115 16-bit ADC on I2C.
+ * Channel 0 measures battery voltage through a 1:2 resistive divider.
+ * USB / Charging status is managed via an AW35615 USB-C CC controller.
+ */
+class ADS1115BatteryLevel : public AnalogBatteryLevel
+{
+ public:
+ bool init()
+ {
+ {
+ concurrency::LockGuard guard(spiLock);
+ if (!_ads.begin(ADS1115_ADDR, &Wire)) {
+ LOG_WARN("ADS1115 not found on I2C bus - battery sensor unavailable");
+ return false;
+ }
+ _ads.setGain(GAIN_ONE); // ±4.096 V FSR matches standard 1:2 voltage-divider
+ _ads.setDataRate(RATE_ADS1115_860SPS); // Maximize conversion speed to keep bus locking minimal
+ }
+
+ initialized = true;
+ LOG_INFO("[ADS1115] battery sensor initialized");
+
+ if (_aw35615.begin(Wire)) {
+ LOG_INFO("[AW35615] USB-C CC controller initialized");
+ } else {
+ LOG_WARN("[AW35615] not found at 0x22");
+ }
+ getBattVoltage(); // initial read cached_mv
+ return true;
+ }
+
+ virtual bool isBatteryConnect() override { return true; }
+ virtual uint16_t getBattVoltage() override
+ {
+ if (!initialized)
+ return 0;
+
+ static constexpr uint32_t MIN_READ_INTERVAL_MS = 30000;
+ if (!initial_read_done || !Throttle::isWithinTimespanMs(last_read_ms, MIN_READ_INTERVAL_MS)) {
+ last_read_ms = millis();
+ float sum = 0;
+ {
+ concurrency::LockGuard guard(spiLock);
+ for (uint8_t i = 0; i < SAMPLE_COUNT; i++) {
+ int16_t raw = _ads.readADC_SingleEnded(0);
+ sum += _ads.computeVolts(raw);
+ }
+ // Piggyback a toggle-engine watchdog on this same throttle interval.
+ // Only re-arm when VBUS is absent - calling this while attached
+ // would restart CC toggling and could glitch an active sink attach.
+ if (_aw35615.isReady() && !_aw35615.isVbusPresent()) {
+ _aw35615.rearmToggle();
+ }
+ }
+
+ // Voltage divider scales by 2.0; convert volts to millivolts
+ float v = (sum / (float)SAMPLE_COUNT) * 2.0f * 1000.0f;
+
+ if (!initial_read_done) {
+ cached_mv = static_cast(v);
+ initial_read_done = true;
+ } else {
+ // Exponential moving average filter (50% smoothing)
+ cached_mv = static_cast(cached_mv + (v - cached_mv) * 0.5f);
+ }
+ }
+ return cached_mv;
+ }
+
+ virtual bool isVbusIn() override
+ {
+ if (_aw35615.isReady()) {
+ concurrency::LockGuard guard(spiLock);
+
+ bool vbus = _aw35615.isVbusPresent();
+ if (!vbus) {
+ // VBUS just went away (or has been away) - make sure the CC
+ // toggle engine is re-armed so the next attach gets detected.
+ _aw35615.rearmToggle();
+ }
+ return vbus;
+ }
+ // Fallback to base GPIO/board checks (or false) if CC chip is absent
+ return false;
+ }
+
+ virtual bool isCharging() override
+ {
+ if (!isBatteryConnect())
+ return false;
+
+ if (_aw35615.isReady()) {
+ concurrency::LockGuard guard(spiLock);
+ // Charging == VBUS present AND we're attached as a sink.
+ // (isSinkAttached() is a latched result - safe to trust here since
+ // isVbusIn() above keeps re-arming toggle on every detach.)
+ return _aw35615.isVbusPresent() && _aw35615.isSinkAttached();
+ }
+ return isVbusIn();
+ }
+
+ private:
+ static constexpr uint8_t SAMPLE_COUNT = 3;
+ Adafruit_ADS1115 _ads;
+ AW35615 _aw35615;
+
+ bool initialized = false;
+ bool initial_read_done = false;
+ uint16_t cached_mv = 0;
+ uint32_t last_read_ms = 0;
+};
+
+static ADS1115BatteryLevel ads1115BattLevel;
+
+bool Power::ads1115Init()
+{
+ if (ads1115BattLevel.init()) {
+ batteryLevel = &ads1115BattLevel;
+ return true;
+ }
+ return false;
+}
+#endif // HAS_ADS1115
+
Power::Power() : OSThread("Power")
{
statusHandler = {};
@@ -814,6 +957,10 @@ bool Power::setup()
found = true;
} else if (meshSolarInit()) {
found = true;
+#ifdef HAS_ADS1115
+ } else if (ads1115Init()) {
+ found = true;
+#endif
} else if (analogInit()) {
found = true;
} else {
@@ -847,11 +994,22 @@ void Power::powerCommandsCheck()
shutdownAtMsec = 0;
shutdown();
}
+
+#ifdef ARCH_STM32
+ // Deferred DFU entry; the delay is armed by AdminModule's enter_dfu handler (rationale there).
+ if (enterDfuAtMsec && Throttle::deadlinePassed(enterDfuAtMsec)) {
+ enterDfuAtMsec = 0;
+ enterDfuMode(); // never returns
+ }
+#endif
}
void Power::reboot()
{
notifyReboot.notifyObservers(NULL);
+#if !MESHTASTIC_EXCLUDE_WAYPOINT
+ waypointStore.saveToFlash();
+#endif
#if defined(ARCH_ESP32)
ESP.restart();
#elif defined(ARCH_NRF52)
@@ -916,6 +1074,9 @@ void Power::shutdown()
#if HAS_SCREEN
messageStore.saveToFlash();
#endif
+#if !MESHTASTIC_EXCLUDE_WAYPOINT
+ waypointStore.saveToFlash();
+#endif
#if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_STM32WL)
#ifdef PIN_LED1
ledOff(PIN_LED1);
@@ -937,6 +1098,20 @@ void Power::shutdown()
#endif
}
+// Consecutive readings only: a battery-less board's floating divider drifts in and out of the
+// "battery present" window, and a count that survived the gaps would deep-sleep a USB-powered node.
+bool updateLowVoltageCounter(uint8_t &counter, bool hasBattery, bool hasUsb, uint16_t battMv, uint16_t cutoffMv)
+{
+ if (!hasBattery || hasUsb || battMv >= cutoffMv) {
+ counter = 0;
+ return false;
+ }
+
+ if (counter < UINT8_MAX)
+ counter++;
+ return counter > LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN;
+}
+
/// Reads power status to powerStatus singleton.
//
// TODO(girts): move this and other axp stuff to power.h/power.cpp.
@@ -1069,16 +1244,16 @@ void Power::readPowerStatus()
// is 2.0 to 2.5V, current OCV min is set to 3100 that is large enough.
//
- if (batteryLevel && powerStatus2.getHasBattery() && !powerStatus2.getHasUSB()) {
- if (batteryLevel->getBattVoltage() < OCV[NUM_OCV_POINTS - 1]) {
- low_voltage_counter++;
- LOG_DEBUG("Low voltage counter: %d/10", low_voltage_counter);
- if (low_voltage_counter > 10) {
- LOG_INFO("Low voltage detected, trigger deep sleep");
- powerFSM.trigger(EVENT_LOW_BATTERY);
- }
- } else {
- low_voltage_counter = 0;
+ if (batteryLevel) {
+ // getBattVoltage() reports pack voltage; the OCV table is per cell.
+ const bool shutdownNow =
+ updateLowVoltageCounter(low_voltage_counter, powerStatus2.getHasBattery(), powerStatus2.getHasUSB(),
+ batteryLevel->getBattVoltage(), OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
+ if (low_voltage_counter)
+ LOG_DEBUG("Low voltage counter: %d/%d", low_voltage_counter, LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN);
+ if (shutdownNow) {
+ LOG_INFO("Low voltage detected, trigger deep sleep");
+ powerFSM.trigger(EVENT_LOW_BATTERY);
}
}
}
@@ -1103,15 +1278,26 @@ void Power::logHeapUsage()
// The first line has no earlier sample to difference against
const int32_t delta = lastHeapLogTime ? (int32_t)(heapFree - lastHeapLogFree) : 0;
+ // min only ever falls: one step down is a transient alloc, repeated new lows are a leak.
+ // A steady min with a shrinking largest block is fragmentation. Empty where unsupported.
+ char detail[64] = "";
+ const uint32_t minFree = memGet.getMinFreeHeap();
+ const uint32_t maxAlloc = memGet.getMaxAllocHeap();
+ if (minFree || maxAlloc)
+ snprintf(detail, sizeof(detail), ", min %u, largest block %u", minFree, maxAlloc);
+
const uint32_t psramTotal = memGet.getPsramSize();
if (psramTotal)
- LOG_INFO("Heap: %u/%u bytes free (%d since last), PSRAM: %u/%u bytes free", heapFree, heapTotal, delta,
+ LOG_INFO("Heap: %u/%u bytes free (%d since last)%s, PSRAM: %u/%u bytes free", heapFree, heapTotal, delta, detail,
memGet.getFreePsram(), psramTotal);
else
- LOG_INFO("Heap: %u/%u bytes free (%d since last)", heapFree, heapTotal, delta);
+ LOG_INFO("Heap: %u/%u bytes free (%d since last)%s", heapFree, heapTotal, delta, detail);
+
+ // Which tagged subsystem moved since boot
+ memaudit::logBreakdown("periodic");
lastHeapLogFree = heapFree;
- lastHeapLogTime = millis();
+ lastHeapLogTime = Time::skipZero(Time::getMillis());
#endif
}
diff --git a/src/Power.h b/src/Power.h
index b47d66aff2..6a7bd21643 100644
--- a/src/Power.h
+++ b/src/Power.h
@@ -30,6 +30,12 @@
#define NUM_CELLS 1
#endif
+/// Consecutive below-cutoff readings needed before the low-battery deep sleep fires.
+static constexpr uint8_t LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN = 10;
+
+/// Advance the low-battery shutdown counter by one reading; true once the device should deep sleep.
+bool updateLowVoltageCounter(uint8_t &counter, bool hasBattery, bool hasUsb, uint16_t battMv, uint16_t cutoffMv);
+
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
#include "modules/Telemetry/Sensor/nullSensor.h"
#if __has_include()
@@ -98,7 +104,7 @@ class Power : public concurrency::OSThread
virtual int32_t runOnce() override;
void setStatusHandler(meshtastic::PowerStatus *handler) { statusHandler = handler; }
const uint16_t OCV[11] = {OCV_ARRAY};
- bool isLowBattery() { return low_voltage_counter >= 10; };
+ bool isLowBattery() { return low_voltage_counter >= LOW_VOLTAGE_READINGS_BEFORE_SHUTDOWN; };
#ifdef ARCH_ESP32
int beforeLightSleep(void *unused);
@@ -127,6 +133,10 @@ class Power : public concurrency::OSThread
bool meshSolarInit();
/// Setup a serial battery sensor
bool serialBatteryInit();
+#ifdef HAS_ADS1115
+ /// Setup ADS1115 I2C battery level sensor
+ bool ads1115Init();
+#endif
private:
void shutdown();
diff --git a/src/PowerFSM.cpp b/src/PowerFSM.cpp
index 2ef5b2ea1c..1a5d3f34db 100644
--- a/src/PowerFSM.cpp
+++ b/src/PowerFSM.cpp
@@ -12,6 +12,7 @@
#include "MeshService.h"
#include "NodeDB.h"
#include "PowerMon.h"
+#include "UptimeClock.h"
#include "configuration.h"
#include "graphics/Screen.h"
#include "main.h"
@@ -58,23 +59,6 @@ static bool isPowered()
return !isPowerSavingMode && powerStatus && (!powerStatus->getHasBattery() || powerStatus->getHasUSB());
}
-static bool isBluetoothEnabledForPowerFSM()
-{
-#if HAS_BLUETOOTH && !MESHTASTIC_EXCLUDE_BLUETOOTH
- return config.bluetooth.enabled;
-#else
- return false;
-#endif
-}
-
-static uint32_t getBluetoothWaitMs()
-{
- if (!isBluetoothEnabledForPowerFSM())
- return 0;
-
- return Default::getConfiguredOrDefaultMs(config.power.wait_bluetooth_secs, default_wait_bluetooth_secs);
-}
-
#if defined(T5_S3_EPAPER_PRO)
static void t5BacklightOffForSleep()
{
@@ -121,7 +105,7 @@ extern Power *power;
static void shutdownEnter()
{
LOG_POWERFSM("State: SHUTDOWN");
- shutdownAtMsec = millis();
+ shutdownAtMsec = Time::skipZero(Time::getMillis());
}
#include "error.h"
@@ -184,6 +168,13 @@ static void lsIdle()
if (pressed) {
powerFSM.trigger(EVENT_PRESS);
}
+#ifdef MOTION_WAKE_INT_PIN
+ // Not the button: the accelerometer can have raised the line instead.
+ else if (config.display.wake_on_tap_or_motion &&
+ digitalRead(MOTION_WAKE_INT_PIN) == (MOTION_WAKE_INT_ACTIVE_HIGH ? HIGH : LOW)) {
+ powerFSM.trigger(EVENT_INPUT);
+ }
+#endif
break;
}
default:
@@ -212,6 +203,17 @@ static void lsExit()
t5BacklightWakeFromSleep();
}
+/// Skip the BLE re-enable while a reboot/shutdown is armed: AdminModule tears BLE down before
+/// scheduling the restart, and a state transition in that window would otherwise bring it back up.
+static void setBluetoothEnableUnlessRestarting()
+{
+ if (rebootAtMsec || shutdownAtMsec) {
+ LOG_POWERFSM("Skip BLE enable, restart pending");
+ return;
+ }
+ setBluetoothEnable(true);
+}
+
static void nbEnter()
{
LOG_POWERFSM("State: nbEnter");
@@ -228,7 +230,7 @@ static void nbEnter()
static void darkEnter()
{
LOG_POWERFSM("State: darkEnter");
- setBluetoothEnable(true);
+ setBluetoothEnableUnlessRestarting();
if (screen)
screen->setOn(false);
// Screen timeout enters DARK; ensure backlight also turns off.
@@ -252,7 +254,7 @@ static void serialExit()
{
LOG_POWERFSM("State: serialExit");
// Turn bluetooth back on when we leave serial stream API
- setBluetoothEnable(true);
+ setBluetoothEnableUnlessRestarting();
}
static void powerEnter()
@@ -265,7 +267,7 @@ static void powerEnter()
} else {
if (screen)
screen->setOn(true);
- setBluetoothEnable(true);
+ setBluetoothEnableUnlessRestarting();
// within enter() the function getState() returns the state we came from
}
}
@@ -283,7 +285,7 @@ static void powerIdle()
static void powerExit()
{
LOG_POWERFSM("State: powerExit");
- setBluetoothEnable(true);
+ setBluetoothEnableUnlessRestarting();
}
static void onEnter()
@@ -291,7 +293,7 @@ static void onEnter()
LOG_POWERFSM("State: onEnter");
if (screen)
screen->setOn(true);
- setBluetoothEnable(true);
+ setBluetoothEnableUnlessRestarting();
}
static void onIdle()
@@ -448,7 +450,10 @@ void PowerFSM_setup()
// If ESP32 and using power-saving, timer mover from DARK to light-sleep
// Also serves purpose of the old DARK to DARK transition(?) See https://github.com/meshtastic/firmware/issues/3517
- powerFSM.add_timed_transition(&stateDARK, &stateLS, getBluetoothWaitMs(), NULL, "Bluetooth timeout");
+ powerFSM.add_timed_transition(
+ &stateDARK, &stateLS,
+ Default::getConfiguredOrDefaultMs(config.power.wait_bluetooth_secs, default_wait_bluetooth_secs), NULL,
+ "Bluetooth timeout");
} else {
// If ESP32, but not using power-saving, check periodically if config has drifted out of stateDark
powerFSM.add_timed_transition(&stateDARK, &stateDARK,
diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp
index 66a266d960..e4a56bb4af 100644
--- a/src/RedirectablePrint.cpp
+++ b/src/RedirectablePrint.cpp
@@ -235,8 +235,6 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_
isBleConnected = nimbleBluetooth && nimbleBluetooth->isActive() && nimbleBluetooth->isConnected();
#elif defined(ARCH_NRF52)
isBleConnected = nrf52Bluetooth != nullptr && nrf52Bluetooth->isConnected();
-#elif defined(ARCH_NRF54L15)
- isBleConnected = nrf54l15Bluetooth != nullptr && nrf54l15Bluetooth->isConnected();
#endif
if (isBleConnected) {
auto thread = concurrency::OSThread::currentThread;
@@ -253,8 +251,6 @@ void RedirectablePrint::log_to_ble(const char *logLevel, const char *format, va_
nimbleBluetooth->sendLog(buffer.get(), size);
#elif defined(ARCH_NRF52)
nrf52Bluetooth->sendLog(buffer.get(), size);
-#elif defined(ARCH_NRF54L15)
- nrf54l15Bluetooth->sendLog(buffer.get(), size);
#endif
}
}
diff --git a/src/UptimeClock.h b/src/UptimeClock.h
index efc04eb899..853fe7cbcf 100644
--- a/src/UptimeClock.h
+++ b/src/UptimeClock.h
@@ -44,6 +44,37 @@ void setMonotonicPublishHookForTests(MonotonicPublishHook hook);
/// which is wrap-correct with no carry state at all.
uint32_t getMillis();
+/// Step a millis value past 0. Stored stamps and deadlines conventionally use 0 for "unset", so the
+/// one tick per ~49.7-day wrap that lands on 0 would read as never-set; 1 is a 1 ms error instead.
+constexpr uint32_t skipZero(uint32_t ms)
+{
+ return ms ? ms : 1;
+}
+
+/// Start a countdown to delayMs from now, never 0. The sum is what has to dodge 0 - a non-zero
+/// read plus a delay lands there once per wrap - so this is not skipZero(getMillis()) + delayMs.
+inline uint32_t timerEndsAtMillis(uint32_t delayMs)
+{
+ return skipZero(getMillis() + delayMs);
+}
+
+/// getMillis() for 0-means-unset stamps, with the 0 tick called 1. Use at the read when the value
+/// is both stored and compared against stamps: skipZero() only at the store makes `now - stamp` wrap.
+inline uint32_t stampMillis()
+{
+ return skipZero(getMillis());
+}
+
+// skipZero() is the whole 0-means-unset contract in one expression, and it is constexpr, so pin it
+// here rather than only in test_uptime_clock: a build that breaks it stops at this header instead of
+// shipping a deadline that reads as never-set. The two obvious "simplifications" are what these
+// catch - `ms | 1` perturbs every even value, and `ms + 1` turns the last tick of the wrap into the
+// 0 the function exists to avoid. Both compile and both pass a test that only checks skipZero(0).
+static_assert(skipZero(0) == 1, "skipZero must lift the one 0 tick to 1");
+static_assert(skipZero(1) == 1, "skipZero must leave 1 alone");
+static_assert(skipZero(2) == 2, "skipZero must pass even values through untouched (ms | 1 would not)");
+static_assert(skipZero(UINT32_MAX) == UINT32_MAX, "skipZero must not wrap the last tick to 0 (ms + 1 would)");
+
/// Milliseconds since boot as a monotonic 64-bit count.
///
/// A pure read: it derives its answer from a complete snapshot published by serviceMonotonic()
diff --git a/src/WaypointStore.cpp b/src/WaypointStore.cpp
new file mode 100644
index 0000000000..b5a0208740
--- /dev/null
+++ b/src/WaypointStore.cpp
@@ -0,0 +1,400 @@
+#include "configuration.h"
+
+#if !MESHTASTIC_EXCLUDE_WAYPOINT
+
+#include "FSCommon.h"
+#include "SPILock.h"
+#include "SafeFile.h"
+#include "Throttle.h"
+#include "UptimeClock.h"
+#include "WaypointStore.h"
+#include "concurrency/LockGuard.h"
+#include "gps/RTC.h"
+#include "meshUtils.h"
+#include
+#include
+#include
+
+namespace
+{
+
+constexpr uint8_t WAYPOINT_STORE_VERSION = 3;
+constexpr const char *WAYPOINT_STORE_FILENAME = "/Waypoints_default.wpts";
+
+#ifndef WAYPOINT_AUTOSAVE_INTERVAL_SEC
+#define WAYPOINT_AUTOSAVE_INTERVAL_SEC (2 * 60 * 60)
+#endif
+
+struct __attribute__((packed)) StoredWaypointRecord {
+ uint32_t creatorNodeNum;
+ uint32_t receivedTime;
+ uint8_t notificationPreferences;
+ uint16_t payloadLength;
+ uint8_t payload[meshtastic_Waypoint_size];
+};
+
+bool decodeWaypointPayload(const uint8_t *payload, size_t payloadLength, meshtastic_Waypoint &wp)
+{
+ memset(&wp, 0, sizeof(wp));
+ return pb_decode_from_bytes(payload, payloadLength, &meshtastic_Waypoint_msg, &wp);
+}
+
+uint16_t encodeWaypointPayload(const meshtastic_Waypoint &wp, uint8_t *payload, size_t payloadCapacity)
+{
+ return (uint16_t)pb_encode_to_bytes(payload, payloadCapacity, &meshtastic_Waypoint_msg, &wp);
+}
+
+static bool g_waypointStoreHasUnsavedChanges = false;
+static uint32_t g_lastWaypointAutoSaveMs = 0;
+
+uint32_t autosaveIntervalMs()
+{
+ uint32_t sec = (uint32_t)WAYPOINT_AUTOSAVE_INTERVAL_SEC;
+ if (sec < 60)
+ sec = 60;
+ return sec * 1000UL;
+}
+
+void markWaypointStoreUnsaved()
+{
+ g_waypointStoreHasUnsavedChanges = true;
+ if (g_lastWaypointAutoSaveMs == 0)
+ g_lastWaypointAutoSaveMs = Time::getMillis();
+}
+
+void persistWaypointStore()
+{
+ LOG_INFO("Autosaving WaypointStore to flash");
+ waypointStore.saveToFlash();
+}
+
+} // namespace
+
+WaypointStore waypointStore;
+
+void WaypointStore::notifyChanged()
+{
+ notifyObservers(this);
+}
+
+bool WaypointStore::isExpired(const meshtastic_Waypoint &wp, uint32_t now)
+{
+ // getTime() counts from boot until the RTC is set, which reads every real expiry as future.
+ if (now == 0)
+ now = getValidTime(RTCQuality::RTCQualityDevice);
+
+ return !waypointIsActive(wp.expire, now);
+}
+
+bool WaypointStore::isExpired(const StoredWaypoint &entry, uint32_t now)
+{
+ return isExpired(entry.waypoint, now);
+}
+
+uint8_t WaypointStore::notificationPreferencesFromWaypoint(const meshtastic_Waypoint &wp)
+{
+ uint8_t preferences = 0;
+ if (wp.notify_on_enter)
+ preferences |= WAYPOINT_NOTIFY_ENTER;
+ if (wp.notify_on_exit)
+ preferences |= WAYPOINT_NOTIFY_EXIT;
+ if (wp.notify_favorites_only)
+ preferences |= WAYPOINT_NOTIFY_FAVORITES_ONLY;
+ return preferences;
+}
+
+uint8_t WaypointStore::mergeNotificationPreferences(bool locallyAuthored, bool hasExisting, uint8_t existingPreferences,
+ const meshtastic_Waypoint &incoming)
+{
+ if (locallyAuthored)
+ return notificationPreferencesFromWaypoint(incoming);
+ return hasExisting ? existingPreferences : 0;
+}
+
+void WaypointStore::clearWireNotificationPreferences(meshtastic_Waypoint &wp)
+{
+ wp.notify_on_enter = false;
+ wp.notify_on_exit = false;
+ wp.notify_favorites_only = false;
+}
+
+const StoredWaypoint *WaypointStore::findWaypoint(uint32_t id) const
+{
+ for (const StoredWaypoint &entry : waypoints) {
+ if (entry.waypoint.id == id)
+ return &entry;
+ }
+ return nullptr;
+}
+
+bool WaypointStore::removeWaypointById(uint32_t id)
+{
+ for (auto it = waypoints.begin(); it != waypoints.end(); ++it) {
+ if (it->waypoint.id == id) {
+ waypoints.erase(it);
+ return true;
+ }
+ }
+
+ return false;
+}
+
+bool WaypointStore::removeWaypoint(uint32_t id)
+{
+ const bool removed = removeWaypointById(id);
+ if (!removed)
+ return false;
+
+#if ENABLE_WAYPOINT_PERSISTENCE
+ markWaypointStoreUnsaved();
+#endif
+ notifyChanged();
+
+ return true;
+}
+
+bool WaypointStore::setNotificationPreference(uint32_t id, WaypointNotificationPreference preference, bool enabled)
+{
+ for (StoredWaypoint &entry : waypoints) {
+ if (entry.waypoint.id != id)
+ continue;
+
+ const uint8_t previous = entry.notificationPreferences;
+ if (enabled)
+ entry.notificationPreferences |= preference;
+ else
+ entry.notificationPreferences &= ~preference;
+ if (entry.notificationPreferences == previous)
+ return true;
+
+#if ENABLE_WAYPOINT_PERSISTENCE
+ markWaypointStoreUnsaved();
+#endif
+ notifyChanged();
+ return true;
+ }
+
+ return false;
+}
+
+void WaypointStore::addStoredWaypoint(const StoredWaypoint &entry)
+{
+ removeWaypointById(entry.waypoint.id);
+
+ waypoints.push_front(entry);
+ while (waypoints.size() > WAYPOINT_HISTORY_LIMIT)
+ waypoints.pop_back();
+}
+
+bool WaypointStore::addFromPacket(const meshtastic_MeshPacket &packet, bool locallyAuthored, StoredWaypoint *stored)
+{
+ StoredWaypoint entry;
+ if (!decodeWaypointPayload(packet.decoded.payload.bytes, packet.decoded.payload.size, entry.waypoint))
+ return false;
+
+ const StoredWaypoint *existing = findWaypoint(entry.waypoint.id);
+ entry.notificationPreferences = mergeNotificationPreferences(
+ locallyAuthored, existing != nullptr, existing ? existing->notificationPreferences : 0, entry.waypoint);
+ clearWireNotificationPreferences(entry.waypoint);
+ entry.receivedTime = packet.rx_time ? packet.rx_time : getTime();
+ entry.creatorNodeNum = getFrom(&packet);
+
+ if (stored)
+ *stored = entry;
+
+ // rx_time holds uptime, not an epoch, when has_rx_time is false; pass 0 so isExpired() resolves
+ // the clock itself rather than comparing an expiry against seconds since boot.
+ if (isExpired(entry, packet.has_rx_time ? packet.rx_time : 0)) {
+ // Respect the lock: only the node a waypoint is locked to may delete it on our device.
+ // An unauthorized deletion attempt is ignored entirely, rather than applied locally.
+ for (const auto &storedEntry : waypoints) {
+ if (storedEntry.waypoint.id != entry.waypoint.id)
+ continue;
+ if (storedEntry.waypoint.locked_to != 0 && storedEntry.waypoint.locked_to != entry.creatorNodeNum)
+ return true; // Packet handled, but the deletion is not honored
+ break;
+ }
+
+ removeWaypoint(entry.waypoint.id);
+ return true;
+ }
+
+ addStoredWaypoint(entry);
+
+#if ENABLE_WAYPOINT_PERSISTENCE
+ markWaypointStoreUnsaved();
+#endif
+ notifyChanged();
+
+ return true;
+}
+
+bool WaypointStore::purgeExpired(uint32_t now)
+{
+ // No local clock normalization: isExpired() owns that policy, including the delete convention.
+ bool changed = false;
+ for (auto it = waypoints.begin(); it != waypoints.end();) {
+ if (!isExpired(*it, now)) {
+ ++it;
+ continue;
+ }
+
+ it = waypoints.erase(it);
+ changed = true;
+ }
+
+ if (changed) {
+#if ENABLE_WAYPOINT_PERSISTENCE
+ markWaypointStoreUnsaved();
+#endif
+ notifyChanged();
+ }
+
+ return changed;
+}
+
+void WaypointStore::saveToFlash()
+{
+ purgeExpired();
+
+#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom)
+ if (!g_waypointStoreHasUnsavedChanges)
+ return;
+
+ spiLock->lock();
+ FSCom.mkdir("/");
+ spiLock->unlock();
+
+ SafeFile f(WAYPOINT_STORE_FILENAME, false);
+
+ spiLock->lock();
+ const uint8_t version = WAYPOINT_STORE_VERSION;
+ size_t countFull = waypoints.size();
+ if (countFull > WAYPOINT_HISTORY_LIMIT)
+ countFull = WAYPOINT_HISTORY_LIMIT;
+ if (countFull > UINT8_MAX)
+ countFull = UINT8_MAX;
+ const uint8_t count = (uint8_t)countFull;
+
+ f.write(&version, 1);
+ f.write(&count, 1);
+
+ for (uint8_t i = 0; i < count; ++i) {
+ StoredWaypointRecord rec = {};
+ rec.creatorNodeNum = waypoints[i].creatorNodeNum;
+ rec.receivedTime = waypoints[i].receivedTime;
+ rec.notificationPreferences = waypoints[i].notificationPreferences;
+ rec.payloadLength = encodeWaypointPayload(waypoints[i].waypoint, rec.payload, sizeof(rec.payload));
+ f.write(reinterpret_cast(&rec), sizeof(rec));
+ }
+ spiLock->unlock();
+ f.close();
+#endif
+
+#if ENABLE_WAYPOINT_PERSISTENCE
+ g_waypointStoreHasUnsavedChanges = false;
+ g_lastWaypointAutoSaveMs = Time::getMillis();
+#endif
+}
+
+void WaypointStore::loadFromFlash()
+{
+ std::deque().swap(waypoints);
+
+#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom)
+ {
+ concurrency::LockGuard guard(spiLock);
+
+ if (FSCom.exists(WAYPOINT_STORE_FILENAME)) {
+ auto f = FSCom.open(WAYPOINT_STORE_FILENAME, FILE_O_READ);
+ if (f) {
+ uint8_t version = 0;
+ uint8_t count = 0;
+ f.readBytes(reinterpret_cast(&version), 1);
+ f.readBytes(reinterpret_cast(&count), 1);
+
+ if (version != WAYPOINT_STORE_VERSION) {
+ LOG_WARN("WaypointStore version mismatch (%u)", version);
+ f.close();
+ } else {
+ if (count > WAYPOINT_HISTORY_LIMIT)
+ count = WAYPOINT_HISTORY_LIMIT;
+
+ for (uint8_t i = 0; i < count; ++i) {
+ StoredWaypoint entry;
+ StoredWaypointRecord rec = {};
+ if (f.readBytes(reinterpret_cast(&rec), sizeof(rec)) != sizeof(rec))
+ break;
+ if (rec.payloadLength == 0 || rec.payloadLength > sizeof(rec.payload)) {
+ LOG_WARN("WaypointStore skipping corrupt record %u", i);
+ continue;
+ }
+ if (!decodeWaypointPayload(rec.payload, rec.payloadLength, entry.waypoint))
+ continue;
+ entry.receivedTime = rec.receivedTime;
+ entry.creatorNodeNum = rec.creatorNodeNum;
+ entry.notificationPreferences = rec.notificationPreferences;
+
+ if (isExpired(entry.waypoint))
+ continue;
+ waypoints.push_back(entry);
+ }
+ f.close();
+ }
+ }
+ }
+ }
+#endif
+
+#if ENABLE_WAYPOINT_PERSISTENCE
+ g_waypointStoreHasUnsavedChanges = false;
+ g_lastWaypointAutoSaveMs = Time::getMillis();
+#endif
+}
+
+void WaypointStore::clearAllWaypoints()
+{
+ const bool hadWaypoints = !waypoints.empty();
+
+ std::deque().swap(waypoints);
+
+#if ENABLE_WAYPOINT_PERSISTENCE && defined(FSCom)
+ SafeFile f(WAYPOINT_STORE_FILENAME, false);
+ {
+ concurrency::LockGuard guard(spiLock);
+ const uint8_t version = WAYPOINT_STORE_VERSION;
+ const uint8_t count = 0;
+ f.write(&version, 1);
+ f.write(&count, 1);
+ }
+ f.close();
+#endif
+
+#if ENABLE_WAYPOINT_PERSISTENCE
+ g_waypointStoreHasUnsavedChanges = false;
+ g_lastWaypointAutoSaveMs = Time::getMillis();
+#endif
+
+ if (hadWaypoints)
+ notifyChanged();
+}
+
+#if ENABLE_WAYPOINT_PERSISTENCE
+void waypointStoreAutosaveTick()
+{
+ if (!g_waypointStoreHasUnsavedChanges) {
+ if (g_lastWaypointAutoSaveMs == 0)
+ g_lastWaypointAutoSaveMs = Time::getMillis();
+ return;
+ }
+
+ if (g_lastWaypointAutoSaveMs == 0) {
+ g_lastWaypointAutoSaveMs = Time::getMillis();
+ return;
+ }
+
+ Throttle::execute(&g_lastWaypointAutoSaveMs, autosaveIntervalMs(), persistWaypointStore);
+}
+#endif
+
+#endif
diff --git a/src/WaypointStore.h b/src/WaypointStore.h
new file mode 100644
index 0000000000..1dbab78e6c
--- /dev/null
+++ b/src/WaypointStore.h
@@ -0,0 +1,75 @@
+#pragma once
+
+#include "configuration.h"
+
+#if !MESHTASTIC_EXCLUDE_WAYPOINT
+
+#ifndef ENABLE_WAYPOINT_PERSISTENCE
+#define ENABLE_WAYPOINT_PERSISTENCE 1
+#endif
+
+#ifndef WAYPOINT_HISTORY_LIMIT
+#define WAYPOINT_HISTORY_LIMIT 10
+#endif
+
+#include "Observer.h"
+#include "mesh/MeshTypes.h"
+#include "mesh/generated/meshtastic/mesh.pb.h"
+#include
+#include
+
+enum WaypointNotificationPreference : uint8_t {
+ WAYPOINT_NOTIFY_ENTER = 1 << 0,
+ WAYPOINT_NOTIFY_EXIT = 1 << 1,
+ WAYPOINT_NOTIFY_FAVORITES_ONLY = 1 << 2,
+};
+
+struct StoredWaypoint {
+ meshtastic_Waypoint waypoint = meshtastic_Waypoint_init_zero;
+ uint32_t receivedTime = 0;
+ NodeNum creatorNodeNum = 0;
+ uint8_t notificationPreferences = 0;
+
+ bool notificationEnabled(WaypointNotificationPreference preference) const
+ {
+ return (notificationPreferences & preference) != 0;
+ }
+};
+
+class WaypointStore : public Observable
+{
+ public:
+ bool addFromPacket(const meshtastic_MeshPacket &packet, bool locallyAuthored, StoredWaypoint *stored = nullptr);
+ bool purgeExpired(uint32_t now = 0);
+ bool removeWaypoint(uint32_t id);
+ bool setNotificationPreference(uint32_t id, WaypointNotificationPreference preference, bool enabled);
+
+ const std::deque &getWaypoints() const { return waypoints; }
+ const StoredWaypoint *findWaypoint(uint32_t id) const;
+
+ void saveToFlash();
+ void loadFromFlash();
+ void clearAllWaypoints();
+
+ static bool isExpired(const meshtastic_Waypoint &wp, uint32_t now = 0);
+ static bool isExpired(const StoredWaypoint &entry, uint32_t now = 0);
+ static uint8_t notificationPreferencesFromWaypoint(const meshtastic_Waypoint &wp);
+ static uint8_t mergeNotificationPreferences(bool locallyAuthored, bool hasExisting, uint8_t existingPreferences,
+ const meshtastic_Waypoint &incoming);
+ static void clearWireNotificationPreferences(meshtastic_Waypoint &wp);
+
+ private:
+ void addStoredWaypoint(const StoredWaypoint &entry);
+ bool removeWaypointById(uint32_t id);
+ void notifyChanged();
+
+ std::deque waypoints;
+};
+
+#if ENABLE_WAYPOINT_PERSISTENCE
+void waypointStoreAutosaveTick();
+#endif
+
+extern WaypointStore waypointStore;
+
+#endif
diff --git a/src/WaypointUtils.h b/src/WaypointUtils.h
new file mode 100644
index 0000000000..ec78c214e2
--- /dev/null
+++ b/src/WaypointUtils.h
@@ -0,0 +1,38 @@
+#pragma once
+
+#include
+#include
+
+namespace WaypointUtils
+{
+
+inline std::string utf8FromCodepoint(uint32_t codepoint)
+{
+ if (codepoint == 0 || (codepoint >= 0xD800 && codepoint <= 0xDFFF) || codepoint > 0x10FFFF)
+ return "";
+
+ char buf[4];
+ if (codepoint <= 0x7F) {
+ buf[0] = static_cast(codepoint);
+ return std::string(buf, 1);
+ }
+ if (codepoint <= 0x7FF) {
+ buf[0] = static_cast(0xC0 | (codepoint >> 6));
+ buf[1] = static_cast(0x80 | (codepoint & 0x3F));
+ return std::string(buf, 2);
+ }
+ if (codepoint <= 0xFFFF) {
+ buf[0] = static_cast(0xE0 | (codepoint >> 12));
+ buf[1] = static_cast(0x80 | ((codepoint >> 6) & 0x3F));
+ buf[2] = static_cast(0x80 | (codepoint & 0x3F));
+ return std::string(buf, 3);
+ }
+
+ buf[0] = static_cast(0xF0 | (codepoint >> 18));
+ buf[1] = static_cast(0x80 | ((codepoint >> 12) & 0x3F));
+ buf[2] = static_cast(0x80 | ((codepoint >> 6) & 0x3F));
+ buf[3] = static_cast(0x80 | (codepoint & 0x3F));
+ return std::string(buf, 4);
+}
+
+} // namespace WaypointUtils
diff --git a/src/airtime.cpp b/src/airtime.cpp
index aaacefb092..154014cf8e 100644
--- a/src/airtime.cpp
+++ b/src/airtime.cpp
@@ -2,7 +2,9 @@
#include "NodeDB.h"
#include "UptimeClock.h"
#include "configuration.h"
+#include
#include
+#include
#include
AirTime *airTime = NULL;
@@ -60,7 +62,7 @@ uint8_t AirTime::Windows::getPeriodUtilHour(const Held &)
return (secSinceBoot / 60) % MINUTES_IN_HOUR;
}
-void AirTime::Windows::syncNow(const Held &)
+void AirTime::Windows::syncNow(const Held &held)
{
// Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move
// airtime accounting. Pure read; the main loop publishes the wrap carry it derives from.
@@ -112,13 +114,16 @@ void AirTime::Windows::syncNow(const Held &)
// Channel utilization is a rolling 60-second view split into six 10-second buckets.
// Clear every bucket crossed while asleep so old airtime decays by real elapsed time.
uint32_t elapsedUtilPeriods = (this->secSinceBoot / 10) - (oldSecSinceBoot / 10);
- if (elapsedUtilPeriods >= CHANNEL_UTILIZATION_PERIODS) {
- memset(this->channelUtilization, 0, sizeof(this->channelUtilization));
- } else {
- for (uint32_t i = 1; i <= elapsedUtilPeriods; i++) {
- this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0;
- }
+ // Fold one reading per crossed bucket, each before that bucket is cleared, so one delayed sync
+ // lands where the same number of 10 s syncs would have. Bounded: six clears empty the window.
+ const uint32_t steppedUtilPeriods = std::min(elapsedUtilPeriods, CHANNEL_UTILIZATION_PERIODS);
+ for (uint32_t i = 1; i <= steppedUtilPeriods; i++) {
+ foldChannelUtil(channelUtilizationPercentRaw(held), 1, held);
+ this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0;
}
+ // Anything past a full window is elapsed time against an already-empty ring, so it folds as
+ // idle in closed form rather than looping over a sleep that may have lasted days.
+ foldChannelUtil(0.0f, elapsedUtilPeriods - steppedUtilPeriods, held);
// TX utilization is a rolling 60-minute view used by duty-cycle checks.
uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60);
@@ -154,11 +159,8 @@ bool AirTime::Windows::airtimeReport(reportTypes reportType, uint32_t *out, size
return true;
}
-float AirTime::Windows::channelUtilizationPercent(const Held &held)
+float AirTime::Windows::channelUtilizationPercentRaw(const Held &)
{
- // Gate decisions should see buckets that have decayed across light-sleep time.
- syncNow(held);
-
uint32_t sum = 0;
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) {
sum += this->channelUtilization[i];
@@ -167,6 +169,42 @@ float AirTime::Windows::channelUtilizationPercent(const Held &held)
return (float(sum) / float(CHANNEL_UTILIZATION_PERIODS * 10 * 1000)) * 100;
}
+float AirTime::Windows::channelUtilizationPercent(const Held &held)
+{
+ // Gate decisions should see buckets that have decayed across light-sleep time.
+ syncNow(held);
+
+ return channelUtilizationPercentRaw(held);
+}
+
+void AirTime::Windows::foldChannelUtil(float sample, uint32_t steps, const Held &)
+{
+ if (steps == 0)
+ return;
+
+ if (!hasChannelUtilSample) {
+ // Seed from the first reading, or a node booting onto a busy channel reports it quiet
+ // for a whole time constant.
+ channelUtilAvg = sample;
+ hasChannelUtilSample = true;
+ steps--;
+ }
+
+ if (steps > 0) {
+ const float retained = powf(1.0f - 1.0f / float(CHANNEL_UTILIZATION_EMA_DIVISOR), float(steps));
+ channelUtilAvg = sample + (channelUtilAvg - sample) * retained;
+ }
+}
+
+float AirTime::Windows::smoothedChannelUtilizationPercent(const Held &held)
+{
+ syncNow(held);
+
+ // Nothing folded yet before the first bucket crossing, and 0 would read as an idle channel
+ // rather than as no data.
+ return hasChannelUtilSample ? channelUtilAvg : channelUtilizationPercentRaw(held);
+}
+
float AirTime::Windows::utilizationTXPercent(const Held &held)
{
// Duty-cycle checks use this value, so keep it current even outside the periodic thread.
@@ -242,6 +280,12 @@ float AirTime::channelUtilizationPercent()
return w.channelUtilizationPercent(held);
}
+float AirTime::smoothedChannelUtilizationPercent()
+{
+ Held held(this);
+ return w.smoothedChannelUtilizationPercent(held);
+}
+
float AirTime::utilizationTXPercent()
{
Held held(this);
diff --git a/src/airtime.h b/src/airtime.h
index b1e1172a76..e789aa62b5 100644
--- a/src/airtime.h
+++ b/src/airtime.h
@@ -34,6 +34,8 @@
OUTPUTS:
channelUtilizationPercent() % of the last 60s busy, all three types
+ smoothedChannelUtilizationPercent()
+ the same, behind a ~21 min EMA folded per bucket
utilizationTXPercent() % of the last hour we transmitted
isTxAllowedChannelUtil() gate on the former, 40% or 25% "polite"
isTxAllowedAirUtil() gate on the latter, at HALF the duty cycle
@@ -77,6 +79,9 @@
*/
#define CHANNEL_UTILIZATION_PERIODS 6
+// EMA weight per crossed 10 s bucket: 1/128 is a time constant of about 21 minutes, so one busy
+// or quiet minute cannot move the smoothed figure far.
+#define CHANNEL_UTILIZATION_EMA_DIVISOR 128
#define SECONDS_PER_PERIOD 3600
#define PERIODS_TO_LOG 8
#define MINUTES_IN_HOUR 60
@@ -130,6 +135,9 @@ class AirTime : private concurrency::OSThread
void logAirtime(reportTypes reportType, uint32_t airtime_ms);
float channelUtilizationPercent();
+ /// channelUtilizationPercent() behind an EMA advanced by elapsed time, for a caller that must
+ /// judge load from a trend rather than from one 60-second window.
+ float smoothedChannelUtilizationPercent();
float utilizationTXPercent();
/// Compatibility shim: no caller in the tree, kept for out-of-tree ones.
@@ -182,7 +190,12 @@ class AirTime : private concurrency::OSThread
// Modular rings: index is absolute phase, (uptime secs / period) % N, never age.
uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0}; // 6 x 10s
- uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only
+
+ // EMA over channelUtilization, folded in syncNow() once per crossed 10 s bucket so its
+ // time constant follows elapsed time rather than how often a caller happens to ask.
+ float channelUtilAvg = 0.0f;
+ bool hasChannelUtilSample = false;
+ uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only
// Hour crossings rotated but not yet traced. The core cannot log its own rotations: it
// only ever runs under the lock, and DEBUG_PORT.log() blocks on a UART write. runOnce()
@@ -198,6 +211,13 @@ class AirTime : private concurrency::OSThread
void logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &);
float channelUtilizationPercent(const Held &);
+ /// The bucket sum alone. syncNow() folds the EMA and cannot reach it through
+ /// channelUtilizationPercent(), which would re-enter syncNow().
+ float channelUtilizationPercentRaw(const Held &);
+ float smoothedChannelUtilizationPercent(const Held &);
+ /// Fold `steps` readings of `sample` into channelUtilAvg. Closed form, not a loop, so a
+ /// multi-day sleep decays by the time elapsed at the cost of one powf.
+ void foldChannelUtil(float sample, uint32_t steps, const Held &);
float utilizationTXPercent(const Held &);
bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &);
uint8_t getSilentMinutes(float txPercent, float dutyCycle, const Held &);
diff --git a/src/concurrency/OSThread.cpp b/src/concurrency/OSThread.cpp
index ce9a256b73..defc040b9c 100644
--- a/src/concurrency/OSThread.cpp
+++ b/src/concurrency/OSThread.cpp
@@ -54,6 +54,7 @@ void OSThread::setIntervalFromNow(unsigned long _interval)
interval = _interval;
// Cache the next run based on the last_run
+ // unset-sentinel-ok: enabled is the armed flag, and tillRun() reads this as a wrap-safe delta
_cached_next_run = millis() + interval;
}
diff --git a/src/configuration.h b/src/configuration.h
index b55ce262f7..b2bd7e4f60 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -29,18 +29,23 @@ along with this program. If not, see .
#if __has_include("Melopero_RV3028.h")
#include "Melopero_RV3028.h"
#endif
-#if __has_include("SensorRtcHelper.hpp")
-#include "SensorRtcHelper.hpp"
-// SensorLib defines isBitSet as a macro; undefine it here to avoid conflicts
-// with the SparkFun MMC5983MA library, which has a class method of the same name.
-#ifdef isBitSet
-#undef isBitSet
-#endif
+#if __has_include()
+#include
#endif
/* Offer chance for variant-specific defines */
#include "variant.h"
+// Both PCF parts answer at the same address and differ only in register layout, so a variant
+// picks one by defining PCF8563_RTC or PCF85063_RTC to it.
+#if defined(PCF8563_RTC)
+#define PCF_RTC_ADDRESS PCF8563_RTC
+#define PCF_RTC_CHIP PCF8xRTC::PCF8563
+#elif defined(PCF85063_RTC)
+#define PCF_RTC_ADDRESS PCF85063_RTC
+#define PCF_RTC_CHIP PCF8xRTC::PCF85063
+#endif
+
// -----------------------------------------------------------------------------
// Display feature overrides
// -----------------------------------------------------------------------------
@@ -254,10 +259,11 @@ along with this program. If not, see .
// Define if screen should be mirrored left to right
// #define SCREEN_MIRROR
-// I2C Keyboards (M5Stack, RAK14004, T-Deck, T-Deck Pro, T-Lora Pager, CardKB, BBQ10, MPR121, TCA8418)
+// I2C Keyboards (M5Stack, RAK14004, T-Deck, T-Deck Pro, T-Lora Pager, CardKB, BBQ10, MCP23017, MPR121, TCA8418)
#define CARDKB_ADDR 0x5F
#define TDECK_KB_ADDR 0x55
#define BBQ10_KB_ADDR 0x1F
+#define MCP23017_KB_ADDR 0x20 // MCP23017 with A0/A1/A2 tied low - same address as TCA9535_ADDR
#define MPR121_KB_ADDR 0x5A
#define TCA8418_KB_ADDR 0x34
#define TSTC8_KB_ADDR 0x6C // STC8H companion-MCU keypad on the ThinkNode-M9
@@ -353,6 +359,15 @@ along with this program. If not, see .
// -----------------------------------------------------------------------------
#define NCP5623_ADDR 0x38
#define LP5562_ADDR 0x30
+#define LP5814_ADDR 0x2C
+
+// -----------------------------------------------------------------------------
+// Audio Codec
+// -----------------------------------------------------------------------------
+#if not __has_include("Codecs/es8311/ES8311.h")
+#define ES8311_ADDR 0x18 // same address as MCP9808_ADDR / STK8BXX_ADDR / LIS3DH_ADDR
+#endif
+#define ES7243E_ADDR 0x14
// -----------------------------------------------------------------------------
// Security
@@ -367,10 +382,11 @@ along with this program. If not, see .
// -----------------------------------------------------------------------------
// Touchscreen
// -----------------------------------------------------------------------------
-#define FT6336U_ADDR 0x48
-#define CST328_ADDR 0x1A // same address as CST226SE
+#define FT6336U_ADDR 0x48 // same address as ADS1115
+#define CST328_ADDR 0x1A // same address as CST226SE
#define CHSC6X_ADDR 0x2E
#define CST226SE_ADDR_ALT 0x5A
+#define GT911_ADDR 0x5D // same address as SFA30_ADDR / LPS22HB_ADDR_ALT
// -----------------------------------------------------------------------------
// RAK12035VB Soil Monitor (using RAK12023 up to 3 RAK12035 monitors can be connected)
@@ -592,7 +608,12 @@ along with this program. If not, see .
#define MESHTASTIC_EXCLUDE_ADMIN 1
#endif
-// // Turn off wifi even if HW supports wifi (webserver relies on wifi and is also disabled)
+// Store & Forward is implemented only for ESP32 and Portduino
+#if !defined(ARCH_ESP32) && !defined(ARCH_PORTDUINO) && !defined(MESHTASTIC_EXCLUDE_STOREFORWARD)
+#define MESHTASTIC_EXCLUDE_STOREFORWARD 1
+#endif
+
+// Turn off wifi even if HW supports wifi (webserver relies on wifi and is also disabled)
#ifdef MESHTASTIC_EXCLUDE_WIFI
#define MESHTASTIC_EXCLUDE_WEBSERVER 1
#undef HAS_WIFI
@@ -622,6 +643,33 @@ along with this program. If not, see .
#define HAS_SCREEN 0
#endif
+// -----------------------------------------------------------------------------
+// Motion sensor wake
+// -----------------------------------------------------------------------------
+
+/* The motion driver that owns this pin attaches the ISR. sleep.cpp reuses it as a
+ light-sleep wake source and PowerFSM attributes the resulting GPIO wake to motion.
+ Must stay below the exclusion cascade: MESHTASTIC_MINIMIZE_BUILD derives
+ MESHTASTIC_EXCLUDE_I2C above, and no motion driver is built when it is set. */
+#if !MESHTASTIC_EXCLUDE_I2C
+#if defined(BMA4XX_INT) && defined(HAS_BMA423)
+#define MOTION_WAKE_INT_PIN BMA4XX_INT
+#define MOTION_WAKE_INT_ACTIVE_HIGH 1
+#elif defined(BHI260AP_INT) && defined(HAS_BHI260AP)
+#define MOTION_WAKE_INT_PIN BHI260AP_INT
+#define MOTION_WAKE_INT_ACTIVE_HIGH 1
+#elif defined(STK8XXX_INT) && defined(HAS_STK8XXX)
+#define MOTION_WAKE_INT_PIN STK8XXX_INT
+#define MOTION_WAKE_INT_ACTIVE_HIGH 1
+#elif defined(ICM_20948_INT_PIN) && defined(HAS_ICM20948)
+#define MOTION_WAKE_INT_PIN ICM_20948_INT_PIN
+#define MOTION_WAKE_INT_ACTIVE_HIGH 0
+#elif defined(QMA_6100P_INT_PIN) && defined(HAS_QMA6100P)
+#define MOTION_WAKE_INT_PIN QMA_6100P_INT_PIN
+#define MOTION_WAKE_INT_ACTIVE_HIGH 0
+#endif
+#endif
+
#ifndef USE_ETHERNET_DEFAULT
#define USE_ETHERNET_DEFAULT 0
#endif
diff --git a/src/detect/ScanI2C.cpp b/src/detect/ScanI2C.cpp
index 19d5615ba9..56a784b17d 100644
--- a/src/detect/ScanI2C.cpp
+++ b/src/detect/ScanI2C.cpp
@@ -31,8 +31,8 @@ ScanI2C::FoundDevice ScanI2C::firstRTC() const
ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
{
- ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MCP23017, MPR121KB, TCA8418KB, STC8HKB};
- return firstOfOrNONE(7, types);
+ ScanI2C::DeviceType types[] = {CARDKB, TDECKKB, BBQ10KB, RAK14004, MCP23017KB, MPR121KB, TCA8418KB, STC8HKB};
+ return firstOfOrNONE(8, types);
}
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h
index e278a5571e..65c758cc1d 100644
--- a/src/detect/ScanI2C.h
+++ b/src/detect/ScanI2C.h
@@ -51,6 +51,7 @@ class ScanI2C
BMA423,
BQ24295,
LSM6DS3,
+ AW35615,
TCA9535,
TCA9555,
VEML7700,
@@ -72,7 +73,7 @@ class ScanI2C
SCD4X,
MAX30102,
TPS65233,
- MCP23017,
+ MCP23017KB,
MPR121KB,
CGRADSENS,
INA226,
@@ -110,7 +111,11 @@ class ScanI2C
STC8HKB, // STC8H companion-MCU keypad (ThinkNode-M9)
DS248X,
HM330X,
- AS3935
+ AS3935,
+ GT911,
+ LP5814,
+ ES8311,
+ ES7243E,
} DeviceType;
// typedef uint8_t DeviceAddress;
diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp
index 6861f666da..85d1867cef 100644
--- a/src/detect/ScanI2CTwoWire.cpp
+++ b/src/detect/ScanI2CTwoWire.cpp
@@ -319,11 +319,14 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
}
#endif
- // We only need to scan 112 addresses, the rest is reserved for special
- // purposes 0x00 General Call 0x01 CBUS addresses 0x02 Reserved for different
- // bus formats 0x03 Reserved for future purposes 0x04-0x07 High Speed Master
- // Code 0x78-0x7B 10-bit slave addressing 0x7C-0x7F Reserved for future
- // purposes
+ // We only need to scan 112 addresses, the rest is reserved for special purposes
+ // 0x00 General Call
+ // 0x01 CBUS addresses
+ // 0x02 Reserved for different bus formats
+ // 0x03 Reserved for future purposes
+ // 0x04-0x07 High Speed Master Code
+ // 0x78-0x7B 10-bit slave addressing
+ // 0x7C-0x7F Reserved for future purposes
for (addr.address = 8; addr.address < 120; addr.address++) {
#if defined(HAS_QMA6100P) && (defined(ARCH_NRF52) || defined(NRF52_SERIES) || defined(NRF52))
@@ -334,8 +337,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
continue;
LOG_DEBUG("Scan address 0x%x", (uint8_t)addr.address);
}
- // For QMA6100P candidates on nRF52, use bounded I2C probing; otherwise use
- // normal Wire
+ // For QMA6100P candidates on nRF52, use bounded I2C probing; otherwise use normal Wire
#if defined(HAS_QMA6100P) && (defined(ARCH_NRF52) || defined(NRF52_SERIES) || defined(NRF52))
if (addr.address == QMA6100P_ADDRESS_LOW || addr.address == QMA6100P_ADDRESS_HIGH) {
nrf52QmaFound = probeQMA6100P(addr.address);
@@ -446,8 +448,18 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
SCAN_SIMPLE_CASE(ST7567_ADDRESS, SCREEN_ST7567, "ST7567", (uint8_t)addr.address);
#ifdef HAS_NCP5623
SCAN_SIMPLE_CASE(NCP5623_ADDR, NCP5623, "NCP5623", (uint8_t)addr.address);
+#endif
+#if defined(HAS_LP5562) && (LP5562_ADDR != MMC5983MA_ADDR)
+ SCAN_SIMPLE_CASE(LP5562_ADDR, LP5562, "LP5562", (uint8_t)addr.address);
+#endif
+#ifdef HAS_LP5814
+ SCAN_SIMPLE_CASE(LP5814_ADDR, LP5814, "LP5814", (uint8_t)addr.address);
+#endif
+#ifdef HAS_ES7243E
+ SCAN_SIMPLE_CASE(ES7243E_ADDR, ES7243E, "ES7243E", (uint8_t)addr.address);
#endif
case XPOWERS_AXP192_AXP2101_ADDRESS:
+#ifndef SEEED_WIO_TRACKER_L2 // false positive on Wio Tracker L2
// Do we have the axp2101/192 or the TCA8418
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x90), 1);
if (registerValue == 0x0) {
@@ -457,6 +469,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
logFoundDevice("AXP192/AXP2101", (uint8_t)addr.address);
type = PMU_AXP192_AXP2101;
}
+#endif
break;
case BME_ADDR:
case BME_ADDR_ALTERNATE:
@@ -525,8 +538,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
LOG_DEBUG("Register MFG_UID: 0x%x", mfg);
// Only read DIE_UID for vendors we recognize as INA-compatible to avoid
- // an extra I2C transaction + delay on other devices sharing this
- // address.
+ // an extra I2C transaction + delay on other devices sharing this address.
if (mfg == 0x5449 || mfg == 0x190F) {
uint16_t die = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 2);
LOG_DEBUG("Register DIE_UID: 0x%x", die);
@@ -608,10 +620,16 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
#endif
case MCP9808_ADDR:
- // We need to check for STK8BAXX first, since register 0x07 is new data
- // flag for the z-axis and can produce some weird result. and register
- // 0x00 doesn't seems to be colliding with MCP9808 and LIS3DH chips.
+ // We need to check for STK8BAXX first, since register 0x07 is new data flag for the z-axis and can produce some
+ // weird result. and register 0x00 doesn't seems to be colliding with MCP9808 and LIS3DH chips.
{
+ // Check register 0xFD for 0x83 to ID ES8311 audio codec.
+ registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFD), 1);
+ if (registerValue == 0x83) {
+ type = ES8311;
+ logFoundDevice("ES8311", (uint8_t)addr.address);
+ break;
+ }
#ifdef HAS_STK8XXX
// Check register 0x00 for 0x8700 response to ID STK8BA53 chip.
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 2);
@@ -672,6 +690,22 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
case LPS22HB_ADDR_ALT:
+ // GT911 touchscreen: product ID register 0x8140 returns "911"
+ {
+ uint8_t gt911_reg[] = {0x81, 0x40};
+ uint8_t gt911_buf[4] = {0};
+ i2cBus->beginTransmission(addr.address);
+ i2cBus->write(gt911_reg, 2);
+ if (i2cBus->endTransmission() == 0) {
+ i2cBus->requestFrom((int)addr.address, 4);
+ i2cBus->readBytes(gt911_buf, 4);
+ if (gt911_buf[0] == '9' && gt911_buf[1] == '1' && gt911_buf[2] == '1') {
+ type = GT911;
+ logFoundDevice("GT911", (uint8_t)addr.address);
+ break;
+ }
+ }
+ }
// SFA30 detection: send 2-byte command 0xD060 (Get Device Marking) and check for 48-byte response
if (i2cCommandResponseLength(addr, 0xD060, 48)) {
type = SFA30;
@@ -803,24 +837,42 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
logFoundDevice("BMA423", (uint8_t)addr.address);
break;
- case TCA9535_ADDR: // this can also be MCP23017_ADDR (both 0x20)
+ case RAK120353_ADDR: { // AW35615 USB-C CC controller - must be checked before
+ // RAK120353_ADDR which shares 0x22 but is a TCA9535 variant
+ registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 1);
+ if ((registerValue & 0xF0) == 0x90) { // DEVICE_ID upper nibble = 0x9 for AW35615
+ type = AW35615;
+ logFoundDevice("AW35615", (uint8_t)addr.address);
+ break;
+ }
+ // Fall through to TCA9535/RAK check
+ }
+ case TCA9535_ADDR:
case RAK120352_ADDR:
- case RAK120353_ADDR:
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x02), 1);
if (registerValue == addr.address) { // RAK12035 returns its I2C address at 0x02 (eg 0x20)
type = RAK12035;
logFoundDevice("RAK12035", (uint8_t)addr.address);
- } else {
- // TCA9535 only has registers 0x00-0x07; MCP23017 has IOCON at 0x0A
+ break;
+ }
+#if defined(HAS_MCP23017_KEYBOARD) && !defined(USE_MCP23017)
+ // The MCP23017 shares 0x20 with the TCA9535 and there is no read-only probe that
+ // separates them reliably, so only boards that declare an MCP23017 keypad look for
+ // one here. USE_MCP23017 means the part is wired as the radio's GPIO expander and
+ // must never be handed to the keyboard driver, which reprograms IODIR/GPPU.
+ if (addr.address == MCP23017_KB_ADDR) {
+ // IOCON is a single register mirrored at 0x0A and 0x0B; the TCA9535 has no
+ // register there at all. Matching reads are a cheap confirmation.
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0A), 1);
- if (registerValue != 0xFF) {
- type = MCP23017;
+ if (registerValue == getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x0B), 1)) {
+ type = MCP23017KB;
logFoundDevice("MCP23017", (uint8_t)addr.address);
- } else {
- type = TCA9535;
- logFoundDevice("TCA9535", (uint8_t)addr.address);
+ break;
}
}
+#endif
+ type = TCA9535;
+ logFoundDevice("TCA9535", (uint8_t)addr.address);
break;
@@ -915,9 +967,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
SCAN_SIMPLE_CASE(CHSC6X_ADDR, CHSC6X, "CHSC6X", (uint8_t)addr.address);
case LTR553ALS_ADDR:
- registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x86),
- 1); // Part ID register
- if (registerValue == 0x92) { // LTR553ALS Part ID
+ registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x86), 1); // Part ID register
+ if (registerValue == 0x92) { // LTR553ALS Part ID
type = LTR553ALS;
logFoundDevice("LTR553ALS", (uint8_t)addr.address);
} else {
@@ -971,8 +1022,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
type = MLX90614;
logFoundDevice("MLX90614", (uint8_t)addr.address);
} else {
- registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00),
- 1); // DRV2605_REG_STATUS
+ registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); // DRV2605_REG_STATUS
if (registerValue == 0xe0) {
type = DRV2605;
logFoundDevice("DRV2605", (uint8_t)addr.address);
@@ -983,10 +1033,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
}
break;
- case ICM20948_ADDR: // same as BMX160_ADDR, BMI270_ADDR_ALT,
- // ICM42607P_ADDR_ALT, and SEN5X_ADDR
- case ICM20948_ADDR_ALT: // same as MPU6050_ADDR, BMI270_ADDR, and
- // ICM42607P_ADDR
+ case ICM20948_ADDR: // same as BMX160_ADDR, BMI270_ADDR_ALT, ICM42607P_ADDR_ALT, and SEN5X_ADDR
+ case ICM20948_ADDR_ALT: // same as MPU6050_ADDR, BMI270_ADDR, and ICM42607P_ADDR
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1);
#ifdef HAS_ICM20948
type = ICM20948;
@@ -1027,9 +1075,8 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
case CGRADSENS_ADDR:
- // Register 0x00 of the RadSens sensor contains is product identifier
- // 0x7D Undocumented, but some devices return a product identifier of
- // 0x7A
+ // Register 0x00 of the RadSens sensor contains is product identifier 0x7D
+ // Undocumented, but some devices return a product identifier of 0x7A
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1);
if (registerValue == 0x7D || registerValue == 0x7A) {
type = CGRADSENS;
@@ -1041,6 +1088,14 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
break;
case 0x48: {
+ // Check ADS1X15 FIRST - the SE050 probe writes 5 bytes which corrupts the ADS1X15 Lo_thresh register.
+ registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2);
+ if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700 || registerValue == 0xc580) {
+ type = ADS1X15;
+ logFoundDevice("ADS1X15 ADC", (uint8_t)addr.address);
+ break;
+ }
+
// T=1oI2C soft reset; an SE050 answers A5 E0 00 3F 19. requestFrom() is
// required: readBytes() only drains the RX buffer requestFrom() fills.
const uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC};
@@ -1059,24 +1114,15 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
info[i] = i2cBus->read();
isSE050 = (memcmp(expectedInfo, info, sizeof(info)) == 0);
}
- }
- if (isSE050) {
- LOG_INFO("NXP SE050 crypto chip found");
- type = NXP_SE050;
- break;
+ if (isSE050) {
+ LOG_INFO("NXP SE050 crypto chip found");
+ type = NXP_SE050;
+ } else {
+ LOG_INFO("FT6336U touchscreen found");
+ type = FT6336U;
+ }
}
-
- // ADS1X15 default config register is 8583h
- registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x01), 2);
- if (registerValue == 0x8583 || registerValue == 0x8580 || registerValue == 0xf700) {
- type = ADS1X15;
- logFoundDevice("ADS1X15 ADC", (uint8_t)addr.address);
- break;
- }
-
- LOG_INFO("FT6336U touchscreen found");
- type = FT6336U;
break;
}
@@ -1105,55 +1151,55 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
deviceAddresses[type] = addr;
foundDevices[addr] = type;
}
+ }
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
- // AS3935 addresses (0x01-0x03) fall in the reserved range the loop above skips; probe
- // them separately rather than widening that loop for every board.
- static const uint8_t as3935Candidates[] = {AS3935_ADDR_ALT, AS3935_ADDR_ALT2, AS3935_ADDR};
- for (uint8_t i = 0; i < sizeof(as3935Candidates); i++) {
- // Respect the caller's address filter, same as the main loop above (line ~269).
- if (asize != 0 && !in_array(address, asize, as3935Candidates[i]))
- continue;
+ // AS3935 addresses (0x01-0x03) fall in the reserved range the loop above skips; probe
+ // them separately rather than widening that loop for every board.
+ static const uint8_t as3935Candidates[] = {AS3935_ADDR_ALT, AS3935_ADDR_ALT2, AS3935_ADDR};
+ for (uint8_t i = 0; i < sizeof(as3935Candidates); i++) {
+ // Respect the caller's address filter, same as the main loop above (line ~269).
+ if (asize != 0 && !in_array(address, asize, as3935Candidates[i]))
+ continue;
- DeviceAddress as3935Addr(port, as3935Candidates[i]);
+ DeviceAddress as3935Addr(port, as3935Candidates[i]);
+ i2cBus->beginTransmission(as3935Candidates[i]);
+ uint8_t as3935Err = i2cBus->endTransmission();
+ if (as3935Err == 0) {
+ // No WHOAMI, and a POR-only check can't survive a warm reboot (initDevice rewrites
+ // REG0x00). Write a test pattern to bits[5:1] instead and confirm it reads back.
+ constexpr uint8_t AS3935_PROBE_PATTERN = 0b01010; // arbitrary, bits[5:1]
i2cBus->beginTransmission(as3935Candidates[i]);
- uint8_t as3935Err = i2cBus->endTransmission();
- if (as3935Err == 0) {
- // No WHOAMI, and a POR-only check can't survive a warm reboot (initDevice rewrites
- // REG0x00). Write a test pattern to bits[5:1] instead and confirm it reads back.
- constexpr uint8_t AS3935_PROBE_PATTERN = 0b01010; // arbitrary, bits[5:1]
- i2cBus->beginTransmission(as3935Candidates[i]);
- i2cBus->write((uint8_t)0x00); // REG0x00 (AFE_GAIN)
- i2cBus->write((uint8_t)(AS3935_PROBE_PATTERN << 1)); // PWD=0, gain bits = pattern
- if (i2cBus->endTransmission() == 0) {
- uint16_t reg0 = getRegisterValue(ScanI2CTwoWire::RegisterLocation(as3935Addr, 0x00), 1);
- if (((reg0 >> 1) & 0x1F) == AS3935_PROBE_PATTERN) {
- logFoundDevice("AS3935", as3935Candidates[i]);
- deviceAddresses[AS3935] = as3935Addr;
- foundDevices[as3935Addr] = AS3935;
- break; // only one AS3935 expected per bus
- } else {
- LOG_DEBUG("Unexpected REG0x00 readback for AS3935: addr=0x%x val=0x%x", as3935Candidates[i], reg0);
- }
+ i2cBus->write((uint8_t)0x00); // REG0x00 (AFE_GAIN)
+ i2cBus->write((uint8_t)(AS3935_PROBE_PATTERN << 1)); // PWD=0, gain bits = pattern
+ if (i2cBus->endTransmission() == 0) {
+ uint16_t reg0 = getRegisterValue(ScanI2CTwoWire::RegisterLocation(as3935Addr, 0x00), 1);
+ if (((reg0 >> 1) & 0x1F) == AS3935_PROBE_PATTERN) {
+ logFoundDevice("AS3935", as3935Candidates[i]);
+ deviceAddresses[AS3935] = as3935Addr;
+ foundDevices[as3935Addr] = AS3935;
+ break; // only one AS3935 expected per bus
+ } else {
+ LOG_DEBUG("Unexpected REG0x00 readback for AS3935: addr=0x%x val=0x%x", as3935Candidates[i], reg0);
}
}
}
+ }
#endif
- // The QMC6309 magnetometer sits at 0x7C, above the general scan ceiling (the loop above stops at 0x77 to
- // avoid the reserved 0x78-0x7F block). Probe it explicitly. Gated on the SensorLib driver being present so
- // only boards that can actually drive the chip poke this reserved address.
+ // The QMC6309 magnetometer sits at 0x7C, above the general scan ceiling (the loop above stops at 0x77 to
+ // avoid the reserved 0x78-0x7F block). Probe it explicitly. Gated on the SensorLib driver being present so
+ // only boards that can actually drive the chip poke this reserved address.
#if __has_include()
- addr.address = QMC6309_ADDR;
- i2cBus->beginTransmission(addr.address);
- if (i2cBus->endTransmission() == 0 &&
- getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1) == 0x90 /* QMC6309 chip id */) {
- deviceAddresses[QMC6309] = addr;
- foundDevices[addr] = QMC6309;
- logFoundDevice("QMC6309", (uint8_t)addr.address);
- }
-#endif
+ addr.address = QMC6309_ADDR;
+ i2cBus->beginTransmission(addr.address);
+ if (i2cBus->endTransmission() == 0 &&
+ getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1) == 0x90 /* QMC6309 chip id */) {
+ deviceAddresses[QMC6309] = addr;
+ foundDevices[addr] = QMC6309;
+ logFoundDevice("QMC6309", (uint8_t)addr.address);
}
+#endif
}
void ScanI2CTwoWire::scanPort(I2CPort port)
diff --git a/src/freertosinc.h b/src/freertosinc.h
index e9e6cd53a0..db49fbab11 100644
--- a/src/freertosinc.h
+++ b/src/freertosinc.h
@@ -12,7 +12,7 @@
#include
#endif
-#if defined(ARDUINO_NRF52_ADAFRUIT) || defined(ARDUINO_ARCH_RP2040)
+#if defined(ARDUINO_NRF52_ADAFRUIT) || defined(ARDUINO_ARCH_NRF54) || defined(ARDUINO_ARCH_RP2040)
#define HAS_FREE_RTOS
#include
diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp
index 9f29c41f0c..c10e3a3e67 100644
--- a/src/gps/GPS.cpp
+++ b/src/gps/GPS.cpp
@@ -1363,7 +1363,7 @@ void GPS::up()
// We've finished a GPS search cycle (lock or timeout). Enter a low power state, potentially.
void GPS::down()
{
- if (hasValidLocation)
+ if (scheduling.hasValidFixSinceSearchStarted())
scheduling.informGotLock();
else
scheduling.informSearchFailed();
@@ -1545,6 +1545,7 @@ int32_t GPS::runOnce()
// 2. Got a lock for the first time, or 3. Got a lock after turning back on
bool gotLoc = lookForLocation();
if (gotLoc) {
+ scheduling.informValidFix();
#if GPS_DEBUG
if (!hasValidLocation) { // declare that we have location ASAP
LOG_DEBUG("hasValidLocation RISING EDGE");
@@ -1560,14 +1561,13 @@ int32_t GPS::runOnce()
if (holdTime > GPS_FIX_HOLD_MAX_MS)
holdTime = GPS_FIX_HOLD_MAX_MS;
// Same clock the Throttle evaluation reads, and never the "no hold" sentinel.
- const uint32_t holdEnds = Time::getMillis() + holdTime;
- fixHoldEnds = holdEnds == 0 ? 1 : holdEnds;
+ fixHoldEnds = Time::timerEndsAtMillis(holdTime);
LOG_DEBUG_GPS("Holding for %ums after lock", holdTime);
}
}
bool tooLong = scheduling.searchedTooLong();
- if (tooLong && !gotLoc) {
+ if (tooLong && !scheduling.hasValidFixSinceSearchStarted()) {
LOG_WARN("Can't publish valid location: no GPS lock in time");
// we didn't get a location during this ack window, therefore declare loss of lock
if (hasValidLocation) {
diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp
index 7f37e100c9..9f6cc6695f 100644
--- a/src/gps/GPSUpdateScheduling.cpp
+++ b/src/gps/GPSUpdateScheduling.cpp
@@ -32,9 +32,16 @@ uint32_t gpsHardsleepThresholdMs(uint32_t predictedSearchSecs)
void GPSUpdateScheduling::informSearching()
{
searching = true;
+ validFixReceived = false;
searchStartedMs = Time::getMillis();
}
+void GPSUpdateScheduling::informValidFix()
+{
+ if (searching)
+ validFixReceived = true;
+}
+
// Mark the time when searching for GPS is complete,
// then update the predicted lock-time
void GPSUpdateScheduling::informGotLock()
@@ -64,6 +71,7 @@ void GPSUpdateScheduling::informSearchFailed()
void GPSUpdateScheduling::reset()
{
searching = false;
+ validFixReceived = false;
searchStartedMs = 0;
searchEndedMs = 0;
searchCount = 0;
@@ -152,6 +160,11 @@ bool GPSUpdateScheduling::searchedTooLong()
return false;
}
+bool GPSUpdateScheduling::hasValidFixSinceSearchStarted() const
+{
+ return searching && validFixReceived;
+}
+
// Updates the predicted time-to-get-lock, by exponentially smoothing the latest observation
void GPSUpdateScheduling::updateLockTimePrediction()
{
diff --git a/src/gps/GPSUpdateScheduling.h b/src/gps/GPSUpdateScheduling.h
index d7e11ad1ab..b8de3ff654 100644
--- a/src/gps/GPSUpdateScheduling.h
+++ b/src/gps/GPSUpdateScheduling.h
@@ -12,12 +12,14 @@ class GPSUpdateScheduling
public:
// Marks the time of these events, for calculation use
void informSearching();
+ void informValidFix();
void informGotLock(); // Predicted lock-time is recalculated here
void informSearchFailed(); // Search ended without a fix; prediction is left untouched
void reset(); // Reset the prediction - after GPS::disable() / GPS::enable()
bool isUpdateDue(); // Is it time to begin searching for a GPS position?
bool searchedTooLong(); // Have we been searching for too long?
+ bool hasValidFixSinceSearchStarted() const;
uint32_t msUntilNextSearch(); // How long until we need to begin searching for a GPS? Info provided to GPS hardware for sleep
uint32_t elapsedSearchMs(); // How long have we been searching so far?
@@ -26,6 +28,7 @@ class GPSUpdateScheduling
private:
void updateLockTimePrediction(); // Called from informGotLock
bool searching = false; // Set by the inform*() calls; never inferred from stamp ordering
+ bool validFixReceived = false;
uint32_t searchStartedMs = 0;
uint32_t searchEndedMs = 0;
uint32_t searchCount = 0;
@@ -33,4 +36,4 @@ class GPSUpdateScheduling
uint32_t consecutiveFailures = 0; // Count of search cycles that ended without a fix; reset on lock
const float weighting = 0.2; // Controls exponential smoothing of lock-times prediction. 20% weighting of "latest lock-time".
-};
\ No newline at end of file
+};
diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp
index 93e59e31e6..ca5ebe146a 100644
--- a/src/gps/RTC.cpp
+++ b/src/gps/RTC.cpp
@@ -8,6 +8,7 @@
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
#include "modules/NodeInfoModule.h"
+#include "modules/WaypointModule.h"
#include
#include
#include
@@ -35,6 +36,10 @@ static void onTimeSourceQualityChanged(RTCQuality oldQuality, RTCQuality newQual
if (nodeDB)
nodeDB->backfillHeardAt();
}
+#if !MESHTASTIC_EXCLUDE_WAYPOINT && HAS_SCREEN
+ if (waypointModule && oldQuality != newQuality)
+ waypointModule->onDeviceTimeChanged();
+#endif
}
RTCQuality getRTCQuality()
@@ -151,24 +156,25 @@ RTCSetResult readFromRTC()
LOG_WARN("RTC read: not found (addr 0x%02X)", rtc_found.address);
}
#elif defined(PCF8563_RTC) || defined(PCF85063_RTC)
-#if defined(PCF8563_RTC)
- if (rtc_found.address == PCF8563_RTC) {
- SensorPCF8563 rtc;
-#elif defined(PCF85063_RTC)
- if (rtc_found.address == PCF85063_RTC) {
- SensorPCF85063 rtc;
-
-#endif
+ if (rtc_found.address == PCF_RTC_ADDRESS) {
+ PCF8xRTC rtc;
const uint64_t now = Time::getMillisMonotonic();
#if WIRE_INTERFACES_COUNT == 2
- rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found));
+ TwoWire &rtcBus = *ScanI2CTwoWire::fetchI2CBus(rtc_found);
#else
- rtc.begin(Wire);
+ TwoWire &rtcBus = Wire;
#endif
-
- RTC_DateTime datetime = rtc.getDateTime();
- tm t = datetime.toUnixTime();
+ tm t;
+ if (!rtc.begin(rtcBus, PCF_RTC_ADDRESS, PCF_RTC_CHIP)) {
+ LOG_WARN("%s not responding at 0x%02X", rtc.chipName(), PCF_RTC_ADDRESS);
+ return RTCSetResultInvalidTime;
+ }
+ if (!rtc.getTime(t)) {
+ // Only the chip itself can tell us the oscillator stopped, so ask after begin() worked.
+ LOG_WARN("%s read failed%s", rtc.chipName(), rtc.lostPower() ? " (oscillator stopped)" : "");
+ return RTCSetResultInvalidTime;
+ }
tv.tv_sec = gm_mktime(&t);
tv.tv_usec = 0;
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
@@ -183,7 +189,7 @@ RTCSetResult readFromRTC()
}
#endif
- LOG_DEBUG_GPS("RTC time from %s getDateTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t.tm_year + 1900,
+ LOG_DEBUG_GPS("RTC time from %s getTime: %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.chipName(), t.tm_year + 1900,
t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, printableEpoch);
if (currentQuality == RTCQualityNone) {
RTCQuality oldQuality = currentQuality;
@@ -326,7 +332,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
currentQuality = q;
lastSetMsec = now;
if (currentQuality >= RTCQualityNTP) {
- lastSetFromPhoneNtpOrGps = now;
+ lastSetFromPhoneNtpOrGps = Time::skipZero(now);
}
// This delta value works on all platforms
@@ -352,27 +358,23 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address);
}
#elif defined(PCF8563_RTC) || defined(PCF85063_RTC)
-#if defined(PCF8563_RTC)
- if (rtc_found.address == PCF8563_RTC) {
- SensorPCF8563 rtc;
-#elif defined(PCF85063_RTC)
- if (rtc_found.address == PCF85063_RTC) {
- SensorPCF85063 rtc;
-
-#endif
-
+ if (rtc_found.address == PCF_RTC_ADDRESS) {
+ PCF8xRTC rtc;
#if WIRE_INTERFACES_COUNT == 2
- rtc.begin(*ScanI2CTwoWire::fetchI2CBus(rtc_found));
+ TwoWire &rtcBus = *ScanI2CTwoWire::fetchI2CBus(rtc_found);
#else
- rtc.begin(Wire);
+ TwoWire &rtcBus = Wire;
#endif
// tv_sec is a long, which is not time_t everywhere: on Windows
// time_t is 64-bit while long is 32-bit. Copy before taking &.
time_t setSecs = tv->tv_sec;
const tm *t = gmtime(&setSecs);
- rtc.setDateTime(*t);
- LOG_DEBUG_GPS("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900,
- t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
+ if (rtc.begin(rtcBus, PCF_RTC_ADDRESS, PCF_RTC_CHIP) && rtc.setTime(*t)) {
+ LOG_DEBUG_GPS("%s setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.chipName(), t->tm_year + 1900, t->tm_mon + 1,
+ t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch);
+ } else {
+ LOG_WARN("%s set time failed", rtc.chipName());
+ }
} else {
LOG_WARN("RTC set: not found (addr 0x%02X)", rtc_found.address);
}
diff --git a/src/graphics/BaseUIEInkDisplay.cpp b/src/graphics/BaseUIEInkDisplay.cpp
index 0506ba68dd..15d00f1ee7 100644
--- a/src/graphics/BaseUIEInkDisplay.cpp
+++ b/src/graphics/BaseUIEInkDisplay.cpp
@@ -1,6 +1,7 @@
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "./BaseUIEInkDisplay.h"
+#include "UptimeClock.h"
#include "configuration.h"
#include "main.h"
@@ -73,7 +74,7 @@ void BaseUIEInkDisplay::display()
// Keyframe path. Returns true if a frame was pushed (sets lastDrawMsec).
bool BaseUIEInkDisplay::forceDisplay(uint32_t msecLimit)
{
- const uint32_t now = millis();
+ const uint32_t now = Time::stampMillis();
if (lastDrawMsec != 0 && (now - lastDrawMsec) < msecLimit)
return false;
diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp
index de058bc1f9..aec2c2caf2 100644
--- a/src/graphics/EInkDisplay2.cpp
+++ b/src/graphics/EInkDisplay2.cpp
@@ -1,3 +1,4 @@
+#include "UptimeClock.h"
#include "configuration.h"
#include "graphics/Backlight.h"
@@ -59,7 +60,7 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit)
// No need to grab this lock because we are on our own SPI bus
// concurrency::LockGuard g(spiLock);
- uint32_t now = millis();
+ uint32_t now = Time::stampMillis();
uint32_t sinceLast = now - lastDrawMsec;
if (adafruitDisplay && (sinceLast > msecLimit || lastDrawMsec == 0))
diff --git a/src/graphics/EInkParallelDisplay.cpp b/src/graphics/EInkParallelDisplay.cpp
index a61b1bae97..d8d0f9475c 100644
--- a/src/graphics/EInkParallelDisplay.cpp
+++ b/src/graphics/EInkParallelDisplay.cpp
@@ -1,4 +1,5 @@
#include "EInkParallelDisplay.h"
+#include "UptimeClock.h"
#ifdef USE_EINK_PARALLELDISPLAY
@@ -208,7 +209,7 @@ void EInkParallelDisplay::display(void)
const uint16_t h = this->displayHeight;
// Simple rate limiting: avoid very-frequent responsive updates
- uint32_t nowMs = millis();
+ uint32_t nowMs = Time::stampMillis();
if (lastUpdateMs != 0 && (nowMs - lastUpdateMs) < EPD_RESPONSIVE_MIN_MS) {
LOG_DEBUG("rate-limited, skipping update");
return;
@@ -367,11 +368,11 @@ void EInkParallelDisplay::display(void)
startAsyncFullUpdate(forceFull ? CLEAR_SLOW : CLEAR_FAST);
}
- lastUpdateMs = millis();
+ lastUpdateMs = Time::stampMillis();
previousImageHash = imageHash;
// Keep same behavior as before
- lastDrawMsec = millis();
+ lastDrawMsec = Time::stampMillis();
}
#ifdef EINK_LIMIT_GHOSTING_PX
@@ -420,7 +421,7 @@ bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit)
if (!displayReady)
return false;
- uint32_t now = millis();
+ uint32_t now = Time::stampMillis();
if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) {
display();
return true;
diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index 8cf3f8c926..8d9c35ae42 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -24,6 +24,7 @@ along with this program. If not, see .
#include "NodeDB.h"
#include "PowerMon.h"
#include "Throttle.h"
+#include "UptimeClock.h"
#include "configuration.h"
#include "meshUtils.h"
#if HAS_SCREEN
@@ -327,7 +328,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options)
NotificationRenderer::parseBannerMessageWithFonts(NotificationRenderer::alertBannerMessage);
NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
NotificationRenderer::alertBannerUntil =
- (banner_overlay_options.durationMs == 0) ? 0 : millis() + banner_overlay_options.durationMs;
+ (banner_overlay_options.durationMs == 0) ? 0 : Time::timerEndsAtMillis(banner_overlay_options.durationMs);
NotificationRenderer::optionsArrayPtr = banner_overlay_options.optionsArrayPtr;
NotificationRenderer::optionsEnumPtr = banner_overlay_options.optionsEnumPtr;
NotificationRenderer::alertBannerOptions = banner_overlay_options.optionsCount;
@@ -351,7 +352,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct
// Store the message and set the expiration timestamp
strncpy(NotificationRenderer::alertBannerMessage, message, 255);
NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
- NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
+ NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs);
NotificationRenderer::alertBannerCallback = bannerCallback;
NotificationRenderer::pauseBanner = false;
NotificationRenderer::curSelected = 0;
@@ -373,7 +374,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t
// Store the message and set the expiration timestamp
strncpy(NotificationRenderer::alertBannerMessage, message, 255);
NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
- NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
+ NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs);
NotificationRenderer::alertBannerCallback = bannerCallback;
NotificationRenderer::pauseBanner = false;
NotificationRenderer::curSelected = 0;
@@ -402,7 +403,7 @@ void Screen::showAlphanumericPicker(const char *message, const char *initialText
strncpy(NotificationRenderer::alertBannerMessage, message, 255);
NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
- NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
+ NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs);
NotificationRenderer::textInputCallback = bannerCallback;
NotificationRenderer::pauseBanner = false;
NotificationRenderer::curSelected = 0;
@@ -439,7 +440,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t
// Store the message and set the expiration timestamp (use same pattern as other notifications)
strncpy(NotificationRenderer::alertBannerMessage, header ? header : "Text Input", 255);
NotificationRenderer::alertBannerMessage[255] = '\0';
- NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
+ NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : Time::timerEndsAtMillis(durationMs);
NotificationRenderer::pauseBanner = false;
NotificationRenderer::current_notification_type = notificationTypeEnum::text_input;
@@ -492,7 +493,7 @@ float Screen::estimatedHeading(double lat, double lon)
static double oldLat, oldLon;
static float b = -1.0f;
static uint32_t lastHeadingAtMs = 0;
- const uint32_t now = millis();
+ const uint32_t now = Time::stampMillis();
const uint32_t gpsUpdateIntervalSecs =
Default::getConfiguredOrDefault(config.position.gps_update_interval, default_gps_update_interval);
uint32_t effectiveUpdateIntervalSecs = gpsUpdateIntervalSecs;
@@ -1083,6 +1084,7 @@ int32_t Screen::runOnce()
{
// If we don't have a screen, don't ever spend any CPU for us.
if (!useDisplay) {
+ textMessageFrameShown = false;
enabled = false;
return RUN_SAME;
}
@@ -1202,7 +1204,11 @@ int32_t Screen::runOnce()
handleStartFirmwareUpdateScreen();
break;
case Cmd::STOP_ALERT_FRAME:
+ // Cleared even while a module holds the screen: START_ALERT_FRAME set it and nothing
+ // else would, so swallowing it here would leave banners suppressed for good.
NotificationRenderer::pauseBanner = false;
+ if (hasModalModule())
+ break; // only the owning module may take the screen back off its own frame
// Return from one-off alert mode back to regular frames.
if (!showingNormalScreen && NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) {
setFrames();
@@ -1223,6 +1229,7 @@ int32_t Screen::runOnce()
if (!screenOn) { // If we didn't just wake and the screen is still off, then
// stop updating until it is on again
+ textMessageFrameShown = false;
enabled = false;
return 0;
}
@@ -1260,7 +1267,7 @@ int32_t Screen::runOnce()
// standard screen switching is stopped.
if (showingNormalScreen) {
// standard screen loop handling here
- if (config.display.auto_screen_carousel_secs > 0 &&
+ if (config.display.auto_screen_carousel_secs > 0 && !hasModalModule() &&
NotificationRenderer::current_notification_type != notificationTypeEnum::text_input &&
!Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) {
@@ -1276,6 +1283,9 @@ int32_t Screen::runOnce()
}
}
+ textMessageFrameShown = showingNormalScreen && framesetInfo.positions.textMessage != 255 && ui &&
+ ui->getUiState()->currentFrame == framesetInfo.positions.textMessage;
+
// LOG_DEBUG("want fps %d, fixed=%d", targetFramerate,
// ui->getUiState()->frameState); If we are scrolling we need to be called
// soon, otherwise just 1 fps (to save CPU) We also ask to be called twice
@@ -1315,6 +1325,10 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
if (einkScreensaver != NULL) {
screensaverFrame = einkScreensaver;
ui->setFrames(&screensaverFrame, 1);
+
+ // Hide the nav bar before the sleep / shutdown screen is rendered
+ static OverlayCallback screensaverOverlays[] = {NotificationRenderer::drawBannercallback};
+ ui->setOverlays(screensaverOverlays, 1);
}
// Else, display the usual "overlay" screensaver
@@ -1372,6 +1386,7 @@ void Screen::setFrames(FrameFocus focus)
return;
}
+ const FramesetInfo previousFramesetInfo = framesetInfo;
uint8_t originalPosition = ui->getUiState()->currentFrame;
uint8_t previousFrameCount = framesetInfo.frameCount;
FramesetInfo fsi; // Location of specific frames, for applying focus parameter
@@ -1624,8 +1639,15 @@ void Screen::setFrames(FrameFocus focus)
break;
case FOCUS_PRESERVE:
- // No more adjustment - force stay on same index
- if (previousFrameCount > fsi.frameCount) {
+ if (previousFramesetInfo.positions.waypoint == 255 && fsi.positions.waypoint != 255) {
+ const uint8_t target = originalPosition >= fsi.positions.waypoint ? originalPosition + 1 : originalPosition;
+ ui->switchToFrame(target);
+ } else if (previousFramesetInfo.positions.waypoint != 255 && fsi.positions.waypoint == 255) {
+ const uint8_t target = originalPosition > previousFramesetInfo.positions.waypoint
+ ? originalPosition - 1
+ : std::min(originalPosition, fsi.frameCount - 1);
+ ui->switchToFrame(target);
+ } else if (previousFrameCount > fsi.frameCount) {
ui->switchToFrame(originalPosition - 1);
} else if (previousFrameCount < fsi.frameCount) {
ui->switchToFrame(originalPosition + 1);
@@ -1848,6 +1870,19 @@ void Screen::applyHiddenFramesMask(uint32_t mask)
hiddenFrames.chirpy = getBit(mask, FVBIT_CHIRPY);
}
+bool Screen::isShowingModuleFrame(const MeshModule *m) const
+{
+ if (!m || !showingNormalScreen)
+ return false;
+ // Same effective frame drawModuleFrame() picks: mid-transition the incoming frame is the one
+ // being rendered, so comparing currentFrame would report false while the module is on screen.
+ const OLEDDisplayUiState *state = ui->getUiState();
+ uint8_t frame = state->currentFrame;
+ if (state->frameState == IN_TRANSITION && state->transitionFrameRelationship == TransitionRelationship_INCOMING)
+ frame = state->transitionFrameTarget;
+ return frame < moduleFrames.size() && moduleFrames.at(frame) == m;
+}
+
void Screen::loadFrameVisibility()
{
#ifdef FSCom
@@ -2109,6 +2144,21 @@ int Screen::handleUIFrameEvent(const UIFrameEvent *event)
return 0;
}
+// Only the environmental telemetry frame answers SELECT with a menu. A module frame that has none
+// must not claim the press, or every frame matched after it in the dispatch chain is unreachable.
+static bool moduleFrameHasMenu(size_t frame)
+{
+#if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
+ // moduleFrames bounds the module-frame region, before favorites are appended; its leading slots
+ // are nullptr padding for the built-in frames, so only a non-null entry is a real module frame.
+ const MeshModule *module = frame < moduleFrames.size() ? moduleFrames.at(frame) : nullptr;
+ return module != nullptr && environmentTelemetryModule != nullptr && environmentTelemetryModule->ownsFrame(module);
+#else
+ (void)frame;
+ return false;
+#endif
+}
+
int Screen::handleInputEvent(const InputEvent *event)
{
LOG_INPUT("Screen Input event %u! kb %u", event->inputEvent, event->kbchar);
@@ -2262,7 +2312,8 @@ int Screen::handleInputEvent(const InputEvent *event)
#endif
if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_ALT_PRESS) {
showFrame(FrameDirection::PREVIOUS);
- } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS) {
+ } else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS ||
+ (event->inputEvent == INPUT_BROKER_ANYKEY && event->kbchar == ' ')) {
showFrame(FrameDirection::NEXT);
} else if (event->inputEvent == INPUT_BROKER_FN_F1) {
this->ui->switchToFrame(0);
@@ -2336,16 +2387,8 @@ int Screen::handleInputEvent(const InputEvent *event)
menuHandler::textMessageBaseMenu();
}
}
- // moduleFrames.size() bounds the module-frame region, before favorites are appended; its leading
- // slots are nullptr padding for the built-in frames, so only a non-null entry is a real module frame.
- } else if (this->ui->getUiState()->currentFrame < moduleFrames.size() &&
- moduleFrames.at(this->ui->getUiState()->currentFrame) != nullptr) {
-#if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
- const MeshModule *currentModule = moduleFrames.at(this->ui->getUiState()->currentFrame);
- if (environmentTelemetryModule != nullptr && environmentTelemetryModule->ownsFrame(currentModule)) {
- menuHandler::environmentTelemetryMenu();
- }
-#endif
+ } else if (moduleFrameHasMenu(this->ui->getUiState()->currentFrame)) {
+ menuHandler::environmentTelemetryMenu();
} else if (framesetInfo.positions.firstFavorite != 255 &&
this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite &&
this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) {
@@ -2360,6 +2403,9 @@ int Screen::handleInputEvent(const InputEvent *event)
menuHandler::nodeListMenu();
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.wifi) {
menuHandler::wifiBaseMenu();
+ } else if (framesetInfo.positions.waypoint != 255 &&
+ this->ui->getUiState()->currentFrame == framesetInfo.positions.waypoint) {
+ menuHandler::waypointBaseMenu();
}
} else if (event->inputEvent == INPUT_BROKER_BACK) {
showFrame(FrameDirection::PREVIOUS);
@@ -2393,6 +2439,11 @@ bool Screen::isOverlayBannerShowing()
return NotificationRenderer::isOverlayBannerShowing();
}
+bool Screen::isTextMessageFrameShown() const
+{
+ return textMessageFrameShown.load();
+}
+
bool Screen::isGamesFrameShown()
{
return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games;
diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h
index e7a77942f5..7a99c4820b 100644
--- a/src/graphics/Screen.h
+++ b/src/graphics/Screen.h
@@ -5,6 +5,7 @@
#include "detect/ScanI2C.h"
#include "mesh/generated/meshtastic/config.pb.h"
#include
+#include
#include
#include
#include
@@ -46,6 +47,8 @@ struct BannerOverlayOptions {
bool shouldWakeOnReceivedMessage();
+class MeshModule;
+
#if !HAS_SCREEN
#include "Power.h"
namespace graphics
@@ -64,6 +67,8 @@ class Screen
};
explicit Screen(ScanI2C::DeviceAddress, meshtastic_Config_DisplayConfig_OledType, OLEDDISPLAY_GEOMETRY);
+ // These are empty stubs, but they mirror the real Screen's instance API, so they can't become static.
+ // cppcheck-suppress-begin functionStatic
void onPress() {}
void setup() {}
void setOn(bool) {}
@@ -73,6 +78,10 @@ class Screen
void increaseBrightness() {}
void decreaseBrightness() {}
void startAlert(const char *) {}
+ void setModalModule(const MeshModule *) {}
+ void clearModalModule(const MeshModule *) {}
+ bool hasModalModule() const { return false; }
+ bool isShowingModuleFrame(const MeshModule *) const { return false; }
void showSimpleBanner(const char *message, uint32_t durationMs = 0) {}
void showOverlayBanner(BannerOverlayOptions) {}
void setFrames(FrameFocus focus) {}
@@ -80,6 +89,7 @@ class Screen
bool getIsI2cScreen() const { return false; }
uint32_t getI2cFrequency() const { return 0; }
ScanI2C::I2CPort getI2CPort() const { return ScanI2C::I2CPort::NO_I2C; }
+ // cppcheck-suppress-end functionStatic
};
} // namespace graphics
#else
@@ -277,6 +287,9 @@ class Screen : public concurrency::OSThread
bool isOverlayBannerShowing();
+ // Thread-safe snapshot of whether the text-message frame is currently shown.
+ bool isTextMessageFrameShown() const;
+
// True if the always-present games frame is the one currently on screen. Lets the games module
// ignore D-pad input when the player has navigated to a different frame.
bool isGamesFrameShown();
@@ -338,6 +351,20 @@ class Screen : public concurrency::OSThread
enqueueCmd(cmd);
}
+ // Holds the screen against the carousel, the new-message banner and a foreign endAlert().
+ // Only the owner can release it, unlike endAlert(), which any caller can fire.
+ void setModalModule(const MeshModule *owner) { modalModule = owner; }
+ void clearModalModule(const MeshModule *owner)
+ {
+ if (modalModule == owner)
+ modalModule = nullptr;
+ }
+ bool hasModalModule() const { return modalModule != nullptr; }
+
+ // True while this module's own frame is on screen. Modules observe input before Screen does,
+ // so one handling keys needs this or it takes them from the frame the user is looking at.
+ bool isShowingModuleFrame(const MeshModule *m) const;
+
void showSimpleBanner(const char *message, uint32_t durationMs = 0);
void showOverlayBanner(BannerOverlayOptions);
@@ -680,6 +707,9 @@ class Screen : public concurrency::OSThread
uint16_t displayHeight = 0;
private:
+ // nullptr for every build with no modal module, which is why the three sites are unchanged.
+ const MeshModule *modalModule = nullptr;
+
FrameCallback alertFrames[1];
struct ScreenCmd {
Cmd cmd;
@@ -801,6 +831,7 @@ class Screen : public concurrency::OSThread
// Whether we are showing the regular screen (as opposed to booth screen or
// Bluetooth PIN screen)
bool showingNormalScreen = false;
+ std::atomic textMessageFrameShown{false};
/// Track USB power state to only wake screen on actual power state changes
bool lastPowerUSBState = false;
diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp
index 5e6567206c..762b618239 100644
--- a/src/graphics/SharedUIDisplay.cpp
+++ b/src/graphics/SharedUIDisplay.cpp
@@ -557,13 +557,20 @@ const int *getTextPositions(OLEDDisplay *display)
textPositions[5] = textFifthLine_medium;
textPositions[6] = textSixthLine_medium;
} else {
+ int bodyShift = 0;
+ if (isTFTColoringEnabled()) {
+ const int headerBottom = FONT_HEIGHT_SMALL + 1 + BASEUI_HEADER_MARGIN;
+ const int overlap = headerBottom - textFirstLine;
+ if (overlap > 0 && (textSixthLine + FONT_HEIGHT_SMALL + overlap) <= SCREEN_HEIGHT)
+ bodyShift = overlap;
+ }
textPositions[0] = textZeroLine;
- textPositions[1] = textFirstLine;
- textPositions[2] = textSecondLine;
- textPositions[3] = textThirdLine;
- textPositions[4] = textFourthLine;
- textPositions[5] = textFifthLine;
- textPositions[6] = textSixthLine;
+ textPositions[1] = textFirstLine + bodyShift;
+ textPositions[2] = textSecondLine + bodyShift;
+ textPositions[3] = textThirdLine + bodyShift;
+ textPositions[4] = textFourthLine + bodyShift;
+ textPositions[5] = textFifthLine + bodyShift;
+ textPositions[6] = textSixthLine + bodyShift;
}
return textPositions;
}
diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp
index 445d5c8960..07bb7e6350 100644
--- a/src/graphics/TFTDisplay.cpp
+++ b/src/graphics/TFTDisplay.cpp
@@ -830,6 +830,183 @@ class LGFX : public lgfx::LGFX_Device
static LGFX *tft = nullptr;
+#elif defined(SEEED_WIO_TRACKER_L2) // Inline LGFX: NV3031B panel + SPI3 + GT911 touch + LP5814 backlight
+
+#include
+
+// LP5814 4-channel LED driver (backlight), I2C 0x2C
+class Wio_Tracker_Light : public lgfx::v1::ILight
+{
+ static constexpr uint8_t REG_DEVICE_CONFIG0 = 0x00;
+ static constexpr uint8_t REG_MAX_CURRENT = 0x01;
+ static constexpr uint8_t REG_ENABLE_CONTROL = 0x02;
+ static constexpr uint8_t REG_DIM_MODE = 0x04;
+ static constexpr uint8_t REG_ENGINE_MODE = 0x05;
+ static constexpr uint8_t REG_UPDATE = 0x0F;
+ static constexpr uint8_t REG_LED0_DC = 0x14;
+ static constexpr uint8_t REG_LED0_PWM = 0x18;
+
+ public:
+ struct config_t {
+ uint8_t brightness = 153;
+ }; // 60%
+
+ const config_t &config(void) const { return _cfg; }
+ void config(const config_t &cfg) { _cfg = cfg; }
+
+ bool init(uint8_t brightness) override
+ {
+ Wire.beginTransmission(0x2c);
+ if (Wire.endTransmission() != 0) {
+ LOG_ERROR("LP5814 not found at 0x2c");
+ return false;
+ }
+ bool result = true;
+ result &= writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable
+ result &= writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current
+ result &= writeReg(REG_ENABLE_CONTROL, 0x00); // disable outputs while configuring
+ result &= writeReg(REG_DIM_MODE, 0x4E); // dim mode config
+ result &= writeReg(REG_ENGINE_MODE, 0xF0); // engine mode config
+ // Set DC current for all 4 channels (registers 0x14..0x17)
+ for (uint8_t i = 0; i < 4; i++) {
+ result &= writeReg(REG_LED0_DC + i, 200);
+ }
+ result &= writeReg(REG_ENABLE_CONTROL, 0x0F); // enable all 4 channels
+ result &= writeReg(REG_UPDATE, 0x55); // latch parameters (LP5814 requires 0x55)
+ delay(5); // LP5814 engine startup settling time
+
+ setBrightness(brightness);
+ return result;
+ }
+
+ void setBrightness(uint8_t brightness) override
+ {
+ // Write PWM to all 4 channels (registers 0x18..0x1B).
+ for (uint8_t i = 0; i < 4; i++) {
+ writeReg(REG_LED0_PWM + i, brightness);
+ }
+ _cfg.brightness = brightness;
+ }
+
+ uint8_t getBrightness(void) const { return _cfg.brightness; }
+ virtual ~Wio_Tracker_Light(void) = default;
+
+ private:
+ bool writeReg(uint8_t reg, uint8_t value)
+ {
+ Wire.beginTransmission(0x2c);
+ Wire.write(reg);
+ Wire.write(value);
+ uint8_t error = Wire.endTransmission();
+ if (error != 0) {
+ LOG_ERROR("LP5814 write reg 0x%02x failed: %d", reg, error);
+ return false;
+ }
+ return true;
+ }
+ config_t _cfg;
+};
+
+class LGFX : public lgfx::LGFX_Device
+{
+ lgfx::Panel_NV3031B _panel_instance;
+ lgfx::Bus_SPI _bus_instance;
+ lgfx::Touch_GT911 _touch_instance;
+ Wio_Tracker_Light _light_instance;
+
+ public:
+ const uint32_t screenWidth = 320;
+ const uint32_t screenHeight = 240;
+
+ bool hasButton(void) { return true; }
+
+ bool init_impl(bool use_reset, bool use_clear) override
+ {
+ _light_instance.init(_light_instance.config().brightness);
+ bool result = LGFX_Device::init_impl(use_reset, use_clear);
+ // GT911 probe leaves I2C BUSY flag stuck; reset peripheral for LP5814 setBrightness
+ Wire.end();
+ Wire.begin(47, 48);
+ Wire.setClock(100000);
+ return result;
+ }
+
+ lgfx::ILight *light(void) const { return (lgfx::ILight *)&_light_instance; }
+
+ LGFX(void)
+ {
+ // Bus: SPI3 quad pins (mode 3, 75 MHz write / 16 MHz read)
+ {
+ auto cfg = _bus_instance.config();
+ cfg.spi_host = SPI3_HOST;
+ cfg.spi_mode = 3;
+ cfg.freq_write = 75000000;
+ cfg.freq_read = 16000000;
+ cfg.pin_sclk = 42;
+ cfg.pin_io0 = 41;
+ cfg.pin_io1 = 40;
+ cfg.pin_io2 = 39;
+ cfg.pin_io3 = 38;
+ _bus_instance.config(cfg);
+ _panel_instance.setBus(&_bus_instance);
+ }
+ // Panel: NV3031B (CS=46)
+ {
+ auto cfg = _panel_instance.config();
+ cfg.pin_cs = 46;
+ cfg.pin_rst = -1;
+ cfg.pin_busy = -1;
+ cfg.panel_width = screenHeight; // NV3031B native: 240 wide × 320 tall
+ cfg.panel_height = screenWidth;
+ cfg.memory_width = screenHeight;
+ cfg.memory_height = screenWidth;
+ cfg.offset_x = 0;
+ cfg.offset_y = 0;
+ cfg.offset_rotation = 1; // Landscape with setRotation(0); matches MUI
+ cfg.invert = true; // NV3031B requires invert, otherwise screen shows green grid
+ cfg.rgb_order = true;
+ cfg.dlen_16bit = false;
+ cfg.bus_shared = false;
+ _panel_instance.config(cfg);
+ }
+ // Touch: GT911 (I2C 0x5D on SDA=47 SCL=48)
+ {
+ auto cfg = _touch_instance.config();
+ cfg.pin_cs = -1;
+ cfg.x_min = 0;
+ cfg.x_max = screenHeight - 1;
+ cfg.y_min = 0;
+ cfg.y_max = screenWidth - 1;
+ cfg.pin_int = -1;
+ cfg.offset_rotation = 2;
+ cfg.i2c_port = 0;
+ cfg.i2c_addr = 0x5D;
+ cfg.pin_sda = 47;
+ cfg.pin_scl = 48;
+ cfg.bus_shared = false;
+ cfg.freq = 100000;
+ _touch_instance.config(cfg);
+ _panel_instance.setTouch(&_touch_instance);
+ }
+ _panel_instance.setLight(&_light_instance);
+ setPanel(&_panel_instance);
+ }
+
+ void sleep(void)
+ {
+ _panel->setSleep(true);
+ _light_instance.setBrightness(0);
+ }
+
+ void wakeup(void)
+ {
+ _panel->setSleep(false);
+ _light_instance.setBrightness(_light_instance.config().brightness);
+ }
+};
+
+static LGFX *tft = nullptr;
+
#elif defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER)
#include // Graphics and font library for ILI9341/ILI9342 driver chip
@@ -1874,7 +2051,8 @@ bool TFTDisplay::connect()
tft->setRotation(1); // T-Deck has the TFT in landscape
#elif defined(T_WATCH_S3)
tft->setRotation(2); // T-Watch S3 left-handed orientation
-#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) || defined(T_WATCH_ULTRA)
+#elif ARCH_PORTDUINO || defined(SENSECAP_INDICATOR) || defined(T_LORA_PAGER) || defined(T_WATCH_ULTRA) || \
+ defined(SEEED_WIO_TRACKER_L2)
tft->setRotation(0); // use config.yaml to set rotation
#else
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp
index b7eb83a20e..b631fa8416 100644
--- a/src/graphics/draw/MenuHandler.cpp
+++ b/src/graphics/draw/MenuHandler.cpp
@@ -9,6 +9,7 @@
#include "MeshService.h"
#include "MessageStore.h"
#include "NodeDB.h"
+#include "UptimeClock.h"
#include "buzz.h"
#include "graphics/Backlight.h"
#include "graphics/Screen.h"
@@ -28,11 +29,16 @@
#include "modules/AdminModule.h"
#include "modules/CannedMessageModule.h"
#include "modules/ExternalNotificationModule.h"
+#include "modules/GeofenceModule.h"
#include "modules/KeyVerificationModule.h"
#if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR
#include "modules/Telemetry/EnvironmentTelemetry.h"
#endif
#include "modules/TraceRouteModule.h"
+#include "modules/WaypointModule.h"
+#if !MESHTASTIC_EXCLUDE_WAYPOINT
+#include "WaypointStore.h"
+#endif
#include
#include
#include
@@ -45,6 +51,10 @@ namespace graphics
namespace
{
+#if !MESHTASTIC_EXCLUDE_WAYPOINT
+uint32_t selectedGeofenceWaypointId = 0;
+#endif
+
// Caller must ensure the provided options array outlives the banner callback.
template
BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOption (&options)[N],
@@ -218,8 +228,33 @@ void menuHandler::OnboardMessage()
screen->showOverlayBanner(bannerOptions);
}
+// Out-of-box US setup starts on LongTurbo rather than the region table's LongFast. Menu-only: the
+// US entry in `regions[]` keeps LongFast, so no other route onto US changes. Anything that already
+// states a preset - a pinned userpref, or a preset moved off the install default - outranks it.
+meshtastic_Config_LoRaConfig_ModemPreset menuHandler::presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora,
+ meshtastic_Config_LoRaConfig_RegionCode selected)
+{
+#ifdef USERPREFS_LORACONFIG_MODEM_PRESET
+ (void)selected; // the pinned preset wins outright; nothing to decide
+#else
+ if (lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && selected == meshtastic_Config_LoRaConfig_RegionCode_US &&
+ lora.use_preset && lora.modem_preset == meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST) {
+ return meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO;
+ }
+#endif
+ return lora.modem_preset;
+}
+
static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool isHam)
{
+ // Decided first: it keys off the *outgoing* region being UNSET.
+ const meshtastic_Config_LoRaConfig_ModemPreset selectionPreset = menuHandler::presetForRegionSelection(config.lora, region);
+ if (selectionPreset != config.lora.modem_preset) {
+ LOG_INFO("First region is %s, default preset to %s", getRegion(region)->name,
+ DisplayFormatters::getModemPresetDisplayName(selectionPreset, false, true));
+ config.lora.modem_preset = selectionPreset;
+ }
+
config.lora.region = region;
config.lora.channel_num = 0; // Reset to default channel
@@ -263,6 +298,10 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
if (gps != nullptr && !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED)
gps->enable();
#endif
+ if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && !config.lora.tx_enabled && !owner.is_licensed) {
+ LOG_WARN("Setting config.lora.tx_enabled to true");
+ config.lora.tx_enabled = true;
+ }
service->reloadConfig(changes);
}
@@ -440,7 +479,7 @@ void menuHandler::deviceRolePicker()
config.device.role = meshtastic_Config_DeviceConfig_Role_TRACKER;
}
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
};
screen->showOverlayBanner(bannerOptions);
}
@@ -1834,12 +1873,12 @@ void menuHandler::resetNodeDBMenu()
LOG_INFO("Initiate node-db reset");
nodeDB->resetNodes();
disableBluetooth();
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
} else if (selected == 2) {
LOG_INFO("Initiate node-db reset, keep favorites");
nodeDB->resetNodes(1);
disableBluetooth();
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
} else if (selected == 0) {
menuQueue = NodeBaseMenu;
screen->runNow();
@@ -2034,12 +2073,12 @@ void menuHandler::GPSSmartPositionMenu()
config.position.position_broadcast_smart_enabled = true;
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
} else if (selected == 2) {
config.position.position_broadcast_smart_enabled = false;
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
}
};
bannerOptions.InitialSelected = config.position.position_broadcast_smart_enabled ? 1 : 2;
@@ -2094,7 +2133,7 @@ void menuHandler::GPSUpdateIntervalMenu()
if (selected != 0) {
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
}
};
@@ -2184,7 +2223,7 @@ void menuHandler::GPSPositionBroadcastMenu()
if (selected != 0) {
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
}
};
@@ -2325,7 +2364,7 @@ void menuHandler::switchToMUIMenu()
config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR;
config.bluetooth.enabled = false;
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
}
};
screen->showOverlayBanner(bannerOptions);
@@ -2346,7 +2385,7 @@ void menuHandler::rebootMenu()
IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0));
nodeDB->saveToDisk();
messageStore.saveToFlash();
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
} else {
menuQueue = PowerMenu;
screen->runNow();
@@ -2400,6 +2439,158 @@ void menuHandler::removeFavoriteMenu()
screen->showOverlayBanner(bannerOptions);
}
+void menuHandler::waypointBaseMenu()
+{
+ enum optionsNumbers { Back, GeofenceAlerts, RemoveWaypoint };
+ static const char *optionsArray[] = {"Back", "Geofence Alerts", "Remove Waypoint"};
+
+ BannerOverlayOptions bannerOptions;
+ bannerOptions.message = "Waypoint Action";
+ bannerOptions.optionsArrayPtr = optionsArray;
+ bannerOptions.optionsCount = 3;
+ bannerOptions.bannerCallback = [](int selected) -> void {
+ if (selected == GeofenceAlerts) {
+ menuQueue = GeofenceWaypointMenu;
+ screen->runNow();
+ } else if (selected == RemoveWaypoint) {
+ menuQueue = RemoveWaypointMenu;
+ screen->runNow();
+ }
+ };
+ screen->showOverlayBanner(bannerOptions);
+}
+
+void menuHandler::geofenceWaypointMenu()
+{
+#if MESHTASTIC_EXCLUDE_WAYPOINT
+ menuQueue = MenuNone;
+#else
+ static const char *optionsArray[WAYPOINT_HISTORY_LIMIT + 1];
+ static uint32_t waypointIds[WAYPOINT_HISTORY_LIMIT + 1];
+ static std::string labelStorage[WAYPOINT_HISTORY_LIMIT + 1];
+
+ optionsArray[0] = "Back";
+ int options = 1;
+ for (const StoredWaypoint &entry : waypointStore.getWaypoints()) {
+ if (options > WAYPOINT_HISTORY_LIMIT || !GeofenceModule::hasGeofence(entry.waypoint))
+ continue;
+ std::string name = sanitizeString(entry.waypoint.name);
+ if (name.empty())
+ name = "Unnamed Geofence";
+ labelStorage[options] = name.substr(0, 20);
+ optionsArray[options] = labelStorage[options].c_str();
+ waypointIds[options] = entry.waypoint.id;
+ options++;
+ }
+
+ BannerOverlayOptions bannerOptions;
+ bannerOptions.message = options > 1 ? "Geofence Alerts" : "No Geofences";
+ bannerOptions.optionsArrayPtr = optionsArray;
+ bannerOptions.optionsCount = options;
+ bannerOptions.bannerCallback = [](int selected) -> void {
+ if (selected == 0) {
+ menuQueue = WaypointBaseMenu;
+ } else {
+ selectedGeofenceWaypointId = waypointIds[selected];
+ menuQueue = GeofenceOptionsMenu;
+ }
+ screen->runNow();
+ };
+ screen->showOverlayBanner(bannerOptions);
+#endif
+}
+
+void menuHandler::geofenceOptionsMenu()
+{
+#if MESHTASTIC_EXCLUDE_WAYPOINT
+ menuQueue = MenuNone;
+#else
+ const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId);
+ if (!entry) {
+ menuQueue = GeofenceWaypointMenu;
+ screen->runNow();
+ return;
+ }
+
+ static std::string labels[4];
+ static const char *optionsArray[4];
+ labels[0] = "Back";
+ labels[1] = std::string("Enter Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_ENTER) ? "On" : "Off");
+ labels[2] = std::string("Exit Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_EXIT) ? "On" : "Off");
+ labels[3] = std::string("Favorites Only: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY) ? "On" : "Off");
+ for (size_t i = 0; i < 4; ++i)
+ optionsArray[i] = labels[i].c_str();
+
+ BannerOverlayOptions bannerOptions;
+ bannerOptions.message = "Geofence Alerts";
+ bannerOptions.optionsArrayPtr = optionsArray;
+ bannerOptions.optionsCount = 4;
+ bannerOptions.bannerCallback = [](int selected) -> void {
+ if (selected == 0) {
+ menuQueue = GeofenceWaypointMenu;
+ } else {
+ const StoredWaypoint *current = waypointStore.findWaypoint(selectedGeofenceWaypointId);
+ if (current) {
+ const WaypointNotificationPreference preference =
+ selected == 1 ? WAYPOINT_NOTIFY_ENTER
+ : (selected == 2 ? WAYPOINT_NOTIFY_EXIT : WAYPOINT_NOTIFY_FAVORITES_ONLY);
+ waypointStore.setNotificationPreference(selectedGeofenceWaypointId, preference,
+ !current->notificationEnabled(preference));
+ }
+ menuQueue = GeofenceOptionsMenu;
+ }
+ screen->runNow();
+ };
+ screen->showOverlayBanner(bannerOptions);
+#endif
+}
+
+void menuHandler::removeWaypointMenu()
+{
+#if MESHTASTIC_EXCLUDE_WAYPOINT
+ menuQueue = MenuNone;
+#else
+ static const char *optionsArray[WAYPOINT_HISTORY_LIMIT + 1];
+ static uint32_t waypointIds[WAYPOINT_HISTORY_LIMIT + 1];
+ static std::string labelStorage[WAYPOINT_HISTORY_LIMIT + 1];
+
+ optionsArray[0] = "Back";
+ int options = 1;
+
+ for (const auto &entry : waypointStore.getWaypoints()) {
+ if (options > WAYPOINT_HISTORY_LIMIT)
+ break;
+ std::string name = sanitizeString(entry.waypoint.name);
+ if (name.empty())
+ name = "Unnamed Waypoint";
+ labelStorage[options] = name.substr(0, 20);
+ optionsArray[options] = labelStorage[options].c_str();
+ waypointIds[options] = entry.waypoint.id;
+ options++;
+ }
+
+ BannerOverlayOptions bannerOptions;
+ bannerOptions.message = "Remove Waypoint";
+ bannerOptions.optionsArrayPtr = optionsArray;
+ bannerOptions.optionsCount = options;
+ bannerOptions.bannerCallback = [](int selected) -> void {
+ if (selected == 0) {
+ menuQueue = WaypointBaseMenu;
+ screen->runNow();
+ return;
+ }
+ const uint32_t waypointId = waypointIds[selected];
+ LOG_INFO("Removing waypoint 0x%08x", waypointId);
+ if (waypointModule)
+ waypointModule->broadcastDelete(waypointId);
+ else
+ waypointStore.removeWaypoint(waypointId);
+ screen->setFrames(graphics::Screen::FOCUS_DEFAULT);
+ };
+ screen->showOverlayBanner(bannerOptions);
+#endif
+}
+
void menuHandler::traceRouteMenu()
{
screen->showNodePicker("Node to Trace", 30000, [](uint32_t nodenum) -> void {
@@ -2497,12 +2688,12 @@ void menuHandler::wifiToggleMenu()
config.network.wifi_enabled = false;
config.bluetooth.enabled = true;
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
} else if (selected == Wifi_enable) {
config.network.wifi_enabled = true;
config.bluetooth.enabled = false;
service->reloadConfig(SEGMENT_CONFIG);
- rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
}
};
screen->showOverlayBanner(bannerOptions);
@@ -3037,6 +3228,18 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case RemoveFavorite:
removeFavoriteMenu();
break;
+ case WaypointBaseMenu:
+ waypointBaseMenu();
+ break;
+ case GeofenceWaypointMenu:
+ geofenceWaypointMenu();
+ break;
+ case GeofenceOptionsMenu:
+ geofenceOptionsMenu();
+ break;
+ case RemoveWaypointMenu:
+ removeWaypointMenu();
+ break;
case TraceRouteMenu:
traceRouteMenu();
break;
diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h
index 9650932232..a2cc14e677 100644
--- a/src/graphics/draw/MenuHandler.h
+++ b/src/graphics/draw/MenuHandler.h
@@ -37,6 +37,10 @@ class menuHandler
NodePickerMenu,
ManageNodeMenu,
RemoveFavorite,
+ WaypointBaseMenu,
+ GeofenceWaypointMenu,
+ GeofenceOptionsMenu,
+ RemoveWaypointMenu,
TestMenu,
NumberTest,
EnvironmentTelemetryMenu,
@@ -108,6 +112,10 @@ class menuHandler
static void manageNodeMenu();
static void addFavoriteMenu();
static void removeFavoriteMenu();
+ static void waypointBaseMenu();
+ static void geofenceWaypointMenu();
+ static void geofenceOptionsMenu();
+ static void removeWaypointMenu();
static void traceRouteMenu();
static void testMenu();
static void numberTest();
@@ -133,6 +141,11 @@ class menuHandler
// ever runs via screen->showOverlayBanner(), which is why nothing here was unit-testable.
static void toggleNodeMuted(uint32_t nodeNum); // uint32_t, matching pickedNodeNum above
+ // Preset a region selection should leave installed. `lora` is the config as it stands *before*
+ // the selection is written.
+ static meshtastic_Config_LoRaConfig_ModemPreset presetForRegionSelection(const meshtastic_Config_LoRaConfig &lora,
+ meshtastic_Config_LoRaConfig_RegionCode selected);
+
private:
static void saveUIConfig();
static void keyVerificationInitMenu();
diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp
index acfc4d11b6..284bfc6c4f 100644
--- a/src/graphics/draw/MessageRenderer.cpp
+++ b/src/graphics/draw/MessageRenderer.cpp
@@ -3,6 +3,8 @@
#include "MessageRenderer.h"
// Core includes
+#include "Channels.h"
+#include "MeshService.h"
#include "MessageStore.h"
#include "NodeDB.h"
#include "UIRenderer.h"
@@ -1132,18 +1134,13 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
{
if (packet.from != 0) {
hasUnreadMessage = true;
- const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive();
+ const bool suppressBanner =
+ (cannedMessageModule && cannedMessageModule->isFreeTextActive()) || (screen && screen->isTextMessageFrameShown());
// Don't let the pop-up clobber a menu/picker the user is interacting with; the wake below
// still happens so a message can light the screen back up.
const bool menuShowing = NotificationRenderer::isMenuShowing();
- // Determine if message belongs to a muted channel
- bool isChannelMuted = false;
- if (sm.type == MessageType::BROADCAST) {
- const meshtastic_Channel channel = channels.getByIndex(packet.channel ? packet.channel : channels.getPrimaryIndex());
- if (channel.settings.has_module_settings && channel.settings.module_settings.is_muted)
- isChannelMuted = true;
- }
+ const bool isMuted = isMutedForPacket(packet);
// Banner logic
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
@@ -1163,21 +1160,9 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
char truncatedLongName[64];
graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName),
availWidth);
- const char *msgRaw = reinterpret_cast(packet.decoded.payload.bytes);
char banner[256];
- bool isAlert = false;
-
- // Check if alert detection is enabled via external notification module
- if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra ||
- moduleConfig.external_notification.alert_bell_buzzer) {
- for (size_t i = 0; i < packet.decoded.payload.size && i < 100; i++) {
- if (msgRaw[i] == '\x07') {
- isAlert = true;
- break;
- }
- }
- }
+ const bool isAlert = MeshService::isAlertPayload(packet);
if (isAlert) {
if (truncatedLongName[0])
@@ -1185,8 +1170,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
else
strcpy(banner, "Alert Received");
} else {
- // Skip muted channels unless it's an alert
- if (isChannelMuted)
+ // Skip muted channels/senders unless it's an alert
+ if (isMuted)
return;
if (truncatedLongName[0]) {
@@ -1230,7 +1215,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
screen->setOn(true);
}
- if (!suppressBanner && !menuShowing) {
+ if (!suppressBanner && !menuShowing && !screen->hasModalModule()) {
screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
}
}
diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp
index 24a5eeaf63..6f2f6ff650 100644
--- a/src/graphics/draw/NodeListRenderer.cpp
+++ b/src/graphics/draw/NodeListRenderer.cpp
@@ -566,9 +566,7 @@ void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
float bearing = GeoCoord::bearing(userLat, userLon, nodeLat, nodeLon);
float relativeBearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeadingRadian);
float relativeBearingDeg = CompassRenderer::radiansToDegrees360(relativeBearing);
- // Shrink size by 2px
- int size = FONT_HEIGHT_SMALL - 5;
- CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg);
+ drawRelativeCompassArrow(display, centerX, centerY, relativeBearingDeg);
/*
float angle = relativeBearing * DEG_TO_RAD;
float halfSize = size / 2.0;
@@ -613,6 +611,12 @@ void drawCompassUnknown(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
display->drawString(centerX, y, "?");
}
+void drawRelativeCompassArrow(OLEDDisplay *display, int16_t centerX, int16_t centerY, float relativeBearingDeg)
+{
+ const int size = FONT_HEIGHT_SMALL - 5;
+ CompassRenderer::drawArrowToNode(display, centerX, centerY, size, relativeBearingDeg);
+}
+
// =============================
// Main Screen Functions
// =============================
diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h
index d1e8bac1dd..3d14b6466f 100644
--- a/src/graphics/draw/NodeListRenderer.h
+++ b/src/graphics/draw/NodeListRenderer.h
@@ -45,6 +45,7 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
// Extras renderers
void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth,
float myHeadingRadian, double userLat, double userLon);
+void drawRelativeCompassArrow(OLEDDisplay *display, int16_t centerX, int16_t centerY, float relativeBearingDeg);
// Screen frame functions
void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp
index 7abfd210da..28de1a4b74 100644
--- a/src/graphics/draw/NotificationRenderer.cpp
+++ b/src/graphics/draw/NotificationRenderer.cpp
@@ -802,7 +802,10 @@ void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisp
uint16_t screenHeight = display->height();
uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3;
- uint8_t visibleTotalLines = std::min(totalLines, (screenHeight - vPadding * 2) / effectiveLineHeight);
+ // Pairing PIN: pass every line, drawNotificationBox fits them (tiny panels spread them over the full screen).
+ uint8_t visibleTotalLines = (current_notification_type == notificationTypeEnum::pairing_pin)
+ ? totalLines
+ : std::min(totalLines, (screenHeight - vPadding * 2) / effectiveLineHeight);
uint8_t linesShown = lineCount;
const char *linePointers[visibleTotalLines + 1] = {0}; // this is sort of a dynamic allocation
@@ -940,10 +943,16 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3;
uint8_t visibleTotalLines = 0;
uint16_t contentHeight = 0;
+#if defined(OLED_TINY)
+ // Tiny panels: the pairing PIN takes the whole screen, all lines shown and spread evenly over it.
+ const bool fullScreenPin = (current_notification_type == notificationTypeEnum::pairing_pin);
+#else
+ const bool fullScreenPin = false;
+#endif
const uint16_t availableHeight = (screenHeight > (vPadding * 2)) ? (screenHeight - vPadding * 2) : 0;
for (uint8_t i = 0; i < lineCount; i++) {
uint8_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight;
- if (contentHeight + thisLineHeight > availableHeight) {
+ if (!fullScreenPin && contentHeight + thisLineHeight > availableHeight) {
break;
}
contentHeight += thisLineHeight;
@@ -964,7 +973,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
}
int16_t boxTop = (display->height() / 2) - (boxHeight / 2);
boxHeight += (currentResolution == ScreenResolution::High) ? 2 : 1;
- if (graphics::isCompactPanel(display)) {
+ if (fullScreenPin || graphics::isCompactPanel(display)) {
boxLeft = 0;
boxTop = 0;
boxWidth = display->width();
@@ -1008,6 +1017,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
for (int i = 0; i < visibleTotalLines; i++) {
display->setFont(fontForBannerLine(lineFonts[i]));
int16_t thisLineHeight = lineEffectiveHeights[i] ? lineEffectiveHeights[i] : effectiveLineHeight;
+ if (fullScreenPin) {
+ // Equal slots over the full height (10 rows each on a 32px panel, glyphs sit in rows 3..9).
+ thisLineHeight = boxHeight / visibleTotalLines;
+ lineY = i * thisLineHeight;
+ }
int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2;
if (needs_bell && i == 0) {
int fontHeight = thisLineHeight + 3;
diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp
index 804f949ffb..58d759679b 100644
--- a/src/graphics/draw/UIRenderer.cpp
+++ b/src/graphics/draw/UIRenderer.cpp
@@ -1,3 +1,4 @@
+#include "UptimeClock.h"
#include "configuration.h"
#if HAS_SCREEN
#include "CompassRenderer.h"
@@ -545,7 +546,9 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht
// Draw satellite image
if (currentResolution == ScreenResolution::High) {
- NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display);
+ const int iconSlack = FONT_HEIGHT_SMALL - (imgGPS_height * 2);
+ const int iconY = y + (iconSlack > 0 ? iconSlack / 2 : 0);
+ NodeListRenderer::drawScaledXBitmap16x16(x, iconY, imgGPS_width, imgGPS_height, imgGPS, display);
} else {
display->drawXbm(x + 1, y + 3, imgGPS_width, imgGPS_height, imgGPS);
}
@@ -2210,12 +2213,12 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
if (navBarVisible && !navBarPrevVisible) {
EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when showing nav bar
cosmeticRefreshDone = false;
- navBarLastShown = millis();
+ navBarLastShown = Time::skipZero(Time::getMillis());
}
if (!navBarVisible && navBarPrevVisible) {
- EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when hiding nav bar
- navBarLastShown = millis(); // Mark when it disappeared
+ EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when hiding nav bar
+ navBarLastShown = Time::skipZero(Time::getMillis()); // Mark when it disappeared
}
if (!navBarVisible && navBarLastShown != 0 && !cosmeticRefreshDone) {
diff --git a/src/graphics/emotes.cpp b/src/graphics/emotes.cpp
index 9fb26fa1fd..9bb807d56f 100644
--- a/src/graphics/emotes.cpp
+++ b/src/graphics/emotes.cpp
@@ -188,7 +188,8 @@ const Emote emotes[] = {
{"\u2603\uFE0F", snow_man, snow_man_width, snow_man_height}, // ☃️ Snowman
// --- Misc ---
- {"\U0001F4A8", dashing_away, dashing_away_width, dashing_away_height} // 💨 Dashing Away
+ {"\U0001F4A8", dashing_away, dashing_away_width, dashing_away_height}, // 💨 Dashing Away
+ {"\U0001F4CD", pushpin, pushpin_width, pushpin_height} // 📍 Pushpin
#endif
};
@@ -606,6 +607,10 @@ const unsigned char wood[] PROGMEM = {0xF0, 0x0F, 0x08, 0x10, 0x04, 0x20, 0x0C,
const unsigned char beer[] PROGMEM = {0x00, 0x00, 0x50, 0x05, 0xA8, 0x0A, 0x5C, 0x1D, 0xE4, 0x13, 0x1C,
0x7C, 0xE4, 0x73, 0x04, 0x50, 0x04, 0x51, 0x14, 0x50, 0x44, 0x54,
0x04, 0x71, 0x44, 0x70, 0x04, 0x10, 0x18, 0x0C, 0xE0, 0x03};
+
+const unsigned char pushpin[] PROGMEM = {0x00, 0x00, 0xC0, 0x07, 0xE0, 0x0F, 0xF0, 0x1F, 0xF0, 0x1F, 0xF0,
+ 0x17, 0xF0, 0x13, 0xF0, 0x11, 0x60, 0x08, 0xC0, 0x07, 0x00, 0x01,
+ 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00};
#endif
} // namespace graphics
diff --git a/src/graphics/emotes.h b/src/graphics/emotes.h
index 9646ac78ef..cb00f5d0ed 100644
--- a/src/graphics/emotes.h
+++ b/src/graphics/emotes.h
@@ -425,6 +425,10 @@ extern const unsigned char wood[] PROGMEM;
#define beer_width 16
#define beer_height 16
extern const unsigned char beer[] PROGMEM;
+
+#define pushpin_width 16
+#define pushpin_height 16
+extern const unsigned char pushpin[] PROGMEM;
#endif // EXCLUDE_EMOJI
} // namespace graphics
diff --git a/src/graphics/niche/InkHUD/Applet.cpp b/src/graphics/niche/InkHUD/Applet.cpp
index 50efff8ffe..03fdd0ea40 100644
--- a/src/graphics/niche/InkHUD/Applet.cpp
+++ b/src/graphics/niche/InkHUD/Applet.cpp
@@ -635,7 +635,7 @@ std::string InkHUD::Applet::getTimeString(uint32_t epochSeconds)
// Format the clock string, either 12 hour or 24 hour
char clockStr[11];
if (config.display.use_12h_clock)
- sprintf(clockStr, "%u:%02u %s", (hour % 12 == 0 ? 12 : hour % 12), min, hour > 11 ? "PM" : "AM");
+ sprintf(clockStr, "%u:%02u %s", (hour % 12 == 0 ? 12u : hour % 12), min, hour > 11 ? "PM" : "AM");
else
sprintf(clockStr, "%02u:%02u", hour, min);
diff --git a/src/graphics/niche/InkHUD/Applet.h b/src/graphics/niche/InkHUD/Applet.h
index 84891b1fec..0f48039588 100644
--- a/src/graphics/niche/InkHUD/Applet.h
+++ b/src/graphics/niche/InkHUD/Applet.h
@@ -129,6 +129,7 @@ class Applet : public GFX
virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification
virtual class MapApplet *asMapApplet() { return nullptr; } // Returns non-null only for MapApplet and its subclasses
+ virtual class WaypointListApplet *asWaypointListApplet() { return nullptr; } // Returns non-null only for WaypointListApplet
static uint16_t getHeaderHeight(); // How tall the "standard" applet header is
diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp
index 5c8d78cb80..795447b749 100644
--- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.cpp
@@ -2,6 +2,8 @@
#include "./MapApplet.h"
#include "./MapTile.h"
+#include "WaypointStore.h"
+#include "WaypointUtils.h"
#include
#include
@@ -21,11 +23,95 @@ static int tileTyAt(int tileIndex);
static int tileMetadataZoomCount();
static int tileMetadataZoomAt(int index);
-// Observe GPS position updates so the map redraws whenever a new location arrives.
+namespace
+{
+
+bool waypointHasAnchor(const meshtastic_Waypoint &waypoint)
+{
+ return waypoint.has_latitude_i && waypoint.has_longitude_i;
+}
+
+bool waypointHasMapGeometry(const meshtastic_Waypoint &waypoint)
+{
+ return waypointHasAnchor(waypoint) || waypoint.has_bounding_box;
+}
+
+void includeMapPoint(float latNode, float lngNode, float lngCenter, float &northernmost, float &southernmost, float &easternmost,
+ float &westernmost)
+{
+ northernmost = max(northernmost, latNode);
+ southernmost = min(southernmost, latNode);
+
+ const float degEastward = fmodf(((lngNode - lngCenter) + 360.0f), 360.0f);
+ const float degWestward = fabsf(fmodf(((lngNode - lngCenter) - 360.0f), 360.0f));
+ if (degEastward < degWestward)
+ easternmost = max(easternmost, lngCenter + degEastward);
+ else
+ westernmost = min(westernmost, lngCenter - degWestward);
+}
+
+} // namespace
+
+bool InkHUD::MapApplet::mapWaypointIconGlyph(uint32_t codepoint, std::string &glyph)
+{
+ if (!codepoint)
+ return false;
+
+ const std::string utf8 = WaypointUtils::utf8FromCodepoint(codepoint);
+ if (utf8.empty())
+ return false;
+
+ glyph = getFont().decodeUTF8(utf8);
+ return glyph.size() == 1 && glyph[0] != '\x1A' && glyph[0] != '\x7F';
+}
+
+static int16_t markerScreenX(float eastMeters, float metersToPx, uint16_t width)
+{
+ return (width * 0.5f) + (eastMeters * metersToPx);
+}
+
+static int16_t markerScreenY(float northMeters, float metersToPx, uint16_t height)
+{
+ return (height * 0.5f) - (northMeters * metersToPx);
+}
+
+uint8_t InkHUD::MapApplet::fallbackBadgeNumber(const WaypointMarker &entry)
+{
+ uint8_t badge = 0;
+
+ for (auto it = waypointMarkers.rbegin(); it != waypointMarkers.rend(); ++it) {
+ if (!it->hasMarker)
+ continue;
+
+ std::string glyph;
+ if (mapWaypointIconGlyph(it->icon, glyph))
+ continue;
+
+ if (it->id == entry.id)
+ return badge;
+
+ if (badge < 9)
+ ++badge;
+ }
+
+ return 0;
+}
+
+void InkHUD::MapApplet::drawWaypointFallbackMarker(const WaypointMarker &entry, int16_t x, int16_t y)
+{
+ char badgeText[3];
+ snprintf(badgeText, sizeof(badgeText), "%u", (unsigned)fallbackBadgeNumber(entry));
+
+ // Keep fallback digits centered so they read like map markers.
+ setFont(fontSmall);
+ printAt(x, y + 1, badgeText, CENTER, MIDDLE);
+}
+
InkHUD::MapApplet::MapApplet()
{
if (gpsStatus)
gpsStatusObserver.observe(&gpsStatus->onNewStatus);
+ waypointStoreObserver.observe(&waypointStore);
}
int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status)
@@ -39,6 +125,17 @@ int InkHUD::MapApplet::onGpsStatusUpdate(const meshtastic::Status *status)
return 0;
}
+int InkHUD::MapApplet::onWaypointStoreChanged(const WaypointStore *store)
+{
+ (void)store;
+
+ if (!isActive())
+ return 0;
+
+ requestUpdate(Drivers::EInk::UpdateTypes::FAST);
+ return 0;
+}
+
// Zoom in one step from the current display zoom.
void InkHUD::MapApplet::zoomIn()
{
@@ -72,6 +169,19 @@ void InkHUD::MapApplet::resetZoom()
{
s_zoomLocked = false;
s_lockedZoom = -1;
+ focusedWaypointId = 0;
+}
+
+bool InkHUD::MapApplet::focusWaypoint(uint32_t waypointId)
+{
+ const StoredWaypoint *entry = waypointStore.findWaypoint(waypointId);
+ if (!entry || WaypointStore::isExpired(*entry) || !waypointHasMapGeometry(entry->waypoint))
+ return false;
+
+ s_zoomLocked = false;
+ s_lockedZoom = -1;
+ focusedWaypointId = waypointId;
+ return true;
}
bool InkHUD::MapApplet::canZoomIn() const
@@ -405,7 +515,7 @@ void InkHUD::MapApplet::onRender(bool full)
chosenZoom = s_lockedZoom;
float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad);
chosenMetersToPx = 1.0f / mpp;
- } else if ((markers.empty() || metersToPxFit <= 0.0f) && nzooms > 0) {
+ } else if (((markers.empty() && waypointMarkers.empty()) || metersToPxFit <= 0.0f) && nzooms > 0) {
// No spread to fit (own node only, or single remote node at map center). Use highest zoom at native scale.
chosenZoom = zooms[0];
float mpp = (2.0f * M_PI * R / (256.0f * (float)(1 << chosenZoom))) * cosf(latRad);
@@ -490,6 +600,48 @@ void InkHUD::MapApplet::onRender(bool full)
setTextColor(BLACK);
}
+ // Draw waypoint markers after nodes so the boxed icons stay legible.
+ for (const WaypointMarker &m : waypointMarkers) {
+ if (m.hasMarker && m.geofenceRadiusMeters > 0) {
+ const int16_t radiusPx = std::max(1, (int16_t)lroundf(m.geofenceRadiusMeters * metersToPx));
+ const int16_t centerX = markerScreenX(m.eastMeters, metersToPx, width());
+ const int16_t centerY = markerScreenY(m.northMeters, metersToPx, height());
+ drawCircle(centerX, centerY, radiusPx, BLACK);
+ }
+
+ if (m.hasBoundingBox) {
+ const int16_t westX = markerScreenX(m.boxWestMeters, metersToPx, width());
+ const int16_t eastX = markerScreenX(m.boxEastMeters, metersToPx, width());
+ const int16_t northY = markerScreenY(m.boxNorthMeters, metersToPx, height());
+ const int16_t southY = markerScreenY(m.boxSouthMeters, metersToPx, height());
+ const int16_t left = std::min(westX, eastX);
+ const int16_t right = std::max(westX, eastX);
+ const int16_t top = std::min(northY, southY);
+ const int16_t bottom = std::max(northY, southY);
+ drawRect(left, top, std::max(1, right - left + 1), std::max(1, bottom - top + 1), BLACK);
+ }
+
+ if (!m.hasMarker)
+ continue;
+
+ int16_t x = markerScreenX(m.eastMeters, metersToPx, width());
+ int16_t y = markerScreenY(m.northMeters, metersToPx, height());
+ constexpr int outlinePad = 1;
+ const int boxSize = fontSmall.lineHeight() + 2;
+ const int radius = max(2, boxSize / 6);
+
+ fillRoundedRect(x, y, boxSize + (outlinePad * 2), boxSize + (outlinePad * 2), radius + 1, WHITE);
+ drawRoundRect(x - (boxSize / 2), y - (boxSize / 2), boxSize, boxSize, radius, BLACK);
+
+ std::string glyph;
+ if (mapWaypointIconGlyph(m.icon, glyph)) {
+ setFont(fontSmall);
+ printAt(x, y + 1, glyph, CENTER, MIDDLE);
+ } else {
+ drawWaypointFallbackMarker(m, x, y);
+ }
+ }
+
// Dual map scale bars
if (metersToPx <= 0.0f)
return;
@@ -588,6 +740,25 @@ void InkHUD::MapApplet::onRender(bool full)
void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
{
+ if (focusedWaypointId != 0) {
+ const StoredWaypoint *entry = waypointStore.findWaypoint(focusedWaypointId);
+ if (entry && !WaypointStore::isExpired(*entry) && waypointHasMapGeometry(entry->waypoint)) {
+ *lat = waypointHasAnchor(entry->waypoint)
+ ? entry->waypoint.latitude_i * 1e-7f
+ : ((float)entry->waypoint.bounding_box.latitude_south_i + entry->waypoint.bounding_box.latitude_north_i) *
+ 0.5e-7f;
+ *lng = waypointHasAnchor(entry->waypoint)
+ ? entry->waypoint.longitude_i * 1e-7f
+ : ((float)entry->waypoint.bounding_box.longitude_west_i + entry->waypoint.bounding_box.longitude_east_i) *
+ 0.5e-7f;
+ latCenter = *lat;
+ lngCenter = *lng;
+ centerIsOurNode = false;
+ return;
+ }
+ focusedWaypointId = 0;
+ }
+
// If we have a valid position for our own node, use that as the anchor
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
meshtastic_PositionLite ourSelfPos;
@@ -646,6 +817,34 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
positionCount++;
}
+ for (const StoredWaypoint &entry : waypointStore.getWaypoints()) {
+ if (WaypointStore::isExpired(entry))
+ continue;
+ if (!waypointHasMapGeometry(entry.waypoint))
+ continue;
+
+ const float latDeg =
+ waypointHasAnchor(entry.waypoint)
+ ? (entry.waypoint.latitude_i * 1e-7f)
+ : ((float)entry.waypoint.bounding_box.latitude_south_i + entry.waypoint.bounding_box.latitude_north_i) *
+ 0.5e-7f;
+ const float lngDeg =
+ waypointHasAnchor(entry.waypoint)
+ ? (entry.waypoint.longitude_i * 1e-7f)
+ : ((float)entry.waypoint.bounding_box.longitude_west_i + entry.waypoint.bounding_box.longitude_east_i) *
+ 0.5e-7f;
+ float latRad = latDeg * DEG_TO_RAD;
+ float lngRad = lngDeg * DEG_TO_RAD;
+ float x = cos(latRad) * cos(lngRad);
+ float y = cos(latRad) * sin(lngRad);
+ float z = sin(latRad);
+
+ xAvg += x;
+ yAvg += y;
+ zAvg += z;
+ positionCount++;
+ }
+
// All NodeDB processed, find mean values
if (positionCount == 0)
return;
@@ -759,15 +958,24 @@ void InkHUD::MapApplet::getMapCenter(float *lat, float *lng)
float latNode = pos.latitude_i * 1e-7;
float lngNode = pos.longitude_i * 1e-7;
- northernmost = max(northernmost, latNode);
- southernmost = min(southernmost, latNode);
+ includeMapPoint(latNode, lngNode, lngCenter, northernmost, southernmost, easternmost, westernmost);
+ }
- float degEastward = fmod(((lngNode - lngCenter) + 360), 360); // Degrees east from center to node
- float degWestward = abs(fmod(((lngNode - lngCenter) - 360), 360)); // Degrees west from center to node
- if (degEastward < degWestward)
- easternmost = max(easternmost, lngCenter + degEastward);
- else
- westernmost = min(westernmost, lngCenter - degWestward);
+ for (const StoredWaypoint &entry : waypointStore.getWaypoints()) {
+ if (WaypointStore::isExpired(entry))
+ continue;
+ if (waypointHasAnchor(entry.waypoint))
+ includeMapPoint(entry.waypoint.latitude_i * 1e-7f, entry.waypoint.longitude_i * 1e-7f, lngCenter, northernmost,
+ southernmost, easternmost, westernmost);
+
+ if (entry.waypoint.has_bounding_box) {
+ includeMapPoint(entry.waypoint.bounding_box.latitude_south_i * 1e-7f,
+ entry.waypoint.bounding_box.longitude_west_i * 1e-7f, lngCenter, northernmost, southernmost,
+ easternmost, westernmost);
+ includeMapPoint(entry.waypoint.bounding_box.latitude_north_i * 1e-7f,
+ entry.waypoint.bounding_box.longitude_east_i * 1e-7f, lngCenter, northernmost, southernmost,
+ easternmost, westernmost);
+ }
}
// Todo: check for issues with map spans >180 deg. MQTT only..
@@ -787,12 +995,48 @@ void InkHUD::MapApplet::getMapSize(uint32_t *widthMeters, uint32_t *heightMeters
*widthMeters = 0;
*heightMeters = 0;
+ if (focusedWaypointId != 0) {
+ for (const WaypointMarker &m : waypointMarkers) {
+ if (m.id != focusedWaypointId)
+ continue;
+ if (m.hasMarker && m.geofenceRadiusMeters > 0) {
+ *widthMeters = m.geofenceRadiusMeters * 2;
+ *heightMeters = m.geofenceRadiusMeters * 2;
+ }
+ if (m.hasBoundingBox) {
+ *widthMeters = max(*widthMeters, (uint32_t)std::max(fabsf(m.boxWestMeters), fabsf(m.boxEastMeters)) * 2);
+ *heightMeters = max(*heightMeters, (uint32_t)std::max(fabsf(m.boxSouthMeters), fabsf(m.boxNorthMeters)) * 2);
+ }
+ *widthMeters *= 1.1;
+ *heightMeters *= 1.1;
+ return;
+ }
+ }
+
// Find the greatest distance horizontally and vertically from map center
for (Marker m : markers) {
*widthMeters = max(*widthMeters, (uint32_t)abs(m.eastMeters) * 2);
*heightMeters = max(*heightMeters, (uint32_t)abs(m.northMeters) * 2);
}
+ // Waypoints contribute to the fit-all bounding box just like nodes, including geofence extents.
+ for (const WaypointMarker &m : waypointMarkers) {
+ if (m.hasMarker) {
+ *widthMeters = max(*widthMeters, (uint32_t)fabsf(m.eastMeters) * 2);
+ *heightMeters = max(*heightMeters, (uint32_t)fabsf(m.northMeters) * 2);
+ }
+
+ if (m.hasMarker && m.geofenceRadiusMeters > 0) {
+ *widthMeters = max(*widthMeters, (uint32_t)(fabsf(m.eastMeters) + m.geofenceRadiusMeters) * 2);
+ *heightMeters = max(*heightMeters, (uint32_t)(fabsf(m.northMeters) + m.geofenceRadiusMeters) * 2);
+ }
+
+ if (m.hasBoundingBox) {
+ *widthMeters = max(*widthMeters, (uint32_t)std::max(fabsf(m.boxWestMeters), fabsf(m.boxEastMeters)) * 2);
+ *heightMeters = max(*heightMeters, (uint32_t)std::max(fabsf(m.boxSouthMeters), fabsf(m.boxNorthMeters)) * 2);
+ }
+ }
+
// Add padding
*widthMeters *= 1.1;
*heightMeters *= 1.1;
@@ -928,6 +1172,13 @@ bool InkHUD::MapApplet::enoughMarkers()
if (nodeDB->hasValidPosition(node) && shouldDrawNode(node))
return true;
}
+
+ // Any live waypoint with a marker or box is enough to justify showing the map.
+ for (const StoredWaypoint &entry : waypointStore.getWaypoints()) {
+ if (!WaypointStore::isExpired(entry) && waypointHasMapGeometry(entry.waypoint))
+ return true;
+ }
+
return false;
}
@@ -937,6 +1188,8 @@ void InkHUD::MapApplet::calculateAllMarkers()
{
// Clear old markers
markers.clear();
+ waypointMarkers.clear();
+ waypointMarkers.reserve(waypointStore.getWaypoints().size());
// For each node in db
for (uint32_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
@@ -965,6 +1218,37 @@ void InkHUD::MapApplet::calculateAllMarkers()
markers.push_back(calculateMarker(pos.latitude_i * 1e-7, pos.longitude_i * 1e-7, node->hops_away));
}
+
+ // Cache waypoint markers once per render pass to avoid repeated geo math below.
+ for (const StoredWaypoint &entry : waypointStore.getWaypoints()) {
+ if (WaypointStore::isExpired(entry))
+ continue;
+ if (!waypointHasMapGeometry(entry.waypoint))
+ continue;
+
+ WaypointMarker marker;
+ marker.id = entry.waypoint.id;
+ marker.icon = entry.waypoint.icon;
+ marker.geofenceRadiusMeters = entry.waypoint.geofence_radius;
+ marker.hasMarker = waypointHasAnchor(entry.waypoint);
+ marker.hasBoundingBox = entry.waypoint.has_bounding_box;
+ if (marker.hasMarker) {
+ Marker base = calculateMarker(entry.waypoint.latitude_i * 1e-7, entry.waypoint.longitude_i * 1e-7, 0);
+ marker.eastMeters = base.eastMeters;
+ marker.northMeters = base.northMeters;
+ }
+ if (entry.waypoint.has_bounding_box) {
+ Marker southWest = calculateMarker(entry.waypoint.bounding_box.latitude_south_i * 1e-7,
+ entry.waypoint.bounding_box.longitude_west_i * 1e-7, 0);
+ Marker northEast = calculateMarker(entry.waypoint.bounding_box.latitude_north_i * 1e-7,
+ entry.waypoint.bounding_box.longitude_east_i * 1e-7, 0);
+ marker.boxWestMeters = southWest.eastMeters;
+ marker.boxSouthMeters = southWest.northMeters;
+ marker.boxEastMeters = northEast.eastMeters;
+ marker.boxNorthMeters = northEast.northMeters;
+ }
+ waypointMarkers.push_back(marker);
+ }
}
void InkHUD::MapApplet::calculateMapScale()
diff --git a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h
index b447273df9..fa90c0c5dd 100644
--- a/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h
+++ b/src/graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h
@@ -16,7 +16,9 @@ The base applet doesn't handle any events; this is left to the derived applets.
#include "configuration.h"
#include
+#include
+#include "WaypointStore.h"
#include "graphics/niche/InkHUD/Applet.h"
#include "GPSStatus.h"
@@ -41,6 +43,7 @@ class MapApplet : public Applet
void zoomIn();
void zoomOut();
void resetZoom();
+ bool focusWaypoint(uint32_t waypointId);
bool isZoomLocked() const { return s_zoomLocked; }
bool canZoomIn() const;
bool canZoomOut() const;
@@ -57,6 +60,9 @@ class MapApplet : public Applet
int onGpsStatusUpdate(const meshtastic::Status *status);
CallbackObserver gpsStatusObserver =
CallbackObserver(this, &MapApplet::onGpsStatusUpdate);
+ int onWaypointStoreChanged(const WaypointStore *store);
+ CallbackObserver waypointStoreObserver =
+ CallbackObserver(this, &MapApplet::onWaypointStoreChanged);
static bool s_zoomLocked;
static int s_lockedZoom;
@@ -69,6 +75,24 @@ class MapApplet : public Applet
uint8_t hopsAway = 0; // Determines marker size
};
+ struct WaypointMarker {
+ float eastMeters = 0;
+ float northMeters = 0;
+ uint32_t id = 0;
+ uint32_t icon = 0;
+ uint32_t geofenceRadiusMeters = 0;
+ bool hasMarker = false;
+ bool hasBoundingBox = false;
+ float boxWestMeters = 0;
+ float boxEastMeters = 0;
+ float boxSouthMeters = 0;
+ float boxNorthMeters = 0;
+ };
+
+ bool mapWaypointIconGlyph(uint32_t codepoint, std::string &glyph);
+ uint8_t fallbackBadgeNumber(const WaypointMarker &entry);
+ void drawWaypointFallbackMarker(const WaypointMarker &entry, int16_t x, int16_t y);
+
Marker calculateMarker(float lat, float lng, uint8_t hopsAway);
void calculateAllMarkers();
void calculateMapScale(); // Conversion factor for meters to pixels
@@ -81,10 +105,12 @@ class MapApplet : public Applet
bool centerIsOurNode = false; // True if map is centered on our own position (GPS or phone)
std::list markers;
+ std::vector waypointMarkers;
uint32_t widthMeters = 0; // Map width: meters
uint32_t heightMeters = 0; // Map height: meters
+ uint32_t focusedWaypointId = 0;
};
} // namespace NicheGraphics::InkHUD
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp
index 005327baea..de4e2ee85e 100644
--- a/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/System/AppSwitcher/AppSwitcherApplet.cpp
@@ -205,6 +205,8 @@ IconKind iconKindForAppletName(const char *name)
return IconKind::CHANNEL;
if (lower.find("position") != std::string::npos)
return IconKind::POSITIONS;
+ if (lower.find("waypoint") != std::string::npos)
+ return IconKind::POSITIONS;
if (lower.find("recent") != std::string::npos)
return IconKind::RECENTS;
if (lower.find("heard") != std::string::npos)
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h
index 3b7606e123..b2a5f5ee67 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h
@@ -141,6 +141,12 @@ enum MenuAction {
MAP_ZOOM_IN,
MAP_ZOOM_OUT,
MAP_ZOOM_RESET,
+ // Waypoints (WaypointListApplet)
+ REMOVE_WAYPOINT,
+ SELECT_GEOFENCE_WAYPOINT,
+ TOGGLE_GEOFENCE_ENTER,
+ TOGGLE_GEOFENCE_EXIT,
+ TOGGLE_GEOFENCE_FAVORITES_ONLY,
};
} // namespace NicheGraphics::InkHUD
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
index 863c1e85d4..6de9c48a5c 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
@@ -9,12 +9,16 @@
#include "MessageStore.h"
#include "Power.h"
#include "Router.h"
+#include "UptimeClock.h"
#include "airtime.h"
#include "gps/RTC.h"
#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h"
+#include "graphics/niche/InkHUD/Applets/User/Waypoints/WaypointListApplet.h"
#include "graphics/niche/Utils/FlashData.h"
#include "main.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
+#include "modules/GeofenceModule.h"
+#include "modules/WaypointModule.h"
#include
#include
#if defined(ARCH_ESP32) && HAS_WIFI
@@ -346,7 +350,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
service->reloadConfig(changes);
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
}
static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role)
@@ -363,7 +367,7 @@ static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role)
// Notify UI that changes are being applied
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
}
static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset)
@@ -380,7 +384,7 @@ static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset)
// Notify UI that changes are being applied
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
}
static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = false)
@@ -390,7 +394,7 @@ static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = f
if (reboot) {
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
}
}
@@ -569,7 +573,7 @@ void InkHUD::MenuApplet::execute(MenuItem item)
case SHUTDOWN:
LOG_INFO("Shutting down from menu");
- shutdownAtMsec = millis();
+ shutdownAtMsec = Time::skipZero(Time::getMillis());
// Menu is then sent to background via onShutdown
break;
@@ -672,7 +676,7 @@ void InkHUD::MenuApplet::execute(MenuItem item)
config.bluetooth.enabled = true;
nodeDB->saveToDisk(SEGMENT_CONFIG);
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
- rebootAtMsec = millis() + 2000;
+ rebootAtMsec = Time::timerEndsAtMillis(2000);
break;
// Power / Network (ESP32-only)
@@ -681,7 +685,7 @@ void InkHUD::MenuApplet::execute(MenuItem item)
config.power.is_power_saving = !config.power.is_power_saving;
nodeDB->saveToDisk(SEGMENT_CONFIG);
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
break;
case TOGGLE_WIFI:
@@ -694,7 +698,7 @@ void InkHUD::MenuApplet::execute(MenuItem item)
nodeDB->saveToDisk(SEGMENT_CONFIG);
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
break;
#endif
// ADC Calibration
@@ -772,7 +776,7 @@ void InkHUD::MenuApplet::execute(MenuItem item)
nodeDB->saveToDisk(SEGMENT_CONFIG);
InkHUD::InkHUD::getInstance()->notifyApplyingChanges();
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
break;
case TOGGLE_BLUETOOTH_PAIR_MODE:
@@ -1120,13 +1124,13 @@ void InkHUD::MenuApplet::execute(MenuItem item)
case RESET_NODEDB_ALL:
InkHUD::getInstance()->notifyApplyingChanges();
nodeDB->resetNodes();
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
break;
case RESET_NODEDB_KEEP_FAVORITES:
InkHUD::getInstance()->notifyApplyingChanges();
nodeDB->resetNodes(1);
- rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
+ rebootAtMsec = Time::timerEndsAtMillis(DEFAULT_REBOOT_SECONDS * 1000);
break;
case WIPE_MESSAGES_ALL:
@@ -1157,6 +1161,35 @@ void InkHUD::MenuApplet::execute(MenuItem item)
break;
}
+ case REMOVE_WAYPOINT: {
+ // cursor - 2 because index 0 is "Back" and index 1 is the "Select to Remove" header
+ const size_t index = cursor - 2;
+ if (waypointModule && index < removeWaypointIds.size())
+ waypointModule->broadcastDelete(removeWaypointIds.at(index));
+ break;
+ }
+
+ case SELECT_GEOFENCE_WAYPOINT: {
+ const size_t index = cursor - 2;
+ if (index < geofenceWaypointIds.size())
+ selectedGeofenceWaypointId = geofenceWaypointIds.at(index);
+ break;
+ }
+
+ case TOGGLE_GEOFENCE_ENTER:
+ case TOGGLE_GEOFENCE_EXIT:
+ case TOGGLE_GEOFENCE_FAVORITES_ONLY: {
+ const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId);
+ if (!entry)
+ break;
+ const WaypointNotificationPreference preference =
+ item.action == TOGGLE_GEOFENCE_ENTER
+ ? WAYPOINT_NOTIFY_ENTER
+ : (item.action == TOGGLE_GEOFENCE_EXIT ? WAYPOINT_NOTIFY_EXIT : WAYPOINT_NOTIFY_FAVORITES_ONLY);
+ waypointStore.setNotificationPreference(selectedGeofenceWaypointId, preference, !entry->notificationEnabled(preference));
+ break;
+ }
+
default:
LOG_WARN("Action not implemented");
}
@@ -1173,6 +1206,8 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
items.clear();
items.shrink_to_fit();
nodeConfigLabels.clear();
+ removeWaypointIds.clear();
+ geofenceWaypointIds.clear();
switch (page) {
case ROOT:
@@ -1196,6 +1231,20 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
}
}
+ // Remove Waypoint - only when viewing the waypoint list applet
+ {
+ WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr;
+ if (waypointListApplet && waypointListApplet->waypointCount() > 0) {
+ items.push_back(MenuItem("Remove Waypoint", MenuPage::REMOVE_WAYPOINT_LIST));
+ for (const StoredWaypoint &entry : waypointStore.getWaypoints()) {
+ if (GeofenceModule::hasGeofence(entry.waypoint)) {
+ items.push_back(MenuItem("Geofence Alerts", MenuPage::GEOFENCE_WAYPOINT_LIST));
+ break;
+ }
+ }
+ }
+ }
+
items.push_back(MenuItem("Options", MenuPage::OPTIONS));
// items.push_back(MenuItem("Display Off", MenuPage::EXIT)); // TODO
items.push_back(MenuItem("Node Config", MenuPage::NODE_CONFIG));
@@ -1213,6 +1262,43 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
previousPage = MenuPage::SEND;
break;
+ case REMOVE_WAYPOINT_LIST:
+ previousPage = MenuPage::ROOT;
+ populateRemoveWaypointPage(); // must be first
+ items.insert(items.begin(), MenuItem::Header("Select to Remove"));
+ items.insert(items.begin(), MenuItem("Back", previousPage));
+ items.push_back(MenuItem("Exit", MenuPage::EXIT));
+ break;
+
+ case GEOFENCE_WAYPOINT_LIST:
+ previousPage = MenuPage::ROOT;
+ populateGeofenceWaypointPage();
+ items.insert(items.begin(), MenuItem::Header("Select Geofence"));
+ items.insert(items.begin(), MenuItem("Back", previousPage));
+ items.push_back(MenuItem("Exit", MenuPage::EXIT));
+ break;
+
+ case GEOFENCE_OPTIONS: {
+ previousPage = MenuPage::GEOFENCE_WAYPOINT_LIST;
+ items.push_back(MenuItem("Back", previousPage));
+ const StoredWaypoint *entry = waypointStore.findWaypoint(selectedGeofenceWaypointId);
+ if (!entry) {
+ items.push_back(MenuItem::Header("Geofence unavailable"));
+ break;
+ }
+ const std::string enterLabel =
+ std::string("Enter Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_ENTER) ? "On" : "Off");
+ const std::string exitLabel =
+ std::string("Exit Alerts: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_EXIT) ? "On" : "Off");
+ const std::string favoritesLabel =
+ std::string("Favorites Only: ") + (entry->notificationEnabled(WAYPOINT_NOTIFY_FAVORITES_ONLY) ? "On" : "Off");
+ items.push_back(MenuItem(enterLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_ENTER, MenuPage::GEOFENCE_OPTIONS));
+ items.push_back(MenuItem(exitLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_EXIT, MenuPage::GEOFENCE_OPTIONS));
+ items.push_back(MenuItem(favoritesLabel.c_str(), MenuAction::TOGGLE_GEOFENCE_FAVORITES_ONLY, MenuPage::GEOFENCE_OPTIONS));
+ items.push_back(MenuItem("Exit", MenuPage::EXIT));
+ break;
+ }
+
case OPTIONS:
previousPage = MenuPage::ROOT;
items.push_back(MenuItem("Back", previousPage));
@@ -2442,6 +2528,40 @@ void InkHUD::MenuApplet::populateRecipientPage()
items.push_back(MenuItem("Exit", MenuPage::EXIT));
}
+// Dynamically create MenuItem entries for each waypoint in the active WaypointListApplet
+void InkHUD::MenuApplet::populateRemoveWaypointPage()
+{
+ assert(items.size() == 0);
+
+ WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr;
+ if (waypointListApplet) {
+ for (size_t i = 0; i < waypointListApplet->waypointCount(); i++) {
+ removeWaypointIds.push_back(waypointListApplet->waypointIdAt(i));
+ items.push_back(MenuItem(waypointListApplet->waypointLabelAt(i).c_str(), MenuAction::REMOVE_WAYPOINT,
+ MenuPage::REMOVE_WAYPOINT_LIST));
+ }
+ }
+}
+
+void InkHUD::MenuApplet::populateGeofenceWaypointPage()
+{
+ assert(items.empty());
+
+ WaypointListApplet *waypointListApplet = borrowedTileOwner ? borrowedTileOwner->asWaypointListApplet() : nullptr;
+ if (!waypointListApplet)
+ return;
+
+ for (size_t i = 0; i < waypointListApplet->waypointCount(); ++i) {
+ const uint32_t id = waypointListApplet->waypointIdAt(i);
+ const StoredWaypoint *entry = waypointStore.findWaypoint(id);
+ if (!entry || !GeofenceModule::hasGeofence(entry->waypoint))
+ continue;
+ geofenceWaypointIds.push_back(id);
+ items.push_back(MenuItem(waypointListApplet->waypointLabelAt(i).c_str(), MenuAction::SELECT_GEOFENCE_WAYPOINT,
+ MenuPage::GEOFENCE_OPTIONS));
+ }
+}
+
void InkHUD::MenuApplet::drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height, const std::string &text)
{
setFont(fontSmall);
diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h
index 2d7fbd398b..28289203df 100644
--- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h
+++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.h
@@ -55,6 +55,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
void populateDisplayTimeoutPage(); // Create menu items for config.display.screen_on_secs
+ void populateRemoveWaypointPage(); // Create menu items: one per waypoint in the active WaypointListApplet
+ void populateGeofenceWaypointPage();
void drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height,
const std::string &text); // Draw input field for free text
@@ -74,7 +76,10 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
uint16_t systemInfoPanelHeight = 0; // Need to know before we render
uint16_t menuTextLimit = 200;
- std::vector