mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-22 06:15:25 -04:00
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:
413 files changed
+16989
-7926
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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> — they cover what changed and how to report what you find.
|
||||
</p>
|
||||
|
||||
<h2>Files</h2>
|
||||
<div class="tree">
|
||||
@@ -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>
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Executable
+36
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Executable
+443
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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_"):
|
||||
|
||||
Executable
+481
@@ -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
|
||||
Executable
+267
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"f_boot": "120000000L",
|
||||
"boot": "qio",
|
||||
"flash_mode": "qio",
|
||||
"psram_type": "opi",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Vendored
+24
-8
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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]
|
||||
|
||||
+1
-1
Submodule protobufs updated: aca181b97b...723a31e420.
+23
-15
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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); }
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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 &);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// =============================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user