Merge branch 'develop' into develop

Resolves the conflicts in src/main.cpp and src/detect/ScanI2CTwoWire.cpp
in favour of develop.

Both files conflicted because an earlier clang-format run on this branch
did not find the repo style at .trunk/configs/.clang-format and fell back
to LLVM defaults, reformatting them whole (1285 lines in main.cpp alone).
That reformat is dropped here, along with the damage a previous merge had
done under cover of it: the AS3935 and QMC6309 probes had moved inside
the 8..119 address loop in scanPort(), and the tail of
KbI2cBase::runOnce() had gained a duplicated default label and return.

Also folded in while resolving, since the conflicting hunks are the same
ones:

- firstKeyboard() kept its count at 7 after growing the array to 8, which
  dropped STC8HKB.
- The kb_model 0x20 case in KbI2cBase::runOnce() had no break and fell
  into the ThinkNode-M9 keypad case.
- LONG_PRESS_THRESHOLD and MULTI_TAP_THRESHOLD sat inside the
  #ifndef CUSTOM_MCP23017_MAP block, so a custom keymap dropped them.
- held() indexed MCP23017_LongPressMap with an unchecked last_key.
- MCP23017_KeyMap had a 16th entry no caller can reach; the key arrays
  are now static const and sized off _NUM_KEYS.
- The WIRE1 path called begin(&Wire1) rather than the resolved i2cBus.
- Multi-tap and long-press timing moved to Throttle, as asked in review.
- MCP23017_KB_ADDR moved to configuration.h with the other keyboard
  addresses.

Detection is now gated on HAS_MCP23017_KEYBOARD and excluded from
USE_MCP23017 builds. The MCP23017 answers at 0x20, the same address as
the TCA9535, and no read-only probe separates them reliably; on a
USE_MCP23017 board the part is the radio's GPIO expander, and handing it
to the keyboard driver, which rewrites IODIR and GPPU, would take the
radio's control lines down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YA5bFZa3iVA6pfvGmRGXWS
This commit is contained in:
Claude committed 2026-09-16 02:49:05 +00:00
commit f24d387ae6
413 files changed
+16989 -7926

No files matched your search

+29
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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"
+47 -3
View File
@@ -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.
<a id="test-comments"></a>
#### 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
<a id="test-naming"></a>
#### 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 <n>` 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 <n>` 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.
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Meshtastic Nightly v%%VERSION%%</title>
<meta name="description" content="Nightly firmware builds from the tip of the Meshtastic develop branch.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -22.5 100 100'%3E%3Cg fill='rgb(103,234,148)' transform='matrix(0.802386,0,0,0.460028,-421.748,-122.127)'%3E%3Cpath transform='matrix(0.579082,0,0,1.01004,460.975,-39.6867)' d='M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z'/%3E%3Cpath transform='matrix(0.582378,0,0,1.01579,485.019,-211.182)' d='M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z'/%3E%3C/g%3E%3C/svg%3E">
<style>
:root {
--bg: #ffffff;
--panel: #fbfbfc;
--fg: #2c2d3c;
--muted: #6b6d80;
--rule: #e3e4ea;
--accent: #0f9d58;
--logo: #2c2d3c;
--notice-bg: #fff9e8;
--notice-rule: #e8b931;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1b1c26;
--panel: #23242f;
--fg: #e7e8ee;
--muted: #9a9cb0;
--rule: #32343f;
--accent: #67ea94;
--logo: #ffffff;
--notice-bg: #2b2618;
--notice-rule: #b9902a;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 2.5rem 1.25rem 4rem;
background: var(--bg);
color: var(--fg);
font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 62rem; margin: 0 auto; }
a { color: var(--accent); }
header { display: flex; align-items: center; gap: 0.85rem; }
header svg { width: 46px; height: auto; fill: var(--logo); flex: none; }
h1 {
margin: 0;
font-size: 1.6rem;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.15;
}
h1 .sub {
display: block;
font-size: 0.82rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
}
.lede { margin: 1.5rem 0 0; max-width: 46rem; color: var(--muted); }
code {
padding: 0.1em 0.35em;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 3px;
font: 0.88em ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
color: var(--fg);
}
dl.meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 1.25rem;
margin: 1.5rem 0 0;
font-size: 0.86rem;
}
dl.meta dt { color: var(--muted); }
dl.meta dd {
margin: 0;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
.notice {
margin: 1.75rem 0 0;
padding: 0.8rem 1rem;
background: var(--notice-bg);
border-left: 3px solid var(--notice-rule);
border-radius: 0 4px 4px 0;
font-size: 0.9rem;
}
h2 {
margin: 2.5rem 0 0.75rem;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.tree {
padding: 1rem 1.15rem;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
overflow-x: auto;
white-space: nowrap;
font: 12.5px/1.75 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
color: var(--muted);
}
.tree a { color: var(--fg); text-decoration: none; }
.tree a:hover { color: var(--accent); text-decoration: underline; }
footer {
margin: 2.5rem 0 0;
padding-top: 1.25rem;
border-top: 1px solid var(--rule);
font-size: 0.85rem;
color: var(--muted);
}
footer a { margin-right: 1.25rem; white-space: nowrap; }
</style>
</head>
<body>
<main>
<header>
<svg viewBox="0 0 100 55" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Meshtastic">
<g transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)">
<path transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)" d="M250.908,330.267L193.126,415.005L180.938,406.694L244.802,313.037C246.174,311.024 248.453,309.819 250.889,309.816C253.326,309.814 255.606,311.015 256.982,313.026L320.994,406.536L308.821,414.869L250.908,330.267Z"/>
<path transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)" d="M87.642,581.398L154.757,482.977L142.638,474.713L75.523,573.134L87.642,581.398Z"/>
</g>
</svg>
<h1>Meshtastic<span class="sub">Nightly Firmware</span></h1>
</header>
<p class="lede">
Firmware built straight from the tip of <code>develop</code>, published every night.
Grab a <code>.bin</code>, <code>.uf2</code> or <code>.hex</code> for your board below, or point the
<a href="https://flasher.meshtastic.org">Web Flasher</a> at the nightly channel and let it pick for you.
</p>
<dl class="meta">
<dt>Version</dt><dd>%%VERSION%%</dd>
<dt>Commit</dt><dd><a href="https://github.com/meshtastic/firmware/commit/%%COMMIT%%">%%COMMIT_SHORT%%</a></dd>
<dt>Built</dt><dd>%%BUILD_DATE%%</dd>
<dt>Build log</dt><dd><a href="%%RUN_URL%%">run %%RUN_ID%%</a></dd>
</dl>
<p class="notice">
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
<a href="./release_notes.md">release notes</a> &mdash; they cover what changed and how to report what you find.
</p>
<h2>Files</h2>
<div class="tree">
+11
View File
@@ -0,0 +1,11 @@
</div>
<footer>
<a href="https://meshtastic.org">meshtastic.org</a>
<a href="https://meshtastic.org/docs/getting-started/flashing-firmware/">Flashing guide</a>
<a href="https://github.com/meshtastic/firmware">Source</a>
<a href="https://github.com/meshtastic/firmware/issues">Report a bug</a>
</footer>
</main>
</body>
</html>
+161
View File
@@ -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 <date/commit>, 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).
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+137 -51
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+352 -202
View File
@@ -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/<env>/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 <testsuites> root; fold in a bare <testsuite> 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 <testsuites> root; fold in a bare <testsuite> 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 <testsuite tests="0"/> row for every test_* dir
# crossed with every hardware variant it cannot run on the native host (~4900 rows).
# They carry no pass/fail/skip status and bury the suites that actually ran. Strip
# them so the Test Report lists only suites with a real status. Only the copy the
# reporter renders is trimmed; the uploaded artifact keeps the full XML.
if: always()
run: sed -i -E 's#<testsuite [^>]*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_<chunk>.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_<shard>.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."
+25
View File
@@ -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: <reason>` 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<path>.+):(?P<line>\d+):(?P<col>\d+):(?P<severity>\w+):(?P<message>.+):(?P<code>[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-<div> 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
+2
View File
@@ -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.
+9 -7
View File
@@ -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<T>` 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<T>` 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.
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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"
+36
View File
@@ -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
+2 -1
View File
@@ -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
+6 -2
View File
@@ -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:
+18
View File
@@ -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
+11 -5
View File
@@ -65,14 +65,20 @@ state_fingerprint() {
# Scoped to this user's processes: /proc/<pid>/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
}
+443
View File
@@ -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: <reason>` 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
# <path>:<line>:<col>:<severity>:<message>:<code>
# 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 `<ident> = <clock>`
# and `<ident> = <already-tainted ident>`, 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 <class T>` 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 `<sentinel> =` 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
+66
View File
@@ -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 <Name>/ holding <Name>.h, which declares `void setup<Name>();`. 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))
+2 -2
View File
@@ -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
+16 -1
View File
@@ -2,7 +2,7 @@
<component type="desktop-application">
<id>org.meshtastic.meshtasticd</id>
<name>Meshtastic</name>
<name>MeshtasticD</name>
<summary>Decentralized mesh communication</summary>
<metadata_license>CC-BY-4.0</metadata_license>
@@ -13,6 +13,9 @@
</developer>
<description>
<p>
MeshtasticD is an app that allows you to use your Linux computer as a Meshtastic node (using a CH341 USB LoRa radio).
</p>
<p>
Meshtastic is an open source project for creating off-grid, affordable, and resilient communication with LoRa mesh networks.
</p>
@@ -87,6 +90,18 @@
</screenshots>
<releases>
<release version="2.8.1" date="2026-09-01">
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.8.1</url>
</release>
<release version="2.8.0" date="2026-06-24">
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.8.0</url>
</release>
<release version="2.7.26" date="2026-06-10">
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.26</url>
</release>
<release version="2.7.25" date="2026-05-23">
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.25</url>
</release>
<release version="2.7.24" date="2026-05-08">
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24</url>
</release>
+35 -2
View File
@@ -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_"):
+481
View File
@@ -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 <name> <expected lines, newline-separated or empty> <source>
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 <name> <expected lines> <source> - 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 <class T> on a function is not a class body" "" 'template <class T> 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
+267
View File
@@ -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 <name>`. 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:<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())
+37 -9
View File
@@ -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:-<none>}: $alive, listed by ps: $listed, its ${its_home:-<no HOME in environ>}"
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"
-26
View File
@@ -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"
}
-1
View File
@@ -15,7 +15,6 @@
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"f_boot": "120000000L",
"boot": "qio",
"flash_mode": "qio",
"psram_type": "opi",
+42
View File
@@ -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"
}
+40
View File
@@ -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"
}
+24 -8
View File
@@ -1,3 +1,27 @@
meshtasticd (2.8.1.0) unstable; urgency=medium
* Version 2.8.1
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Tue, 01 Sep 2026 10:59:38 +0000
meshtasticd (2.8.0.0) unstable; urgency=medium
* Version 2.8.0
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Wed, 24 Jun 2026 11:20:05 +0000
meshtasticd (2.7.26.0) unstable; urgency=medium
* Version 2.7.26
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Wed, 10 Jun 2026 00:19:23 +0000
meshtasticd (2.7.25.0) unstable; urgency=medium
* Version 2.7.25
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> 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 <github-actions[bot]@users.noreply.github.com> Wed, 01 Oct 2025 19:51:41 +0000
-54
View File
@@ -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.
+124
View File
@@ -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/<chip>/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)
+21 -1
View File
@@ -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)
-140
View File
@@ -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)
+8 -3
View File
@@ -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]
+23 -15
View File
@@ -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<AudioFileSourcePROGMEM>(new AudioFileSourcePROGMEM(data, len));
i2sRtttl = std::unique_ptr<AudioGeneratorRTTTL>(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<ESP8266SAM>(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<AudioOutputI2S>(new AudioOutputI2S(1, AudioOutputI2S::EXTERNAL_I2S));
+14 -27
View File
@@ -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 <cstdlib>
#include <cstring>
#include <vector>
#ifdef ARCH_ESP32
#include <esp_heap_caps.h>
#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<meshtastic_FileInfo> 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<volatile char *>(probe) = 0;
free(probe);
break;
}
reservedCount /= 2;
}
#endif
if (reservedCount == 0) {
if (wasLimited)
*wasLimited = true;
-8
View File
@@ -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.
+1
View File
@@ -81,6 +81,7 @@ template <class T> class Observable
// Not called directly, instead call observer.observe
void addObserver(Observer<T> *o) { observers.push_back(o); }
// cppcheck-suppress constParameterPointer ; std::list::remove() needs a non-const pointer
void removeObserver(Observer<T> *o) { observers.remove(o); }
};
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include <Wire.h>
// 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;
}
};
+226 -40
View File
@@ -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 <Adafruit_ADS1X15.h>
#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 <AW35615.h>
/**
* @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<uint16_t>(v);
initial_read_done = true;
} else {
// Exponential moving average filter (50% smoothing)
cached_mv = static_cast<uint16_t>(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
}
+11 -1
View File
@@ -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(<Adafruit_INA219.h>)
@@ -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();
+29 -24
View File
@@ -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,
-4
View File
@@ -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
}
}
+31
View File
@@ -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()
+400
View File
@@ -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 <cstring>
#include <pb_decode.h>
#include <pb_encode.h>
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<const uint8_t *>(&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<StoredWaypoint>().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<char *>(&version), 1);
f.readBytes(reinterpret_cast<char *>(&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<char *>(&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<StoredWaypoint>().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
+75
View File
@@ -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 <cstdint>
#include <deque>
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<const WaypointStore *>
{
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<StoredWaypoint> &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<StoredWaypoint> waypoints;
};
#if ENABLE_WAYPOINT_PERSISTENCE
void waypointStoreAutosaveTick();
#endif
extern WaypointStore waypointStore;
#endif
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstdint>
#include <string>
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<char>(codepoint);
return std::string(buf, 1);
}
if (codepoint <= 0x7FF) {
buf[0] = static_cast<char>(0xC0 | (codepoint >> 6));
buf[1] = static_cast<char>(0x80 | (codepoint & 0x3F));
return std::string(buf, 2);
}
if (codepoint <= 0xFFFF) {
buf[0] = static_cast<char>(0xE0 | (codepoint >> 12));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[2] = static_cast<char>(0x80 | (codepoint & 0x3F));
return std::string(buf, 3);
}
buf[0] = static_cast<char>(0xF0 | (codepoint >> 18));
buf[1] = static_cast<char>(0x80 | ((codepoint >> 12) & 0x3F));
buf[2] = static_cast<char>(0x80 | ((codepoint >> 6) & 0x3F));
buf[3] = static_cast<char>(0x80 | (codepoint & 0x3F));
return std::string(buf, 4);
}
} // namespace WaypointUtils
+55 -11
View File
@@ -2,7 +2,9 @@
#include "NodeDB.h"
#include "UptimeClock.h"
#include "configuration.h"
#include <algorithm>
#include <assert.h>
#include <cmath>
#include <string.h>
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<uint32_t>(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);
+21 -1
View File
@@ -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 &);
+1
View File
@@ -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;
}
+59 -11
View File
@@ -29,18 +29,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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(<PCF8xRTC.h>)
#include <PCF8xRTC.h>
#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 <http://www.gnu.org/licenses/>.
// 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 <http://www.gnu.org/licenses/>.
// -----------------------------------------------------------------------------
#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 <http://www.gnu.org/licenses/>.
// -----------------------------------------------------------------------------
// 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 <http://www.gnu.org/licenses/>.
#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 <http://www.gnu.org/licenses/>.
#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
+2 -2
View File
@@ -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
+7 -2
View File
@@ -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;
+133 -87
View File
@@ -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(<SensorQMC6309.hpp>)
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)
+1 -1
View File
@@ -12,7 +12,7 @@
#include <freertos/task.h>
#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 <FreeRTOS.h>
+4 -4
View File
@@ -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) {
+13
View File
@@ -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()
{
+4 -1
View File
@@ -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".
};
};
+31 -29
View File
@@ -8,6 +8,7 @@
#include "mesh/MeshService.h"
#include "mesh/NodeDB.h"
#include "modules/NodeInfoModule.h"
#include "modules/WaypointModule.h"
#include <Throttle.h>
#include <sys/time.h>
#include <time.h>
@@ -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);
}
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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))
+5 -4
View File
@@ -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;
+71 -20
View File
@@ -24,6 +24,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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<uint8_t>(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;
+31
View File
@@ -5,6 +5,7 @@
#include "detect/ScanI2C.h"
#include "mesh/generated/meshtastic/config.pb.h"
#include <OLEDDisplay.h>
#include <atomic>
#include <functional>
#include <memory>
#include <string>
@@ -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<bool> textMessageFrameShown{false};
/// Track USB power state to only wake screen on actual power state changes
bool lastPowerUSBState = false;
+13 -6
View File
@@ -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;
}
+179 -1
View File
@@ -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 <LovyanGFX.hpp>
// 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 <LovyanGFX.hpp> // 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
+214 -11
View File
@@ -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 <algorithm>
#include <array>
#include <cmath>
@@ -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 <typename T, size_t N, typename Callback>
BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOption<T> (&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;
+13
View File
@@ -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();
+9 -24
View File
@@ -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<const char *>(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);
}
}
+7 -3
View File
@@ -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
// =============================
+1
View File
@@ -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);
+17 -3
View File
@@ -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<uint8_t>(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<uint8_t>(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;
+7 -4
View File
@@ -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) {
+6 -1
View File
@@ -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
+4
View File
@@ -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
+1 -1
View File
@@ -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);
+1
View File
@@ -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
@@ -2,6 +2,8 @@
#include "./MapApplet.h"
#include "./MapTile.h"
#include "WaypointStore.h"
#include "WaypointUtils.h"
#include <math.h>
#include <string.h>
@@ -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<int16_t>(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<int16_t>(1, right - left + 1), std::max<int16_t>(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()
@@ -16,7 +16,9 @@ The base applet doesn't handle any events; this is left to the derived applets.
#include "configuration.h"
#include <list>
#include <vector>
#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<MapApplet, const meshtastic::Status *> gpsStatusObserver =
CallbackObserver<MapApplet, const meshtastic::Status *>(this, &MapApplet::onGpsStatusUpdate);
int onWaypointStoreChanged(const WaypointStore *store);
CallbackObserver<MapApplet, const WaypointStore *> waypointStoreObserver =
CallbackObserver<MapApplet, const WaypointStore *>(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<Marker> markers;
std::vector<WaypointMarker> waypointMarkers;
uint32_t widthMeters = 0; // Map width: meters
uint32_t heightMeters = 0; // Map height: meters
uint32_t focusedWaypointId = 0;
};
} // namespace NicheGraphics::InkHUD
#endif
#endif
@@ -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)
@@ -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
@@ -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 <RadioLibInterface.h>
#include <target_specific.h>
#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);
@@ -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<MenuItem> items; // MenuItems for the current page. Filled by ShowPage
std::vector<MenuItem> items; // MenuItems for the current page. Filled by ShowPage
std::vector<uint32_t> removeWaypointIds; // Parallel to items, for REMOVE_WAYPOINT page
std::vector<uint32_t> geofenceWaypointIds;
uint32_t selectedGeofenceWaypointId = 0;
std::vector<std::string> nodeConfigLabels; // Persistent labels for Node Config pages
uint8_t selectedChannelIndex = 0; // Currently selected LoRa channel (Node Config → Radio → Channel)
bool channelPositionEnabled = false;
@@ -19,6 +19,9 @@ enum MenuPage : uint8_t {
ROOT, // Initial menu page
SEND,
CANNEDMESSAGE_RECIPIENT, // Select destination for a canned message
REMOVE_WAYPOINT_LIST, // Pick a waypoint to delete (WaypointListApplet only)
GEOFENCE_WAYPOINT_LIST,
GEOFENCE_OPTIONS,
OPTIONS,
NODE_CONFIG,
NODE_CONFIG_LORA,
@@ -19,7 +19,12 @@ namespace NicheGraphics::InkHUD
class Notification
{
public:
enum Type : uint8_t { NOTIFICATION_MESSAGE_BROADCAST, NOTIFICATION_MESSAGE_DIRECT, NOTIFICATION_BATTERY } type;
enum Type : uint8_t {
NOTIFICATION_MESSAGE_BROADCAST,
NOTIFICATION_MESSAGE_DIRECT,
NOTIFICATION_BATTERY,
NOTIFICATION_GEOFENCE
} type;
uint32_t timestamp;
@@ -33,8 +38,12 @@ class Notification
uint8_t channel;
uint32_t sender;
uint8_t batteryPercentage;
char geofenceNodeName[sizeof(meshtastic_User::long_name)];
char geofenceName[sizeof(meshtastic_Waypoint::name)];
uint32_t geofenceWaypointId;
bool geofenceEntered;
};
} // namespace NicheGraphics::InkHUD
#endif
#endif
@@ -4,7 +4,12 @@
#include "./Notification.h"
#include "MessageStore.h"
#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h"
#include "graphics/niche/InkHUD/Persistence.h"
#if !MESHTASTIC_EXCLUDE_WAYPOINT
#include "modules/GeofenceModule.h"
#include <cstring>
#endif
#include "meshUtils.h"
#include "modules/TextMessageModule.h"
@@ -16,6 +21,27 @@ using namespace NicheGraphics;
InkHUD::NotificationApplet::NotificationApplet()
{
textMessageObserver.observe(textMessageModule);
#if !MESHTASTIC_EXCLUDE_WAYPOINT
if (geofenceModule)
geofenceObserver.observe(geofenceModule);
#endif
}
void InkHUD::NotificationApplet::showNotification(const Notification &n)
{
assert(isActive());
if (!settings->optionalFeatures.notifications)
return;
dismiss();
hasNotification = true;
currentNotification = n;
if (isApproved()) {
bringToForeground();
inkhud->forceUpdate();
} else
hasNotification = false;
}
// Collect meta-info about the text message, and ask for approval for the notification
@@ -25,11 +51,6 @@ int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket
// System applets are always active
assert(isActive());
// Abort if feature disabled
// This is a bit clumsy, but avoids complicated handling when the feature is enabled / disabled
if (!settings->optionalFeatures.notifications)
return 0;
// Abort if this is an outgoing message
if (getFrom(p) == nodeDB->getNodeNum())
return 0;
@@ -49,23 +70,35 @@ int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket
n.sender = p->from;
}
// Close an old notification, if shown
dismiss();
// Check if we should display the notification
// A foreground applet might already be displaying this info
hasNotification = true;
currentNotification = n;
if (isApproved()) {
bringToForeground();
inkhud->forceUpdate();
} else
hasNotification = false; // Clear the pending notification: it was rejected
showNotification(n);
// Return zero: no issues here, carry on notifying other observers!
return 0;
}
#if !MESHTASTIC_EXCLUDE_WAYPOINT
int InkHUD::NotificationApplet::onGeofenceEvent(const GeofenceNotificationEvent *event)
{
assert(isActive());
if (!event)
return 0;
Notification n;
n.type = Notification::Type::NOTIFICATION_GEOFENCE;
n.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true);
n.geofenceWaypointId = event->waypointId;
strncpy(n.geofenceName, event->geofenceName, sizeof(n.geofenceName) - 1);
n.geofenceName[sizeof(n.geofenceName) - 1] = '\0';
strncpy(n.geofenceNodeName, event->nodeName, sizeof(n.geofenceNodeName) - 1);
n.geofenceNodeName[sizeof(n.geofenceNodeName) - 1] = '\0';
n.geofenceEntered = event->entered;
showNotification(n);
return 0;
}
#endif
void InkHUD::NotificationApplet::onRender(bool full)
{
// Clear the region beneath the tile
@@ -145,7 +178,10 @@ void InkHUD::NotificationApplet::onBackground()
void InkHUD::NotificationApplet::onButtonShortPress()
{
dismiss();
if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE)
openGeofenceOnMap();
else
dismiss();
}
void InkHUD::NotificationApplet::onButtonLongPress()
@@ -180,6 +216,25 @@ void InkHUD::NotificationApplet::onNavLeft()
void InkHUD::NotificationApplet::onNavRight()
{
if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE)
openGeofenceOnMap();
else
dismiss();
}
void InkHUD::NotificationApplet::openGeofenceOnMap()
{
for (uint8_t i = 0; i < inkhud->userApplets.size(); ++i) {
Applet *applet = inkhud->userApplets.at(i);
MapApplet *map = applet ? applet->asMapApplet() : nullptr;
if (!map || !applet->isActive() || !map->focusWaypoint(currentNotification.geofenceWaypointId))
continue;
dismiss();
inkhud->showApplet(i);
return;
}
dismiss();
}
@@ -267,6 +322,12 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
}
}
else if (currentNotification.type == Notification::Type::NOTIFICATION_GEOFENCE) {
text += currentNotification.geofenceNodeName;
text += currentNotification.geofenceEntered ? " IN " : " OUT ";
text += currentNotification.geofenceName;
}
// Parse any non-ascii characters and return
return parse(text);
}
@@ -15,8 +15,10 @@ Feature should be optional; enable disable via on-screen menu
#include "configuration.h"
#include "concurrency/OSThread.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
#if !MESHTASTIC_EXCLUDE_WAYPOINT
struct GeofenceNotificationEvent;
#endif
namespace NicheGraphics::InkHUD
{
@@ -39,6 +41,9 @@ class NotificationApplet : public SystemApplet
void onNavRight() override;
int onReceiveTextMessage(const meshtastic_MeshPacket *p);
#if !MESHTASTIC_EXCLUDE_WAYPOINT
int onGeofenceEvent(const GeofenceNotificationEvent *event);
#endif
bool isApproved(); // Does a foreground applet make notification redundant?
void dismiss(); // Close the Notification Popup
@@ -47,13 +52,19 @@ class NotificationApplet : public SystemApplet
// Get notified when a new text message arrives
CallbackObserver<NotificationApplet, const meshtastic_MeshPacket *> textMessageObserver =
CallbackObserver<NotificationApplet, const meshtastic_MeshPacket *>(this, &NotificationApplet::onReceiveTextMessage);
#if !MESHTASTIC_EXCLUDE_WAYPOINT
CallbackObserver<NotificationApplet, const GeofenceNotificationEvent *> geofenceObserver =
CallbackObserver<NotificationApplet, const GeofenceNotificationEvent *>(this, &NotificationApplet::onGeofenceEvent);
#endif
void showNotification(const Notification &n);
void openGeofenceOnMap();
std::string getNotificationText(uint16_t widthAvailable); // Get text for notification, to suit screen width
bool hasNotification = false; // Only used for assert. Todo: remove?
bool hasNotification = false;
Notification currentNotification = Notification(); // Set when something notification-worthy happens. Used by render()
};
} // namespace NicheGraphics::InkHUD
#endif
#endif
@@ -42,4 +42,4 @@ class PositionsApplet : public MapApplet, public SinglePortModule
} // namespace NicheGraphics::InkHUD
#endif
#endif
@@ -0,0 +1,572 @@
#if defined(MESHTASTIC_INCLUDE_INKHUD)
#include "./WaypointListApplet.h"
#include "GeoCoord.h"
#include "NodeDB.h"
#include "RTC.h"
#include "WaypointStore.h"
#include "WaypointUtils.h"
#include "modules/WaypointModule.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
using namespace NicheGraphics;
namespace
{
uint32_t fnv1aAppend(uint32_t hash, const char *text)
{
while (*text) {
hash ^= (uint8_t)*text++;
hash *= 16777619u;
}
return hash;
}
} // namespace
InkHUD::WaypointListApplet::WaypointListApplet() : concurrency::OSThread("WaypointListApplet")
{
OSThread::disable();
}
void InkHUD::WaypointListApplet::onActivate()
{
setInputsSubscribed(NAV_UP | NAV_DOWN, true);
scrollOffset = 0;
hasRenderHash = false;
waypointStoreObserver.observe(&waypointStore);
waypointStore.purgeExpired();
updateRefreshTimer();
}
void InkHUD::WaypointListApplet::onDeactivate()
{
waypointStoreObserver.unobserve(&waypointStore);
setInputsSubscribed(NAV_UP | NAV_DOWN, false);
OSThread::disable();
}
int32_t InkHUD::WaypointListApplet::runOnce()
{
bool needsUpdate = false;
if (isActive())
waypointStore.purgeExpired();
if (isActive() && !waypointStore.getWaypoints().empty()) {
const uint32_t renderHash = buildRenderHash();
if (!hasRenderHash || renderHash != lastRenderHash) {
lastRenderHash = renderHash;
hasRenderHash = true;
needsUpdate = true;
}
updateRefreshTimer();
}
if (isActive() && needsUpdate)
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
return OSThread::interval;
}
void InkHUD::WaypointListApplet::updateRefreshTimer()
{
if (waypointStore.getWaypoints().empty()) {
OSThread::disable();
return;
}
OSThread::enabled = true;
OSThread::setIntervalFromNow(nextRefreshIntervalMs());
}
bool InkHUD::WaypointListApplet::hasDescription(const meshtastic_Waypoint &waypoint)
{
return waypoint.description[0] != '\0';
}
uint8_t InkHUD::WaypointListApplet::rowHeight(const meshtastic_Waypoint &waypoint)
{
const uint8_t lines = hasDescription(waypoint) ? 3 : 2;
return (fontSmall.lineHeight() * lines) + lines;
}
uint8_t InkHUD::WaypointListApplet::visibleRows(uint8_t start)
{
const auto &waypoints = waypointStore.getWaypoints();
const int16_t contentTop = getHeaderHeight() + 2;
const uint16_t availableH = (height() > contentTop) ? (height() - contentTop) : 1;
if (waypoints.empty() || start >= waypoints.size())
return 0;
uint16_t usedH = 0;
uint8_t count = 0;
for (uint8_t i = start; i < waypoints.size(); ++i) {
const uint8_t nextH = rowHeight(waypoints.at(i).waypoint);
if (count > 0 && usedH + nextH > availableH)
break;
usedH += nextH;
++count;
if (usedH >= availableH)
break;
}
return count;
}
uint8_t InkHUD::WaypointListApplet::maxScrollOffset()
{
const auto &waypoints = waypointStore.getWaypoints();
if (waypoints.empty())
return 0;
const int16_t contentTop = getHeaderHeight() + 2;
const uint16_t availableH = (height() > contentTop) ? (height() - contentTop) : 1;
uint16_t usedH = 0;
uint8_t start = (uint8_t)waypoints.size();
while (start > 0) {
const uint8_t nextH = rowHeight(waypoints.at(start - 1).waypoint);
if (usedH > 0 && usedH + nextH > availableH)
break;
usedH += nextH;
--start;
if (usedH >= availableH)
break;
}
return start;
}
bool InkHUD::WaypointListApplet::rowIndexAt(int16_t y, uint8_t &indexOut)
{
const auto &waypoints = waypointStore.getWaypoints();
if (waypoints.empty())
return false;
const uint8_t start = std::min<uint8_t>(scrollOffset, (uint8_t)waypoints.size() - 1);
const uint8_t rows = visibleRows(start);
const uint8_t end = std::min<uint8_t>((uint8_t)waypoints.size(), start + rows);
// Walk the same row layout used by onRender, stopping at whichever row contains y
int16_t rowTop = getHeaderHeight() + 2;
for (uint8_t i = start; i < end; ++i) {
const uint8_t rowH = rowHeight(waypoints.at(i).waypoint);
if (y < rowTop + rowH) {
indexOut = i;
return true;
}
rowTop += rowH;
}
return false;
}
void InkHUD::WaypointListApplet::scrollBy(int delta)
{
const int next = std::clamp<int>((int)scrollOffset + delta, 0, maxScrollOffset());
if (next == scrollOffset)
return;
scrollOffset = (uint8_t)next;
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
}
void InkHUD::WaypointListApplet::onNavUp()
{
scrollBy(-1);
}
void InkHUD::WaypointListApplet::onNavDown()
{
scrollBy(1);
}
bool InkHUD::WaypointListApplet::onTouchPoint(uint16_t x, uint16_t y, bool longPress)
{
(void)x;
const auto &waypoints = waypointStore.getWaypoints();
if (waypoints.empty() || y < getHeaderHeight())
return false;
// Long press a row to delete that waypoint (broadcasts the deletion to the mesh too)
if (longPress) {
uint8_t index = 0;
if (!rowIndexAt(y, index))
return false;
if (waypointModule)
waypointModule->broadcastDelete(waypoints.at(index).waypoint.id);
return true;
}
const uint16_t midpoint = getHeaderHeight() + ((height() - getHeaderHeight()) / 2);
scrollBy(y < midpoint ? -1 : 1);
return true;
}
int InkHUD::WaypointListApplet::onWaypointStoreChanged(const WaypointStore *store)
{
(void)store;
if (!isActive())
return 0;
syncListState();
requestUpdate(Drivers::EInk::UpdateTypes::FAST);
return 0;
}
std::string InkHUD::WaypointListApplet::headerText()
{
const auto &waypoints = waypointStore.getWaypoints();
if (waypoints.empty())
return "Waypoints";
const uint8_t rows = visibleRows(scrollOffset);
const uint8_t first = scrollOffset + 1;
const uint8_t last = std::min<uint8_t>((uint8_t)waypoints.size(), scrollOffset + rows);
char buf[32];
snprintf(buf, sizeof(buf), "Waypoints %u-%u/%u", first, last, (unsigned)waypoints.size());
return buf;
}
std::string InkHUD::WaypointListApplet::waypointName(const meshtastic_Waypoint &waypoint)
{
if (waypoint.name[0])
return parse(waypoint.name);
char buf[20];
snprintf(buf, sizeof(buf), "Waypoint 0x%x", (unsigned)waypoint.id);
return buf;
}
std::string InkHUD::WaypointListApplet::waypointDescription(const meshtastic_Waypoint &waypoint)
{
if (!hasDescription(waypoint))
return "";
return parse(waypoint.description);
}
std::string InkHUD::WaypointListApplet::coordinateText(const meshtastic_Waypoint &waypoint, bool landscape)
{
if (!waypoint.has_latitude_i || !waypoint.has_longitude_i)
return "--";
const uint8_t decimals = landscape ? (width() >= 220 ? 4 : 3) : (width() >= 140 ? 3 : 2);
const double lat = waypoint.latitude_i * 1e-7;
const double lon = waypoint.longitude_i * 1e-7;
char buf[40];
snprintf(buf, sizeof(buf), "%.*f,%.*f", decimals, lat, decimals, lon);
return buf;
}
bool InkHUD::WaypointListApplet::tryGetOwnPosition(meshtastic_PositionLite &out)
{
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
return ourNode && nodeDB->copyNodePosition(ourNode->num, out) && (out.latitude_i != 0 || out.longitude_i != 0);
}
std::string InkHUD::WaypointListApplet::distanceText(const meshtastic_Waypoint &waypoint)
{
if (!waypoint.has_latitude_i || !waypoint.has_longitude_i)
return "";
meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero;
if (!tryGetOwnPosition(ownPos))
return "";
const float meters = GeoCoord::latLongToMeter(waypoint.latitude_i * 1e-7, waypoint.longitude_i * 1e-7,
ownPos.latitude_i * 1e-7, ownPos.longitude_i * 1e-7);
if (meters < 0)
return "";
return localizeDistance((uint32_t)std::lround(meters));
}
std::string InkHUD::WaypointListApplet::expireText(uint32_t expireEpoch)
{
if (expireEpoch == 0)
return "--";
const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice);
if (now == 0)
return "";
if (expireEpoch <= now)
return "exp";
const uint32_t left = expireEpoch - now;
char buf[12];
if (left < 3600)
snprintf(buf, sizeof(buf), "%lum", (unsigned long)((left + 59) / 60));
else if (left < 86400)
snprintf(buf, sizeof(buf), "%luh", (unsigned long)((left + 3599) / 3600));
else
snprintf(buf, sizeof(buf), "%lud", (unsigned long)((left + 86399) / 86400));
return buf;
}
uint32_t InkHUD::WaypointListApplet::nextExpiryUpdateMs(uint32_t secondsLeft)
{
if (secondsLeft < 60)
return secondsLeft * 1000UL;
const uint32_t step = (secondsLeft < 3600) ? 60UL : (secondsLeft < 86400 ? 3600UL : 86400UL);
return ((((secondsLeft - 1) % step) + 1) * 1000UL);
}
uint32_t InkHUD::WaypointListApplet::nextRefreshIntervalMs()
{
static constexpr uint32_t WAITING_STATE_REFRESH_MS = 1000UL;
static constexpr uint32_t GPS_DISTANCE_REFRESH_MS = 5000UL;
static constexpr uint32_t IDLE_REFRESH_MS = 60000UL;
uint32_t intervalMs = UINT32_MAX;
const uint32_t now = getValidTime(RTCQuality::RTCQualityDevice);
meshtastic_PositionLite ownPos = meshtastic_PositionLite_init_zero;
const bool haveOwnPos = tryGetOwnPosition(ownPos);
for (const StoredWaypoint &entry : waypointStore.getWaypoints()) {
const meshtastic_Waypoint &waypoint = entry.waypoint;
if (waypoint.expire != 0) {
if (now == 0) {
intervalMs = std::min(intervalMs, WAITING_STATE_REFRESH_MS);
} else if (waypoint.expire > now) {
intervalMs = std::min(intervalMs, nextExpiryUpdateMs(waypoint.expire - now));
}
}
if (waypoint.has_latitude_i && waypoint.has_longitude_i) {
if (!haveOwnPos) {
intervalMs = std::min(intervalMs, WAITING_STATE_REFRESH_MS);
} else if (!config.position.fixed_position) {
intervalMs = std::min(intervalMs, GPS_DISTANCE_REFRESH_MS);
}
}
}
if (intervalMs == UINT32_MAX)
return IDLE_REFRESH_MS;
return intervalMs;
}
uint32_t InkHUD::WaypointListApplet::buildRenderHash()
{
uint32_t hash = 2166136261u;
for (const StoredWaypoint &entry : waypointStore.getWaypoints()) {
const meshtastic_Waypoint &waypoint = entry.waypoint;
char idBuf[11];
snprintf(idBuf, sizeof(idBuf), "%lu", (unsigned long)waypoint.id);
hash = fnv1aAppend(hash, idBuf);
hash = fnv1aAppend(hash, "|");
const std::string distance = distanceText(waypoint);
hash = fnv1aAppend(hash, distance.c_str());
hash = fnv1aAppend(hash, "|");
const std::string expire = expireText(waypoint.expire);
hash = fnv1aAppend(hash, expire.c_str());
hash = fnv1aAppend(hash, ";");
}
return hash;
}
void InkHUD::WaypointListApplet::syncListState()
{
scrollOffset = std::min<uint8_t>(scrollOffset, maxScrollOffset());
hasRenderHash = false;
// Re-arm the timer whenever visible waypoint state changes.
updateRefreshTimer();
}
bool InkHUD::WaypointListApplet::canRenderWaypointIcon(const meshtastic_Waypoint &waypoint, std::string *mapped)
{
if (!waypoint.icon)
return false;
const std::string utf8 = WaypointUtils::utf8FromCodepoint(waypoint.icon);
if (utf8.empty())
return false;
const std::string glyph = getFont().decodeUTF8(utf8);
if (glyph.size() != 1 || glyph[0] == '\x1A' || glyph[0] == '\x7F')
return false;
if (mapped)
*mapped = glyph;
return true;
}
uint8_t InkHUD::WaypointListApplet::fallbackBadgeNumber(const meshtastic_Waypoint &waypoint)
{
uint8_t badge = 0;
for (auto it = waypointStore.getWaypoints().rbegin(); it != waypointStore.getWaypoints().rend(); ++it) {
const meshtastic_Waypoint &candidate = it->waypoint;
if (canRenderWaypointIcon(candidate))
continue;
if (candidate.id == waypoint.id)
return badge;
if (badge < 9)
++badge;
}
return 0;
}
bool InkHUD::WaypointListApplet::drawWaypointIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t centerY,
uint16_t boxSize)
{
std::string mappedGlyph;
if (!canRenderWaypointIcon(waypoint, &mappedGlyph))
return false;
printAt(left + (boxSize / 2), centerY, mappedGlyph, CENTER, MIDDLE);
return true;
}
void InkHUD::WaypointListApplet::drawFallbackIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t rowTop,
uint16_t boxWidth, uint16_t rowHeight)
{
char badgeText[3];
snprintf(badgeText, sizeof(badgeText), "%u", (unsigned)fallbackBadgeNumber(waypoint));
setFont(fontSmall);
const int16_t cx = left + (boxWidth / 2);
const uint16_t badgeTextW = std::max<uint16_t>(getTextWidth(badgeText), getTextWidth("0"));
const int16_t centerY = rowTop + (fontSmall.lineHeight() / 2) + 1;
const int16_t boxSize = std::max<int16_t>((int16_t)boxWidth, (int16_t)(badgeTextW + 5));
const int16_t radius = std::max<int16_t>(2, boxSize / 6);
const int16_t boxLeft = cx - (boxSize / 2);
const int16_t boxTop = centerY - (boxSize / 2);
// Match the boxed fallback marker style used on the map.
fillRoundRect(boxLeft, boxTop, boxSize, boxSize, radius, WHITE);
drawRoundRect(boxLeft, boxTop, boxSize, boxSize, radius, BLACK);
setCrop(left - 1, rowTop, boxWidth + 3, rowHeight);
printAt(cx, centerY + 1, badgeText, CENTER, MIDDLE);
resetCrop();
}
void InkHUD::WaypointListApplet::onRender(bool full)
{
(void)full;
const bool landscape = width() > height();
const auto &waypoints = waypointStore.getWaypoints();
drawHeader(headerText());
if (waypoints.empty()) {
setFont(fontMedium);
printAt(X(0.5f), Y(0.5f), "No Waypoints", CENTER, MIDDLE);
return;
}
setFont(fontSmall);
const int16_t contentTop = getHeaderHeight() + 2;
const uint8_t start = std::min<uint8_t>(scrollOffset, (uint8_t)waypoints.size() - 1);
const uint8_t rows = visibleRows(start);
const uint8_t end = std::min<uint8_t>((uint8_t)waypoints.size(), start + rows);
const uint16_t iconW = fontSmall.lineHeight();
const uint16_t gap = 2;
auto ellipsizeToWidth = [this](std::string text, uint16_t maxWidth) {
constexpr const char *ellipsis = "...";
const uint16_t ellipsisW = getTextWidth(ellipsis);
uint16_t textW = getTextWidth(text);
if (maxWidth == 0)
return std::string();
if (textW <= maxWidth)
return text;
if (ellipsisW > maxWidth)
return std::string();
while (!text.empty() && (textW + ellipsisW > maxWidth)) {
text.pop_back();
textW = getTextWidth(text);
}
return text + ellipsis;
};
int16_t rowTop = contentTop;
for (uint8_t i = start; i < end; ++i) {
const meshtastic_Waypoint &waypoint = waypoints.at(i).waypoint;
const uint8_t rowH = rowHeight(waypoint);
const int16_t line1Y = rowTop + (fontSmall.lineHeight() / 2) + 1;
const int16_t line2Y = rowTop + fontSmall.lineHeight() + 1;
const int16_t metaY =
rowTop + (hasDescription(waypoint) ? ((fontSmall.lineHeight() * 2) + 2) : (fontSmall.lineHeight() + 1));
if (!drawWaypointIcon(waypoint, 1, line1Y, iconW - 1))
drawFallbackIcon(waypoint, 0, rowTop, iconW, rowH);
const std::string name = waypointName(waypoint);
const std::string description = waypointDescription(waypoint);
const std::string distance = distanceText(waypoint);
const std::string coord = coordinateText(waypoint, landscape);
const std::string expire = expireText(waypoint.expire);
const int16_t nameLeft = iconW + gap;
int16_t nameRight = width() - 1;
if (!distance.empty()) {
printAt(nameRight, line1Y, distance, RIGHT, MIDDLE);
nameRight -= getTextWidth(distance) + gap;
}
const uint16_t nameWidth = (nameRight >= nameLeft) ? ((nameRight - nameLeft) + 1) : 0;
const std::string shown = ellipsizeToWidth(name, nameWidth);
const uint16_t shownWidth = getTextWidth(shown);
setCrop(nameLeft, rowTop, nameWidth, fontSmall.lineHeight() + 2);
printThick(nameLeft + (shownWidth / 2), line1Y, shown, 2, 1);
resetCrop();
if (!description.empty()) {
const std::string descShown = ellipsizeToWidth(description, nameWidth);
setCrop(nameLeft, line2Y - 1, nameWidth, fontSmall.lineHeight() + 2);
printAt(nameLeft, line2Y, descShown, LEFT, TOP);
resetCrop();
}
int16_t metaRight = width() - 1;
if (!expire.empty()) {
printAt(metaRight, metaY, expire, RIGHT, TOP);
metaRight -= getTextWidth(expire) + gap;
}
const uint16_t coordWidth = (metaRight >= nameLeft) ? ((metaRight - nameLeft) + 1) : 0;
const std::string coordShown = ellipsizeToWidth(coord, coordWidth);
setCrop(nameLeft, metaY - 1, coordWidth, fontSmall.lineHeight() + 2);
printAt(nameLeft, metaY, coordShown, LEFT, TOP);
resetCrop();
const int16_t separatorY = rowTop + rowH - 1;
if (separatorY < height() - 1 && i + 1 < end) {
for (int16_t x = 0; x < width(); x += 2)
drawPixel(x, separatorY, BLACK);
}
rowTop += rowH;
}
}
#endif
@@ -0,0 +1,78 @@
#if defined(MESHTASTIC_INCLUDE_INKHUD)
#pragma once
#include "configuration.h"
#include "Observer.h"
#include "WaypointStore.h"
#include "concurrency/OSThread.h"
#include "graphics/niche/InkHUD/Applet.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <string>
namespace NicheGraphics::InkHUD
{
class WaypointListApplet : public Applet, public concurrency::OSThread
{
public:
WaypointListApplet();
void onActivate() override;
void onDeactivate() override;
void onRender(bool full) override;
void onNavUp() override;
void onNavDown() override;
bool onTouchPoint(uint16_t x, uint16_t y, bool longPress) override;
WaypointListApplet *asWaypointListApplet() override { return this; } // Identify as WaypointListApplet without RTTI
// Read-only access for MenuApplet's "Remove Waypoint" page
size_t waypointCount() const { return waypointStore.getWaypoints().size(); }
uint32_t waypointIdAt(size_t index) const { return waypointStore.getWaypoints().at(index).waypoint.id; }
std::string waypointLabelAt(size_t index) { return waypointName(waypointStore.getWaypoints().at(index).waypoint); }
protected:
int32_t runOnce() override;
private:
void updateRefreshTimer();
uint8_t visibleRows(uint8_t start);
uint8_t rowHeight(const meshtastic_Waypoint &waypoint);
uint8_t maxScrollOffset();
void scrollBy(int delta);
bool rowIndexAt(int16_t y, uint8_t &indexOut); // Which waypoint row is at this y, if any
bool tryGetOwnPosition(meshtastic_PositionLite &out);
uint32_t nextExpiryUpdateMs(uint32_t secondsLeft);
uint32_t nextRefreshIntervalMs();
uint32_t buildRenderHash();
void syncListState();
std::string headerText();
std::string waypointName(const meshtastic_Waypoint &waypoint);
std::string waypointDescription(const meshtastic_Waypoint &waypoint);
std::string coordinateText(const meshtastic_Waypoint &waypoint, bool landscape);
std::string distanceText(const meshtastic_Waypoint &waypoint);
std::string expireText(uint32_t expireEpoch);
bool canRenderWaypointIcon(const meshtastic_Waypoint &waypoint, std::string *mapped = nullptr);
uint8_t fallbackBadgeNumber(const meshtastic_Waypoint &waypoint);
bool drawWaypointIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t centerY, uint16_t boxSize);
void drawFallbackIcon(const meshtastic_Waypoint &waypoint, int16_t left, int16_t rowTop, uint16_t boxWidth,
uint16_t rowHeight);
bool hasDescription(const meshtastic_Waypoint &waypoint);
int onWaypointStoreChanged(const WaypointStore *store);
CallbackObserver<WaypointListApplet, const WaypointStore *> waypointStoreObserver =
CallbackObserver<WaypointListApplet, const WaypointStore *>(this, &WaypointListApplet::onWaypointStoreChanged);
uint8_t scrollOffset = 0;
uint32_t lastRenderHash = 0;
bool hasRenderHash = false;
};
} // namespace NicheGraphics::InkHUD
#endif
+6 -2
View File
@@ -4,6 +4,8 @@
#include "MessageStore.h"
#include "PowerFSM.h"
#include "UptimeClock.h"
#include "WaypointStore.h"
#include "buzz.h"
#include "gps/RTC.h"
#include "modules/ExternalNotificationModule.h"
@@ -364,7 +366,7 @@ void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress)
// A long-press used to open the menu can be followed by a synthetic/queued tap at release.
// Ignore that brief follow-up window so touch-opened menus do not auto-select an item.
if (touchEnabledBuild && !longPress && suppressTouchTapUntilMs != 0) {
if ((int32_t)(millis() - suppressTouchTapUntilMs) < 0) {
if ((int32_t)(Time::getMillis() - suppressTouchTapUntilMs) < 0) {
noteInkHUDUserInteraction();
return;
}
@@ -400,7 +402,7 @@ void InkHUD::Events::onTouchTap(uint16_t x, uint16_t y, bool longPress)
// Only arm suppression if the long-press actually opened menu foreground.
SystemApplet *menu = inkhud->getSystemApplet("Menu");
if (touchEnabledBuild && menu && menu->isForeground()) {
suppressTouchTapUntilMs = millis() + TOUCH_MENU_OPEN_TAP_SUPPRESS_MS;
suppressTouchTapUntilMs = Time::timerEndsAtMillis(TOUCH_MENU_OPEN_TAP_SUPPRESS_MS);
}
} else
onButtonShort();
@@ -468,6 +470,7 @@ int InkHUD::Events::beforeDeepSleep(void *unused)
inkhud->persistence->saveSettings();
inkhud->persistence->saveLatestMessage();
waypointStore.saveToFlash();
// LogoApplet::onShutdown attempted to heal the display by drawing a "shutting down" screen twice,
// then prepared a final powered-off screen for us, which shows device shortname.
@@ -516,6 +519,7 @@ int InkHUD::Events::beforeReboot(void *unused)
} else {
NicheGraphics::clearFlashData();
messageStore.clearAllMessages(); // also wipe the shared message store
waypointStore.clearAllWaypoints();
}
// Note: no forceUpdate call here
+1 -1
View File
@@ -80,7 +80,7 @@ void ExpressLRSFiveWay::update(int *keyValue, bool *keyLongPressed)
if (keyInProcess == NO_PRESS) {
// New key down
if (newKey != NO_PRESS) {
keyDownStart = Time::getMillis();
keyDownStart = Time::skipZero(Time::getMillis());
// DBGLN("down=%u", newKey);
}
} else {
Loaded 100 of 413 files, more files were not shown because too many files have changed in this diff. Show more